You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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("编辑产品")
    }
}

问题解决说明

  1. Int16范围崩溃:通过Int16(exactly:)判断输入值是否在Int16范围内,仅当有效时才更新绑定,避免溢出崩溃。
  2. 自动补全非数字:禁用UITextField的所有智能输入功能,同时过滤输入/粘贴的字符,仅保留数字(Double类型允许小数点)。
  3. 光标位置异常:强制光标停留在数值部分末尾,禁止进入后缀区域;更新文本时手动设置光标位置,避免跳动。
  4. 动态字体缩放:使用系统偏好字体并开启adjustsFontForContentSizeCategory,支持系统字体大小调整。
  5. 前导零处理:通过正则表达式自动移除前导零,可通过配置选项允许保留前导零。
  6. 清空置nil:当数值部分为空时,自动将绑定值设为nil,符合CoreData可选属性的需求。
  7. 不可编辑后缀:显示时追加后缀,用户无法编辑或选中后缀区域,确保后缀始终存在且不可修改。

注意事项

  • 如果需要支持非可选数值类型,可修改Binding转换逻辑,为空时设置默认值(如0)。
  • Double类型的小数位数配置会自动格式化显示,去掉末尾多余的零和小数点。
  • 可根据需求扩展更多数值类型(如Int32、Float),只需添加对应的初始化方法和转换逻辑。

内容的提问来源于stack exchange,提问作者miothethis

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 05:10:55