SwiftUI自定义Int16TextField求助:绑定、输入限制等问题
自定义SwiftUI数值TextField解决方案
针对CoreData关联的Int16/Double类型编辑需求,以下是基于UIViewRepresentable封装的自定义TextField,解决原生TextField绑定数值类型的崩溃问题,同时满足你提出的所有功能要求:
核心实现代码
1. 定义配置选项
enum NumericTextFieldOptions { /// Int16类型配置 /// - allowLeadingZero: 是否允许前导零 /// - suffix: 不可编辑的后缀字符串 case int16(allowLeadingZero: Bool, suffix: String) /// Double类型配置 /// - allowLeadingZero: 是否允许前导零 /// - suffix: 不可编辑的后缀字符串 /// - decimalPlaces: 可选,固定小数位数(nil则不限制) case double(allowLeadingZero: Bool, suffix: String, decimalPlaces: Int?) }
2. 自定义TextField实现
struct NumericTextField: UIViewRepresentable { typealias UIViewType = UITextField @Binding private var value: NSNumber? private let options: NumericTextFieldOptions private let textStyle: UIFont.TextStyle // Int16类型初始化 init(value: Binding<Int16?>, options: NumericTextFieldOptions, textStyle: UIFont.TextStyle = .body) { self._value = Binding( get: { value.wrappedValue.map(NSNumber.init) }, set: { value.wrappedValue = $0?.int16Value } ) self.options = options self.textStyle = textStyle } // Double类型初始化 init(value: Binding<Double?>, options: NumericTextFieldOptions, textStyle: UIFont.TextStyle = .body) { self._value = Binding( get: { value.wrappedValue.map(NSNumber.init) }, set: { value.wrappedValue = $0?.doubleValue } ) self.options = options self.textStyle = textStyle } func makeUIView(context: Context) -> UITextField { let textField = UITextField() textField.delegate = context.coordinator textField.keyboardType = options is Double ? .decimalPad : .numberPad // 配置动态字体 textField.font = UIFont.preferredFont(forTextStyle: textStyle) textField.adjustsFontForContentSizeCategory = true // 禁用自动补全/纠正,避免非数字输入 textField.autocorrectionType = .no textField.autocapitalizationType = .none textField.spellCheckingType = .no textField.smartDashesType = .no textField.smartQuotesType = .no textField.smartInsertDeleteType = .no context.coordinator.updateDisplayText(for: textField) return textField } func updateUIView(_ uiView: UITextField, context: Context) { // 仅当绑定值变化且显示文本不匹配时更新,避免光标跳动 let expectedText = context.coordinator.displayText(for: value) if uiView.text != expectedText { context.coordinator.updateDisplayText(for: uiView) } } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } class Coordinator: NSObject, UITextFieldDelegate { private let parent: NumericTextField init(parent: NumericTextField) { self.parent = parent } // 生成带后缀的显示文本 func displayText(for value: NSNumber?) -> String { guard let value = value else { return "" } let numberString: String switch parent.options { case .int16(_, let suffix): numberString = String(value.int16Value) case .double(_, let suffix, let decimalPlaces): if let places = decimalPlaces { numberString = String(format: "%.\(places)f", value.doubleValue) .trimmingCharacters(in: ["0"]).trimmingCharacters(in: ["."]) // 去掉末尾多余的0和小数点 } else { numberString = String(value.doubleValue) } } return numberString + parent.suffix } // 从显示文本中提取纯数值部分(去掉后缀) func numericPart(from text: String) -> String { let suffix = parent.suffix guard !suffix.isEmpty else { return text } return text.replacingOccurrences(of: suffix, with: "").trimmingCharacters(in: .whitespaces) } // 过滤无效字符、处理前导零 func sanitizedNumericString(_ string: String) -> String { let allowedCharacters: CharacterSet switch parent.options { case .int16: allowedCharacters = .decimalDigits case .double: allowedCharacters = .decimalDigits.union(CharacterSet(charactersIn: ".")) } // 只保留允许的字符 let filtered = string.components(separatedBy: allowedCharacters.inverted).joined() // 处理前导零逻辑 switch parent.options { case .int16(let allowLeadingZero, _): guard !allowLeadingZero else { return filtered } // 去掉前导零,仅保留单独的0 return filtered.replacingOccurrences(of: "^0+(?!$)", with: "", options: .regularExpression) case .double(let allowLeadingZero, _, _): guard !allowLeadingZero else { return filtered } // 处理小数的前导零:00123→123,00.123→0.123 let parts = filtered.components(separatedBy: ".") if parts.count == 1 { return parts[0].replacingOccurrences(of: "^0+(?!$)", with: "", options: .regularExpression) } else { let integerPart = parts[0].replacingOccurrences(of: "^0+(?!$)", with: "", options: .regularExpression) return "\(integerPart.isEmpty ? "0" : integerPart).\(parts[1])" } } } // 更新显示文本并修正光标位置 func updateDisplayText(for textField: UITextField) { textField.text = displayText(for: parent.value) // 光标固定在数值部分末尾,不允许进入后缀区域 let numericLength = numericPart(from: textField.text ?? "").count if let newPosition = textField.position(from: textField.beginningOfDocument, offset: numericLength) { textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) } } // 处理文本输入/粘贴 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let currentNumericPart = numericPart(from: textField.text ?? "") let sanitizedReplacement = sanitizedNumericString(string) // 计算新的数值部分 let newNumericPart: String if let swiftRange = Range(range, in: currentNumericPart) { newNumericPart = currentNumericPart.replacingCharacters(in: swiftRange, with: sanitizedReplacement) } else { newNumericPart = currentNumericPart + sanitizedReplacement } // 转换为对应数值类型,处理范围限制 var newValue: NSNumber? switch parent.options { case .int16: if newNumericPart.isEmpty { newValue = nil } else if let intValue = Int(newNumericPart), let int16Value = Int16(exactly: intValue) { newValue = NSNumber(value: int16Value) } // 超出Int16范围时,newValue保持nil,不更新绑定避免崩溃 case .double: if newNumericPart.isEmpty { newValue = nil } else if let doubleValue = Double(newNumericPart) { newValue = NSNumber(value: doubleValue) } } // 更新绑定值 parent.value = newValue // 更新显示文本 updateDisplayText(for: textField) // 返回false,由我们手动处理文本更新 return false } // 确保光标不会跑到后缀区域 func textFieldDidChangeSelection(_ textField: UITextField) { guard let selectedRange = textField.selectedTextRange else { return } let numericLength = numericPart(from: textField.text ?? "").count let cursorOffset = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start) if cursorOffset > numericLength { // 光标越界,拉回数值部分末尾 if let newPosition = textField.position(from: textField.beginningOfDocument, offset: numericLength) { textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) } } } } // 提取配置中的后缀字符串 private var suffix: String { switch options { case .int16(_, let suffix): return suffix case .double(_, let suffix, _): return suffix } } }
使用示例
假设你有一个CoreData实体Product,包含stock: Int16?和weight: Double?属性,在SwiftUI视图中使用方式如下:
struct ProductEditView: View { @ObservedObject var product: Product var body: some View { Form { Section("库存") { NumericTextField( value: $product.stock, options: .int16(allowLeadingZero: false, suffix: " 件") ) .textFieldStyle(.roundedBorder) } Section("重量") { NumericTextField( value: $product.weight, options: .double(allowLeadingZero: true, suffix: " kg", decimalPlaces: 2) ) .textFieldStyle(.roundedBorder) } } .navigationTitle("编辑产品") } }
问题解决说明
- Int16范围崩溃:通过
Int16(exactly:)判断输入值是否在Int16范围内,仅当有效时才更新绑定,避免溢出崩溃。 - 自动补全非数字:禁用UITextField的所有智能输入功能,同时过滤输入/粘贴的字符,仅保留数字(Double类型允许小数点)。
- 光标位置异常:强制光标停留在数值部分末尾,禁止进入后缀区域;更新文本时手动设置光标位置,避免跳动。
- 动态字体缩放:使用系统偏好字体并开启
adjustsFontForContentSizeCategory,支持系统字体大小调整。 - 前导零处理:通过正则表达式自动移除前导零,可通过配置选项允许保留前导零。
- 清空置nil:当数值部分为空时,自动将绑定值设为nil,符合CoreData可选属性的需求。
- 不可编辑后缀:显示时追加后缀,用户无法编辑或选中后缀区域,确保后缀始终存在且不可修改。
注意事项
- 如果需要支持非可选数值类型,可修改Binding转换逻辑,为空时设置默认值(如0)。
- Double类型的小数位数配置会自动格式化显示,去掉末尾多余的零和小数点。
- 可根据需求扩展更多数值类型(如Int32、Float),只需添加对应的初始化方法和转换逻辑。
内容的提问来源于stack exchange,提问作者miothethis
相关产品推荐
相关产品推荐

