iosswiftuitextfielduistackviewbackspace

UITextField backspace not working correctly for the first placeholder in a 6-digit verification code input in Swift


I am building an iPhone app where users can enter a 6-digit verification code, each digit in a separate UITextField. The UI is created programmatically with UIStackView to arrange the text fields horizontally.

However, I am facing an issue with the backspace functionality. When the cursor is in the second placeholder and the backspace is pressed, the cursor should move to the first placeholder and delete its content. It unfortunately does not work. This works for other text fields but not for the first placeholder.

Code: import UIKit

class SignUpPhoneVerificationViewController: UIViewController, UITextFieldDelegate {
    
    let codeTextFieldStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.distribution = .fillEqually
        stackView.spacing = 10
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
    
    private func setupUI() {
        view.addSubview(codeTextFieldStackView)
        
        for i in 0..<6 {
            let textField = BackspaceTextField()
            textField.borderStyle = .roundedRect
            textField.textAlignment = .center
            textField.font = UIFont.systemFont(ofSize: 24)
            textField.keyboardType = .numberPad
            textField.delegate = self
            textField.tag = i
            textField.translatesAutoresizingMaskIntoConstraints = false
            codeTextFieldStackView.addArrangedSubview(textField)
        }

        // Set up constraints for codeTextFieldStackView (not shown for brevity)
    }

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        // Only allow numbers
        guard CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) || string.isEmpty else {
            return false
        }

        if string.isEmpty {
            // Backspace was pressed
            if textField.text?.isEmpty ?? true {
                if let previousTextField = view.viewWithTag(textField.tag - 1) as? UITextField {
                    previousTextField.text = ""
                    previousTextField.becomeFirstResponder()
                } else {
                    // Move cursor to the first text field if it's the second field
                    if textField.tag == 1, let firstTextField = view.viewWithTag(0) as? UITextField {
                        firstTextField.text = ""
                       firstTextField.becomeFirstResponder()
                    }
                }
            } else {
                textField.text = ""
            }
            return false
        }

        if let text = textField.text, (text + string).count > 1 {
            return false
        }

        textField.text = string
        let nextTag = textField.tag + 1
        if let nextResponder = view.viewWithTag(nextTag) {
            nextResponder.becomeFirstResponder()
        } else {
            textField.resignFirstResponder()
        }
        return false
    }
}

class BackspaceTextField: UITextField {
    override func deleteBackward() {
        if text?.isEmpty ?? true {
            if let previousTextField = superview?.viewWithTag(tag - 1) as? UITextField {
                previousTextField.text = ""
                previousTextField.becomeFirstResponder()
            } else {
                // Move cursor to the first text field if it's the second field
                if tag == 1, let firstTextField = superview?.viewWithTag(0) as? UITextField {
                    firstTextField.text = ""
                    firstTextField.becomeFirstResponder()
                } else {
                    super.deleteBackward()
                }
            }
        } else {
           text = ""
        }
    }
}
  1. Implemented a custom UITextField class (BackspaceTextField) to handle backspace key presses.
  2. Added a "Clear" button to reset all text fields, but the issue persisted.

Expectation: When the cursor is in the second placeholder and the backspace key is pressed, the cursor should move to the first placeholder and clear its content.

Actual Action: The cursor does not move to the first placeholder and clear its content. The first placeholder retains its previous value.


Solution

  • Using the .tag property is generally a bad idea... it can be useful, but it can also result in unexpected problems.

    By default, UI elements inheriting from UIView (which is pretty much every element, including UITextField), have a default .tag of 0

    From Apple's docs:

    Discussion

    This method searches the current view and all of its subviews for the specified view.

    So, superview?.viewWithTag(0) returns the superview.

    If you want to keep using .tag to track your text fields, change this line:

    textField.tag = i
    

    to:

    textField.tag = i + 1
    

    Now your text field tags will be 1 to 6 instead of 0 to 5.


    Another (perhaps better) approach would be to:

    Something along these lines:

    class BackspaceTextField: UITextField {
        
        override func deleteBackward() {
            
            // in case we moved the caret in front of the entered digit
            if !(text ?? "").isEmpty {
                text = ""
                return
            }
            
            // make sure we have a superview
            guard let sv = superview else {
                text = ""
                return
            }
            
            // get array of superview's subviews that are BackspaceTextField
            let rtfArray = sv.subviews.compactMap{$0 as? BackspaceTextField}
            
            guard !rtfArray.isEmpty else {
                text = ""
                return
            }
            
            // get index of self, and make sure it's not the first text field
            guard let idx = rtfArray.firstIndex(of: self),
                  idx > 0
            else {
                text = ""
                return
            }
            
            // clear the text of the previous field and set it as first responder
            rtfArray[idx - 1].text = ""
            rtfArray[idx - 1].becomeFirstResponder()
        }
        
    }