iosswiftbecomefirstresponder

How to change firstResponder with buttons on keyboard toolbar between UITextFields in UITableViewCells


I guess that it is a low brainer I'm struggling with, but unfortunately all my searches in this forum and other sources didn't give me a glue yet.

I'm creating a shopping list app for iOS. In the Viewcontroller for the entry of the shoppinglist positions I'm showing only the relevant entry fields depending on the kind of goods to be put on the shopping list.

Hence I have set up a tableView with different prototype cells and some of them contain UITextFields to handle this dynamic setup.

I have defined a toolbar for the keyboard containing one button at the right to hide the keyboard (which works) and two buttons ("next" & "back") on the left to jump to the next respectively previous input field, which should then become first responder, cursor set in this field and showing the keyboard.

layout

Unfortunately this handing over of the firstResponder isn't working and the cursor is not set to the next/previous input field and sometimes even the keyboard disappears.

Jumping back doesn't work at all and the keyboard disappears always when the next active field is part of a different prototype cell (e.g. moving forward from the field for "brand" to the field for "quantity".

Has anyone a solution for it?

For the handling I have defined two notifications:

let keyBoardBarBackNotification = Notification.Name("keyBoardBarBackNotification")
let keyBoardBarNextNotification = Notification.Name("keyBoardBarNextNotification")

And the definition of the toolbar is done in the extension of UIViewController:

func setupKeyboardBar() -> UIToolbar {
    let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: 50))
    let leftButton = UIBarButtonItem(image: UIImage(systemName: "chevron.left"), style: .plain, target: self, action: #selector(leftButtonTapped))
    leftButton.tintColor = UIColor.systemBlue
    let nextButton = UIBarButtonItem(image: UIImage(systemName: "chevron.right"), style: .plain, target: self, action: #selector(nextButtonTapped))
    nextButton.tintColor = UIColor.systemBlue
    let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
    let fixSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
    let doneButton = UIBarButtonItem(image: UIImage(systemName: "keyboard.chevron.compact.down"), style: .plain, target: self, action: #selector(doneButtonTapped))
    doneButton.tintColor = UIColor.darkGray
    toolbar.setItems([fixSpace, leftButton, fixSpace, nextButton, flexSpace, doneButton], animated: true)
    toolbar.sizeToFit()
    return toolbar
}

@objc func leftButtonTapped() {
    view.endEditing(true)
    NotificationCenter.default.post(Notification(name: keyBoardBarBackNotification))
}

@objc func nextButtonTapped() {
    view.endEditing(true)
    NotificationCenter.default.post(Notification(name: keyBoardBarNextNotification))
}

@objc func doneButtonTapped() {
    view.endEditing(true)
}

}

In the viewController I have setup routines for the keyboard handling and a routine "switchActiveField" to determine the next actual field that should become the firstResponder:

class AddPositionVC: UIViewController {

@IBOutlet weak var menue: UITableView!


override func viewDidLoad() {
    super.viewDidLoad()
    self.menue.delegate = self
    self.menue.dataSource = self
    self.menue.separatorStyle = .none
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    NotificationCenter.default.addObserver(self, selector: #selector(handleKeyboardDidShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(handleKeyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(handleBackButtonPressed), name: keyBoardBarBackNotification, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(handleNextButtonPressed), name: keyBoardBarNextNotification, object: nil)
}


enum TableCellType: String {
    case product = "Product:"
    case brand = "Brand:"
    case quantity = "Quantity:"
    case price = "Price:"
    case shop = "Shop:"
    // ...
}

var actualField = TableCellType.product  // field that becomes firstResponder

// Arrray, defining the fields to be diplayed
var menueList: Array<TableCellType> = [.product, .brand, .quantity, .shop
]

// Array with IndexPath of displayed fields
var tableViewIndex = Dictionary<TableCellType, IndexPath>()

@objc func handleKeyboardDidShow(notification: NSNotification) {
    guard let endframeKeyboard = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey]
                as? CGRect else { return }
    let insets = UIEdgeInsets( top: 0, left: 0, bottom: endframeKeyboard.size.height - 60, right: 0 )
    self.menue.contentInset = insets
    self.menue.scrollIndicatorInsets = insets
    self.scrollToMenuezeile(self.actualField)
    self.view.layoutIfNeeded()
}

@objc func handleKeyboardWillHide()  {
    self.menue.contentInset = .zero
    self.view.layoutIfNeeded()
}

@objc func handleBackButtonPressed() {
    switchActiveField(self.actualField, back: true)
}

@objc func handleNextButtonPressed() {
    switchActiveField(self.actualField, back: false)
}

// Definition, which field should become next firstResponder
func switchActiveField(_ art: TableCellType, back bck: Bool) {
    switch art {
    case .brand:
        self.actualField = bck ? .product : .quantity
    case .quantity:
        self.actualField = bck ? .brand : .shop
    case .price:
        self.actualField = bck ? .quantity : .shop
    case .product:
        self.actualField = bck ? .shop : .brand
    case .shop:
        self.actualField = bck ? .price : .product
    // ....
    }
    if let index = self.tableViewIndex[self.actualField] {
            self.menue.reloadRows(at: [index], with: .automatic)
    }
}

}

And the extension for the tableView is:

extension AddPositionVC: UITableViewDelegate, UITableViewDataSource {
    
    func scrollToMenuezeile(_ art: TableCellType) {
        if let index = self.tableViewIndex[art] {
            self.menue.scrollToRow(at: index, at: .bottom, animated: false)
        }
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return menueList.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let tableCellType = self.menueList[indexPath.row]
        self.tableViewIndex[tableCellType] = indexPath
        switch tableCellType {
        case .product, .brand, .shop:
            let cell = tableView.dequeueReusableCell(withIdentifier: "LabelTextFieldCell", for: indexPath) as! LabelTextFieldCell
            cell.item.text = tableCellType.rawValue
            cell.itemInput.inputAccessoryView = self.setupKeyboardBar()
            cell.itemInput.text = "" // respective Input
            if self.actualField == tableCellType {
                cell.itemInput.becomeFirstResponder()
            }
            return cell
        case .quantity, .price:
            let cell = tableView.dequeueReusableCell(withIdentifier: "QuantityPriceCell", for: indexPath) as! QuantityPriceCell
            cell.quantity.inputAccessoryView = self.setupKeyboardBar()
            cell.quantity.text = "" // respective Input
            cell.price.inputAccessoryView = self.setupKeyboardBar()
            cell.price.text = "" // respective Input
            if self.actualField == .price {
                cell.price.becomeFirstResponder()
            } else if self.actualField == .quantity {
                cell.quantity.becomeFirstResponder()
            }
            return cell
        }
    }
}


//*********************************************
// MARK: - tableViewCells
//*********************************************


class LabelTextFieldCell: UITableViewCell, UITextFieldDelegate {
    
    override func awakeFromNib() {
        super.awakeFromNib()
        itemInput.delegate = self
    }
    
    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }
    
    func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
        self.itemInput.resignFirstResponder()
    }
    
    @IBOutlet weak var item: UILabel!
    @IBOutlet weak var itemInput: UITextField!
}


class QuantityPriceCell: UITableViewCell, UITextFieldDelegate {
    
    override func awakeFromNib() {
        super.awakeFromNib()
        self.quantity.delegate = self
        self.price.delegate = self
    }
    
    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }
    
    func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
        textField.resignFirstResponder()
    }
    
    @IBOutlet weak var quantity: UITextField!
    @IBOutlet weak var price: UITextField!
    
}

Thanks for your support.


Solution

  • There are various ways to approach this... In fact, it's easy to find open-source 3rd-party libraries with lots of features -- just search (Google or wherever) for swift ios form builder.

    But, if you'd like to work on it on your own, the basic idea is:

    If all your fields are "on-screen" it's pretty straight-forward.

    If they won't fit vertically (particularly when the keyboard is showing), if they're all in a scroll view, again, pretty straight-forward.

    It gets complicated when putting them in cells in a tableView, for several reasons:

    To add repeating similar-but-varying "rows," we don't need to use a table view.

    For example, if we have a UIStackView with .axis = .vertical:

    for i in 1...10 {
        let label = UILabel()
        label.text = "Row \(i)"
        stackView.addArrangedSubview(label)
    }
    

    We've now added 10 single-label "cells."

    So, for your task, instead of using a table view with your LabelTextFieldCell, we can write this function:

    func buildLabelTextFieldView(labelText str: String) -> UIView {
        let aView = UIView()
        
        let label: UILabel = {
            let v = UILabel()
            v.font = .systemFont(ofSize: 15.0, weight: .light)
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        let field: UITextField = {
            let v = UITextField()
            v.borderStyle = .bezel
            v.font = .systemFont(ofSize: 15.0, weight: .light)
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        
        label.text = str
        
        self.textFields.append(field)
        
        aView.addSubview(label)
        aView.addSubview(field)
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: aView.leadingAnchor, constant: 0.0),
            label.firstBaselineAnchor.constraint(equalTo: field.firstBaselineAnchor),
            field.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8.0),
            field.trailingAnchor.constraint(equalTo: aView.trailingAnchor, constant: 0.0),
            field.topAnchor.constraint(equalTo: aView.topAnchor, constant: 0.0),
            field.bottomAnchor.constraint(equalTo: aView.bottomAnchor, constant: 0.0),
        ])
        return aView
    }
    

    and a similar (but slightly more complex):

    func buildQuantityPriceView() -> UIView {
        let aView = UIView()
        ...
        return aView
    }
    

    then use it similarly to cellForRowAt:

        for i in 0..<menueList.count {
            let tableCellType = menueList[i]
            
            var rowView: UIView!
            
            switch tableCellType {
                
            case .product, .brand, .shop:
                rowView = buildLabelTextFieldView(labelText: tableCellType.rawValue)
    
            case .quantity, .price:
                rowView = buildQuantityPriceView()
                
            }
        
            stackView.addArrangedSubview(rowView)
        }
        
    

    If we add that stackView to a scrollView, we have a scrollable "Form."

    Here's a complete example you can try out (no @IBOutlet or @IBAction connections ... just set a blank view controller's class to FormVC):

    class FormVC: UIViewController, UITextFieldDelegate {
        
        var textFields: [UITextField] = []
        
        let scrollView = UIScrollView()
        
        var menueList: Array<TableCellType> = [.product, .brand, .quantity, .shop]
        
        lazy var kbToolBar: UIToolbar = {
            let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: 50))
            let leftButton = UIBarButtonItem(image: UIImage(systemName: "chevron.left"), style: .plain, target: self, action: #selector(leftButtonTapped))
            leftButton.tintColor = UIColor.systemBlue
            let nextButton = UIBarButtonItem(image: UIImage(systemName: "chevron.right"), style: .plain, target: self, action: #selector(nextButtonTapped))
            nextButton.tintColor = UIColor.systemBlue
            let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
            let fixSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
            let doneButton = UIBarButtonItem(image: UIImage(systemName: "keyboard.chevron.compact.down"), style: .plain, target: self, action: #selector(doneButtonTapped))
            doneButton.tintColor = UIColor.darkGray
            toolbar.setItems([fixSpace, leftButton, fixSpace, nextButton, flexSpace, doneButton], animated: true)
            toolbar.sizeToFit()
            return toolbar
        }()
        
        var activeField: UITextField?
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let stackView = UIStackView()
            
            stackView.axis = .vertical
            stackView.spacing = 32
            
            stackView.translatesAutoresizingMaskIntoConstraints = false
            scrollView.addSubview(stackView)
            
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(scrollView)
            
            let g = view.safeAreaLayoutGuide
            let cg = scrollView.contentLayoutGuide
            let fg = scrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
                
                scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 16.0),
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 16.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -16.0),
                scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -16.0),
                
                stackView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
                stackView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
                stackView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
                stackView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
                
                stackView.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -16.0),
                
            ])
            
            for i in 0..<menueList.count {
                let tableCellType = menueList[i]
                
                var rowView: UIView!
                
                switch tableCellType {
                    
                case .product, .brand, .shop:
                    rowView = buildLabelTextFieldView(labelText: tableCellType.rawValue)
    
                case .quantity, .price:
                    rowView = buildQuantityPriceView()
                    
                }
            
                stackView.addArrangedSubview(rowView)
            }
            
            // we've added all the labels and fields
            //  and our textFields array contains all the fields in order
            
            // we want all the "first/left" labels to be equal widths
            guard let firstLabel = stackView.arrangedSubviews.first?.subviews.first as? UILabel
            else  {
                fatalError("We did something wrong in our setup!")
            }
            stackView.arrangedSubviews.forEach { v in
                // skip the first one
                if v != stackView.arrangedSubviews.first {
                    if let thisLabel = v.subviews.first as? UILabel {
                        thisLabel.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
                    }
                }
            }
            
            // set inputAccessoryView and delegate on all the text fields
            textFields.forEach { v in
                v.inputAccessoryView = kbToolBar
                v.delegate = self
            }
            
            // prevent keyboard from hiding scroll view elements
            let notificationCenter = NotificationCenter.default
            notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
            notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
            
            // during dev, use "if true" and set some colors so we can see view framing
            if false {
                view.backgroundColor = .systemYellow
                scrollView.backgroundColor = .yellow
                stackView.layer.borderColor = UIColor.red.cgColor
                stackView.layer.borderWidth = 1
                stackView.arrangedSubviews.forEach { v in
                    v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
                }
            }
            
        }
        
        @objc func leftButtonTapped() {
            guard let aField = self.activeField,
                  let idx = self.textFields.firstIndex(of: aField)
            else { return }
            if idx == 0 {
                textFields.last?.becomeFirstResponder()
            } else {
                textFields[idx - 1].becomeFirstResponder()
            }
        }
        
        @objc func nextButtonTapped() {
            guard let aField = self.activeField,
                  let idx = self.textFields.firstIndex(of: aField)
            else { return }
            if idx == self.textFields.count - 1 {
                textFields.first?.becomeFirstResponder()
            } else {
                textFields[idx + 1].becomeFirstResponder()
            }
        }
        
        @objc func doneButtonTapped() {
            view.endEditing(true)
        }
    
        func textFieldDidBeginEditing(_ textField: UITextField) {
            self.activeField = textField
        }
        func textFieldDidEndEditing(_ textField: UITextField) {
            self.activeField = nil
        }
    
        @objc func adjustForKeyboard(notification: Notification) {
            guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
            
            let keyboardScreenEndFrame = keyboardValue.cgRectValue
            let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
            
            if notification.name == UIResponder.keyboardWillHideNotification {
                self.scrollView.contentInset = .zero
            } else {
                self.scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0)
            }
            
            self.scrollView.scrollIndicatorInsets = self.scrollView.contentInset
        }
    }
    

    We'll put our "Row View" builder funcs in extensions, just to keep the code separated and a bit more readable:

    extension FormVC {
        func buildLabelTextFieldView(labelText str: String) -> UIView {
            let aView = UIView()
            
            let label: UILabel = {
                let v = UILabel()
                v.font = .systemFont(ofSize: 15.0, weight: .light)
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            let field: UITextField = {
                let v = UITextField()
                v.borderStyle = .bezel
                v.font = .systemFont(ofSize: 15.0, weight: .light)
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            
            label.text = str
            
            self.textFields.append(field)
            
            aView.addSubview(label)
            aView.addSubview(field)
            NSLayoutConstraint.activate([
                label.leadingAnchor.constraint(equalTo: aView.leadingAnchor, constant: 0.0),
                label.firstBaselineAnchor.constraint(equalTo: field.firstBaselineAnchor),
                field.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8.0),
                field.trailingAnchor.constraint(equalTo: aView.trailingAnchor, constant: 0.0),
                field.topAnchor.constraint(equalTo: aView.topAnchor, constant: 0.0),
                field.bottomAnchor.constraint(equalTo: aView.bottomAnchor, constant: 0.0),
            ])
            return aView
        }
    }
    
    extension FormVC {
        func buildQuantityPriceView() -> UIView {
            let aView = UIView()
            
            let labelA: UILabel = {
                let v = UILabel()
                v.font = .systemFont(ofSize: 15.0, weight: .light)
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            let fieldA: UITextField = {
                let v = UITextField()
                v.borderStyle = .bezel
                v.font = .systemFont(ofSize: 15.0, weight: .light)
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            
            labelA.text = "Quantity:"
            
            self.textFields.append(fieldA)
            
            let labelB: UILabel = {
                let v = UILabel()
                v.font = .systemFont(ofSize: 15.0, weight: .light)
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            let fieldB: UITextField = {
                let v = UITextField()
                v.borderStyle = .bezel
                v.font = .systemFont(ofSize: 15.0, weight: .light)
                v.translatesAutoresizingMaskIntoConstraints = false
                return v
            }()
            
            labelB.text = "Price:"
            
            self.textFields.append(fieldB)
            
            aView.addSubview(labelA)
            aView.addSubview(fieldA)
            aView.addSubview(labelB)
            aView.addSubview(fieldB)
            NSLayoutConstraint.activate([
                labelA.leadingAnchor.constraint(equalTo: aView.leadingAnchor, constant: 0.0),
                labelA.firstBaselineAnchor.constraint(equalTo: fieldA.firstBaselineAnchor),
                fieldA.leadingAnchor.constraint(equalTo: labelA.trailingAnchor, constant: 8.0),
                fieldA.topAnchor.constraint(equalTo: aView.topAnchor, constant: 0.0),
                fieldA.bottomAnchor.constraint(equalTo: aView.bottomAnchor, constant: 0.0),
                labelB.leadingAnchor.constraint(equalTo: fieldA.trailingAnchor, constant: 8.0),
                labelB.firstBaselineAnchor.constraint(equalTo: fieldB.firstBaselineAnchor),
                fieldB.leadingAnchor.constraint(equalTo: labelB.trailingAnchor, constant: 8.0),
                fieldB.topAnchor.constraint(equalTo: aView.topAnchor, constant: 0.0),
                fieldB.bottomAnchor.constraint(equalTo: aView.bottomAnchor, constant: 0.0),
                fieldB.trailingAnchor.constraint(equalTo: aView.trailingAnchor, constant: 0.0),
                
                // we want both fields to be equal widths
                fieldB.widthAnchor.constraint(equalTo: fieldA.widthAnchor),
            ])
            return aView
        }
    }
    

    When running, it looks like this:

    enter image description here

    If you add some more "rows" - or, easier, increase the stack view spacing, such as stackView.spacing = 100 - you'll see how it continues to work with the scrollView when the keyboard is showing.

    Of course, you mention in your comments: "...more entry fields (e.g. date with a Datepicker, etc.)", so you'd need to write new "row builder" funcs and add some logic to Next tap going to/from a Picker instead of a textField.

    But, you may find this a helpful starting point.