如何通过键盘工具栏按钮切换UITableViewCell中UITextField的第一响应者
问题描述
开发iOS购物清单应用时,商品录入页面的UITableView根据商品类型动态展示不同原型单元格,部分单元格包含UITextField用于输入。为键盘添加了带返回、前进、完成按钮的工具栏,完成按钮可正常隐藏键盘,但前进/返回按钮无法正确切换UITextField成为firstResponder:光标未定位到目标输入框,切换不同原型单元格的输入框时键盘消失,返回功能完全失效。
原实现代码
全局通知定义
let keyBoardBarBackNotification = Notification.Name("keyBoardBarBackNotification") let keyBoardBarNextNotification = Notification.Name("keyBoardBarNextNotification")
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) }
视图控制器核心逻辑
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 // 当前成为firstResponder的字段 // 定义要显示的字段数组 var menueList: Array<TableCellType> = [.product, .brand, .quantity, .shop] // 存储字段对应的IndexPath 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) } // 定义下一个要成为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) } }
UITableView代理实现
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 = "" // 此处会清空输入内容 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 = "" // 此处会清空输入内容 cell.price.inputAccessoryView = self.setupKeyboardBar() cell.price.text = "" // 此处会清空输入内容 if self.actualField == .price { cell.price.becomeFirstResponder() } else if self.actualField == .quantity { cell.quantity.becomeFirstResponder() } return cell } } } //********************************************* // MARK: - 表格单元格定义 //********************************************* 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() // 重复调用resign } @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() // 重复调用resign } @IBOutlet weak var quantity: UITextField! @IBOutlet weak var price: UITextField! }
解决方案
问题根源分析
- 切换按钮中提前调用
view.endEditing(true),直接收起键盘,后续试图让新输入框成为firstResponder时失效 switchActiveField中逻辑错误:menueList里没有.price字段,但切换.shop的返回时指向了.price,导致找不到对应的IndexPath,返回功能完全失效- 依赖
reloadRows触发cellForRow中的becomeFirstResponder,但reload是异步操作,时机无法保证,导致光标无法正确定位 cellForRow中每次都设置输入框文本为空,丢失用户输入内容- 单元格的
textFieldDidEndEditing中重复调用resignFirstResponder,干扰键盘状态
修复步骤及代码修改
1. 移除切换按钮中的提前收起键盘操作
修改UIViewController扩展中的按钮点击方法:
@objc func leftButtonTapped() { // 移除 view.endEditing(true),避免提前收起键盘 NotificationCenter.default.post(Notification(name: keyBoardBarBackNotification)) } @objc func nextButtonTapped() { // 移除 view.endEditing(true),避免提前收起键盘 NotificationCenter.default.post(Notification(name: keyBoardBarNextNotification)) }
2. 修复字段切换逻辑错误
由于menueList中没有.price,调整switchActiveField中.shop的返回逻辑,并新增直接激活目标输入框的方法:
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: // 原逻辑指向.price,但menueList中无此字段,改为指向quantity self.actualField = bck ? .quantity : .product // .... } // 直接获取目标cell并设置firstResponder,不再依赖reloadRows activateTargetField() } // 新增方法:激活目标输入框 private func activateTargetField() { guard let indexPath = tableViewIndex[actualField] else { return } // 滚动到目标行 menue.scrollToRow(at: indexPath, at: .bottom, animated: true) // 获取目标cell guard let cell = menue.cellForRow(at: indexPath) else { // 如果cell尚未加载,先刷新再尝试 menue.reloadRows(at: [indexPath], with: .automatic) DispatchQueue.main.async { [weak self] in self?.activateTargetField() } return } // 根据字段类型激活对应的输入框 switch actualField { case .product, .brand, .shop: let textFieldCell = cell as! LabelTextFieldCell textFieldCell.itemInput.becomeFirstResponder() case .quantity: let quantityPriceCell = cell as! QuantityPriceCell quantityPriceCell.quantity.becomeFirstResponder() case .price: let quantityPriceCell = cell as! QuantityPriceCell quantityPriceCell.price.becomeFirstResponder() } }
3. 保留用户输入内容
首先在AddPositionVC中添加存储输入内容的属性:
// 存储各字段的输入内容 var fieldValues: [TableCellType: String] = [:]
然后修改cellForRow中的文本设置,并添加输入值存储逻辑:
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() // 从fieldValues中读取已输入的内容,而非清空 cell.itemInput.text = fieldValues[tableCellType] ?? "" // 设置代理,监听输入变化 cell.itemInput.delegate = self // 绑定字段类型到输入框,方便后续存储值 cell.itemInput.tag = tableCellType.rawValue.hashValue if self.actualField == tableCellType { DispatchQueue.main.async { cell.itemInput.becomeFirstResponder() } } return cell case .quantity, .price: let cell = tableView.dequeueReusableCell(withIdentifier: "QuantityPriceCell", for: indexPath) as! QuantityPriceCell cell.quantity.inputAccessoryView = self.setupKeyboardBar() cell.price.inputAccessoryView = self.setupKeyboardBar() // 读取已输入内容 cell.quantity.text = fieldValues[.quantity] ?? "" cell.price.text = fieldValues[.price] ?? "" // 设置代理和tag cell.quantity.delegate = self cell.quantity.tag = TableCellType.quantity.rawValue.hashValue cell.price.delegate = self cell.price.tag = TableCellType.price.rawValue.hashValue if self.actualField == .price { DispatchQueue.main.async { cell.price.becomeFirstResponder() } } else if self.actualField == .quantity { DispatchQueue.main.async { cell.quantity.becomeFirstResponder() } } return cell } }
让AddPositionVC实现UITextFieldDelegate,存储输入值:
extension AddPositionVC: UITextFieldDelegate { func textFieldDidEndEditing(_ textField: UITextField) { // 通过tag找到对应的字段类型 guard let type = TableCellType.allCases.first(where: { $0.rawValue.hashValue == textField.tag }) else { return } fieldValues[type] = textField.text ?? "" } }
4. 移除单元格中重复的resignFirstResponder调用
修改LabelTextFieldCell:
func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { // 移除 self.itemInput.resignFirstResponder(),避免重复调用 }
修改QuantityPriceCell:
func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { // 移除 textField.resignFirstResponder(),避免重复调用 }
额外优化建议
- 可以移除全局通知,直接在按钮点击时调用AddPositionVC中的切换方法,减少通知带来的耦合
- 对TableCellType的hashValue绑定tag的方式,可以改为自定义rawValue为Int类型,更可靠
- 在activateTargetField中添加延迟确保cell完全加载,避免异步问题
内容的提问来源于stack exchange,提问作者carlson
相关产品推荐
相关产品推荐

