iosswiftmobileuikittableview

How to avoid ghost values being recorded while using textfields in UITableView


quantityTextfields are getting ghost values (values that are not putted by user in text field) Even when only one text field is changed, I think this is because how TableView Cells are reused, but how to resolve it

Below is the declaration of quantityTextFields

  private var quantityTextFields: [Int:[String: UITextField]] = [:] // Map material codes to text fields




extension CompetitorSaleThruVC: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    selectedMaterials.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
   let cell = tblView.dequeueReusableCell(withIdentifier: "competitorCell", for: indexPath) as! CompetitorCell
    
    let material = selectedMaterials[indexPath.row]
    
    cell.quantityTextField.text = ""
   
    cell.label.text = material.value
    
    //quantityTextFields[material.value] = cell.quantityTextField
    
 
    quantityTextFields[indexPath.row] = [material.value:cell.quantityTextField]
  

    
   // cell.quantityTextField.text = textFieldValues[indexPath.row]
    cell.quantityTextField.tag = indexPath.row
    
    cell.selectionStyle = .none
    
    cell.quantityTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
    
    cell.quantityTextField.delegate = self
    
    return cell
    
    if navigatedFromCompetitorDisplayCell == true && material.value == competitorMaterial {
        cell.quantityTextField.text = String(compSaleQuantity)
    }
    
    
    
    
}
}

Solution

  • Cells are reused ... trying to track a UI element (such as a text field) in a cell and handling its changes the way you are doing it is going to be prone to errors and "mis-matches."

    A much better approach is to handle the text field editing in your cell class, and then use a "callback" closure to let the controller know the field has been edited.

    So, we add a closure like this to the cell class:

    class CompetitorCell: UITableViewCell, UITextFieldDelegate {
        
        // "callback" closure
        var quantityEdited: ((CompetitorCell, Int) -> ())?
    

    In the cell class, we add the target and handle editing:

    // in cell init
    {
        // action when field is edited
        quantityTextField.addTarget(self, action: #selector(edited(_:)), for: .editingChanged)
    }   
    
    @objc func edited(_ sender: UITextField) {
        let str = sender.text ?? ""
        // execute the "callback" closure
        self.quantityEdited?(self, Int(str) ?? 0)
    }
    

    Then, in your controller in cellForRowAt:

        // set the "callback" closure
        cell.quantityEdited = { [weak self] theCell, theValue in
            
            // safely unwrap optionals
            guard let self = self else { return }
            guard let indexPath = self.tableView.indexPath(for: theCell) else { return }
            
            // update our data
            self.materials[indexPath.row].quantity = theValue
            
        }
    

    Here is a complete example - no @IBOutlet or @IBAction connections... just assign a blank view controller's custom class to CompTableVC


    Simple Data Struct

    struct Material {
        var name: String = ""
        var quantity: Int = 0
    }
    

    Cell Class - with label and text field

    class CompetitorCell: UITableViewCell, UITextFieldDelegate {
        
        // "callback" closure
        var quantityEdited: ((CompetitorCell, Int) -> ())?
        
        static let identifier: String = "CompetitorCell"
        
        let label = UILabel()
        let quantityTextField = UITextField()
        
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            label.translatesAutoresizingMaskIntoConstraints = false
            quantityTextField.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(label)
            contentView.addSubview(quantityTextField)
            
            let g = contentView.layoutMarginsGuide
            NSLayoutConstraint.activate([
                
                label.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                label.heightAnchor.constraint(equalToConstant: 42.0),
    
                quantityTextField.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 8.0),
                
                quantityTextField.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                quantityTextField.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                quantityTextField.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
                
            ])
            
            quantityTextField.borderStyle = .roundedRect
            quantityTextField.keyboardType = .numberPad
            
            // action when field is edited
            quantityTextField.addTarget(self, action: #selector(edited(_:)), for: .editingChanged)
            
            // so we can see the framing
            label.backgroundColor = .cyan
            quantityTextField.backgroundColor = .yellow
            
        }
        
        @objc func edited(_ sender: UITextField) {
            let str = sender.text ?? ""
            // execute the "callback" closure
            self.quantityEdited?(self, Int(str) ?? 0)
        }
    }
    

    Sample Controller Class

    class CompTableVC: UIViewController, UITableViewDataSource, UITableViewDelegate {
        
        var materials: [Material] = []
        
        let tableView = UITableView()
        let totalLabel = UILabel()
        var doneButton: UIButton!
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
    
            var cfg = UIButton.Configuration.filled()
            cfg.title = "Done"
            doneButton = UIButton(configuration: cfg, primaryAction: UIAction() { _ in
                self.view.endEditing(true)
            })
            
            totalLabel.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(totalLabel)
            
            doneButton.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(doneButton)
            
            tableView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(tableView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                totalLabel.centerYAnchor.constraint(equalTo: doneButton.centerYAnchor, constant: 0.0),
                totalLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                totalLabel.heightAnchor.constraint(equalTo: doneButton.heightAnchor),
    
                doneButton.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                doneButton.leadingAnchor.constraint(equalTo: totalLabel.trailingAnchor, constant: 20.0),
                doneButton.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
    
                tableView.topAnchor.constraint(equalTo: doneButton.bottomAnchor, constant: 20.0),
                
                tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
                
            ])
            
            totalLabel.backgroundColor = .systemRed
            totalLabel.textColor = .white
            
            tableView.register(CompetitorCell.self, forCellReuseIdentifier: CompetitorCell.identifier)
            tableView.dataSource = self
            tableView.delegate = self
            
            // let's create some sample data
            materials = (0..<20).map {
                let formatter = NumberFormatter()
                formatter.numberStyle = .spellOut
                var m: Material = Material()
                m.name = formatter.string(for: $0)!
                m.quantity = 1
                return m
            }
            
            // handle keyboard covering cells...
            NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
            NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
            
            // only show "Done" button if editing is active
            doneButton.isHidden = true
            
            // update the "Total" label
            updateTotal()
        }
        
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return materials.count
        }
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: CompetitorCell.identifier, for: indexPath) as! CompetitorCell
            
            // set the label and text field
            cell.label.text = "Material: " + materials[indexPath.row].name
            let q = materials[indexPath.row].quantity
            cell.quantityTextField.text = q > 0 ? "\(q)" : ""
            
            // set the "callback" closure
            cell.quantityEdited = { [weak self] theCell, theValue in
                
                // safely unwrap optionals
                guard let self = self else { return }
                guard let indexPath = self.tableView.indexPath(for: theCell) else { return }
                
                // update our data
                self.materials[indexPath.row].quantity = theValue
                
                // update "Total" label
                self.updateTotal()
                
            }
            
            return cell
        }
        
        func updateTotal() {
            let t = materials.reduce(0) { $0 + $1.quantity }
            totalLabel.text = "Total: \(t)"
        }
        
        @objc func keyboardWillShow(notification: NSNotification) {
            guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
            
            // Get the size of the keyboard
            let keyboardHeight = keyboardFrame.height
            
            // Adjust the tableView's contentInset and scrollIndicatorInsets
            let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom: keyboardHeight, right: 0)
            tableView.contentInset = contentInsets
            tableView.scrollIndicatorInsets = contentInsets
            
            // Scroll to the active text field if it's hidden by the keyboard
            if let activeTextField = findActiveTextField(),
               let cell = activeTextField.superview?.superview as? UITableViewCell,
               let indexPath = tableView.indexPath(for: cell) {
                tableView.scrollToRow(at: indexPath, at: .middle, animated: true)
            }
            
            self.doneButton.isHidden = false
        }
        
        @objc func keyboardWillHide(notification: NSNotification) {
            // Reset the tableView's contentInset and scrollIndicatorInsets
            let contentInsets = UIEdgeInsets.zero
            tableView.contentInset = contentInsets
            tableView.scrollIndicatorInsets = contentInsets
    
            self.doneButton.isHidden = true
        }
        
        func findActiveTextField() -> UITextField? {
            for cell in tableView.visibleCells {
                if let textField = cell.contentView.subviews.compactMap({ $0 as? UITextField }).first(where: { $0.isFirstResponder }) {
                    return textField
                }
            }
            return nil
        }
    }
    

    Looks like this when running:

    enter image description here

    enter image description here

    You'll be able to edit the "quantity" in each cell ... it will update the data source ... and you can scroll up and down without the "ghost values" problem.