带格式化程序的 SwiftUI TextField 不工作?

SwiftUI TextField with formatter not working?

我正在尝试更新数字字段,因此我使用了带有格式化程序:参数集的 TextField。它将数字格式化为输入字段就好了,但在编辑时不会更新绑定值。 TextField 在没有指定格式化程序的情况下工作正常(在字符串上)。这是一个错误还是我遗漏了什么?

更新:从 Xcode 11 beta 3 开始,它可以正常工作。现在,如果您编辑数字 TextField,绑定值会在您点击 return 后更新。每次按键后,String TextField 仍会更新。我猜他们不想在每次按键时将要格式化的值发送给格式化程序,或者可能 is/will 是 TextField 的修饰符来告诉它这样做。

请注意 API 略有变化;旧的 TextField init() 已弃用,新的​​ titleKey String 字段已添加为第一个参数,该参数在该字段中显示为占位符文本。

struct TestView : View {
   @State var someText = "Change me!"
   @State var someNumber = 123.0
   var body: some View {
       Form {
            // Xcode 11 beta 2
            // TextField($someText)
            // TextField($someNumber, formatter: NumberFormatter())
            // Xcode 11 beta 3
            TextField("Text", text: $someText)
            TextField("Number", value: $someNumber, formatter: NumberFormatter())
            Spacer()
            // if you change the first TextField value, the change shows up here
            // if you change the second (the number),
            // it does not *until you hit return*
            Text("text: \(self.someText), number: \(self.someNumber)")
            // the button does the same, but logs to the console
            Button(action: { print("text: \(self.someText), number: \(self.someNumber)")}) {
                Text("Log Values")
            }
        }
    }
}

如果您键入第一个(字符串)TextField,文本视图中的值会立即更新。如果您编辑第二个(数字),什么也不会发生。 同样,点击 Button 会显示 String 的更新值,但不会显示数字。我只在模拟器中试过这个。

似乎在使用 value: 作为输入时,SwiftUI 不会为用户点击的任何键重新加载视图。而且,正如您提到的,它会在用户退出或提交该字段时重新加载视图。

另一方面,每当按下一个键时,SwiftUI 使用 text: 作为输入(立即)重新加载视图。我没有别的想法。

就我而言,我为 someNumber2 做了如下操作:

struct ContentView: View {

@State var someNumber = 123.0
@State var someNumber2 = "123"


var formattedNumber : NSNumber {

    let formatter = NumberFormatter()

    guard let number = formatter.number(from: someNumber2) else {
        print("not valid to be converted")
        return 0
    }

    return number
}

var body: some View {

    VStack {

        TextField("Number", value: $someNumber, formatter: NumberFormatter())
        TextField("Number2", text: $someNumber2)

        Text("number: \(self.someNumber)")
        Text("number: \(self.formattedNumber)")
    }
  }
}

您可以使用 Binding 将 Double<-->String 转换为 TextField

struct TestView: View {
    @State var someNumber = 123.0

    var body: some View {
        let someNumberProxy = Binding<String>(
            get: { String(format: "%.02f", Double(self.someNumber)) },
            set: {
                if let value = NumberFormatter().number(from: [=10=]) {
                    self.someNumber = value.doubleValue
                }
            }
        )

        return VStack {
            TextField("Number", text: someNumberProxy)

            Text("number: \(someNumber)")
        }
      }
}

您可以使用计算属性 的方式来解决这个问题。 (感谢@iComputerfreak)

struct TestView: View {
    @State var someNumber = 123.0

    var someNumberProxy: Binding<String> {
        Binding<String>(
            get: { String(format: "%.02f", Double(self.someNumber)) },
            set: {
                if let value = NumberFormatter().number(from: [=11=]) {
                    self.someNumber = value.doubleValue
                }
            }
        )
    }

    var body: some View {
        VStack {
            TextField("Number", text: someNumberProxy)

            Text("number: \(someNumber)")
        }
      }
}

我知道这有一些可接受的答案,但上面的答案在输入值时似乎有错误的用户体验结果(至少对于双精度值)。所以我决定编写自己的解决方案。它很大程度上受到这里答案的启发,所以我会先尝试这里的其他例子,然后再尝试这个例子,因为它有更多的代码。

WARNING Although I have been an iOS developer for a long time, I'm fairly new to SwiftUI. So this is far from expert advice. I would love feedback on my approach but be nice. So far this has been working out well on my new project. However, I doubt this is as efficient as Apple's formatters.

protocol NewFormatter {
    associatedtype Value: Equatable

    /// The logic that converts your value to a string presented by the `TextField`. You should omit any values 
    /// - Parameter object: The value you are converting to a string.
    func toString(object: Value) -> String

    /// Once the change is allowed and the input is final, this will convert
    /// - Parameter string: The full text currently on the TextField.
    func toObject(string: String) -> Value

    /// Specify if the value contains a final result. If it does not, nothing will be changed yet.
    /// - Parameter string: The full text currently on the TextField.
    func isFinal(string: String) -> Bool

    /// Specify **all** allowed inputs, **including** intermediate text that cannot be converted to your object **yet** but are necessary in the input process for a final result. It will allow this input without changing your value until a final correct value can be determined.
    /// For example, `1.` is not a valid `Double`, but it does lead to `1.5`, which is. Therefore the `DoubleFormatter` would return true on `1.`.
    /// Returning false will reset the input to the previous allowed value.
    /// - Parameter string: The full text currently on the TextField.
    func allowChange(to string: String) -> Bool
}

struct NewTextField<T: NewFormatter>: View {
    let title: String
    @Binding var value: T.Value
    let formatter: T
    @State private var previous: T.Value
    @State private var previousGoodString: String? = nil

    init(_ title: String, value: Binding<T.Value>, formatter: T) {
        self.title = title
        self._value = value
        self._previous = State(initialValue: value.wrappedValue)
        self.formatter = formatter
    }

    var body: some View {
        let changedValue = Binding<String>(
            get: {
                if let previousGoodString = self.previousGoodString {
                    let previousValue = self.formatter.toObject(string: previousGoodString)

                    if previousValue == self.value {
                        return previousGoodString
                    }
                }

                let string = self.formatter.toString(object: self.value)
                return string
            },
            set: { newString in
                if self.formatter.isFinal(string: newString) {
                    let newValue = self.formatter.toObject(string: newString)
                    self.previousGoodString = newString
                    self.previous = newValue
                    self.value = newValue
                } else if !self.formatter.allowChange(to: newString) {
                    self.value = self.previous
                }
            }
        )

        return TextField(title, text: changedValue)
    }
}

然后您可以为 Double 创建自定义格式化程序,如下所示:

/// An object that converts a double to a valid TextField value.
struct DoubleFormatter: NewFormatter {
    let numberFormatter: NumberFormatter = {
        let numberFormatter = NumberFormatter()
        numberFormatter.allowsFloats = true
        numberFormatter.numberStyle = .decimal
        numberFormatter.maximumFractionDigits = 15
        return numberFormatter
    }()

    /// The logic that converts your value to a string used by the TextField.
    func toString(object: Double) -> String {
        return numberFormatter.string(from: NSNumber(value: object)) ?? ""
    }

    /// The logic that converts the string to your value.
    func toObject(string: String) -> Double {
        return numberFormatter.number(from: string)?.doubleValue ?? 0
    }

    /// Specify if the value contains a final result. If it does not, nothing will be changed yet.
    func isFinal(string: String) -> Bool {
        return numberFormatter.number(from: string) != nil
    }

    /// Specify **all** allowed values, **including** intermediate text that cannot be converted to your object **yet** but are necessary in the input process for a final result.
    /// For example, `1.` is not a valid `Double`, but it does lead to `1.5`, which is. It will allow this input without changing your value until a final correct value can be determined.
    /// Returning false will reset the input the the previous allowed value. For example, when using the `DoubleFormatter` the input `0.1j` would result in false which would reset the value back to `0.1`.
    func allowChange(to string: String) -> Bool {
        let components = string.components(separatedBy: ".")

        if components.count <= 2 {
            // We allow an Integer or an empty value.
            return components.allSatisfy({ [=11=] == "" || Int([=11=]) != nil })
        } else {
            // If the count is > 2, we have more than one decimal
            return false
        }
    }
}

你可以像这样使用这个新组件:

NewTextField(
    "Value",
    value: $bodyData.doubleData.value,
    formatter: DoubleFormatter()
)

以下是我能想到的其他一些用法:

/// Just a simple passthrough formatter to use on a NewTextField
struct PassthroughFormatter: NewFormatter {
    func toString(object: String) -> String {
        return object
    }

    func toObject(string: String) -> String {
        return string
    }

    func isFinal(string: String) -> Bool {
        return true
    }

    func allowChange(to string: String) -> Bool {
        return true
    }
}

/// A formatter that converts empty strings to nil values
struct EmptyStringFormatter: NewFormatter {
    func toString(object: String?) -> String {
        return object ?? ""
    }

    func toObject(string: String) -> String? {
        if !string.isEmpty {
            return string
        } else {
            return nil
        }
    }

    func isFinal(string: String) -> Bool {
        return true
    }

    func allowChange(to string: String) -> Bool {
        return true
    }
}

方案B。由于使用value:NumberFormatter不起作用,我们可以使用自定义的TextField。我已将 TextField 包裹在 struct 中,以便您可以尽可能透明地使用它。

我对Swift和SwiftUI都是非常新手,所以毫无疑问是一个更优雅的解决方案。

struct IntField: View {
    @Binding var int: Int
    @State private var intString: String  = ""
    var body: some View {
        return TextField("", text: $intString)
        .onReceive(Just(intString)) { value in
            if let i = Int(value) { int = i }
            else { intString = "\(int)" }
        }
        .onAppear(perform: {
            intString = "\(int)"
        })
    }
}

并且在 ContentView 中:

struct ContentView: View {
    @State var testInt: Int = 0
    var body: some View {
        return HStack {
            Text("Number:")
            IntField(int: $testInt);
            Text("Value: \(testInt)")
        }
    }
}

基本上,我们使用 TextField("…", text: …),它的行为符合预期,并使用代理文本字段。

与使用value:NumberFormatter的版本不同,.onReceive方法会立即响应,我们用它来设置绑定的真实整数值。当我们这样做的时候,我们检查文本是否真的产生了一个整数。

.onAppear方法用于从整数填充字符串

您可以对 FloatField 执行相同的操作。

这可能会在 Apple 完成之前完成这项工作。

受上面接受的代理答案的启发,这里有一个具有相当数量代码的现成的结构。我真的希望 Apple 可以添加一个选项来切换行为。

struct TextFieldRow<T>: View {
    var value: Binding<T>
    var title: String
    var subtitle: String?

    var valueProxy: Binding<String> {
        switch T.self {
        case is String.Type:
            return Binding<String>(
                get: { self.value.wrappedValue as! String },
                set: { self.value.wrappedValue = [=10=] as! T } )
        case is String?.Type:
            return Binding<String>(
                get: { (self.value.wrappedValue as? String).bound },
                set: { self.value.wrappedValue = [=10=] as! T })
        case is Double.Type:
            return Binding<String>( get: { String(self.value.wrappedValue as! Double) },
                set: {
                    let doubleFormatter = NumberFormatter()
                    doubleFormatter.numberStyle = .decimal
                    doubleFormatter.maximumFractionDigits = 3

                    if let doubleValue = doubleFormatter.number(from: [=10=])?.doubleValue {
                        self.value.wrappedValue = doubleValue as! T
                    }
                }
            )
        default:
            fatalError("not supported")
        }
    }
    
    var body: some View {
        return HStack {
            VStack(alignment: .leading) {
                Text(title)
                if let subtitle = subtitle, subtitle.isEmpty == false {
                    Text(subtitle)
                        .font(.caption)
                        .foregroundColor(Color(UIColor.secondaryLabel))
                }
            }
            Spacer()
            TextField(title, text: valueProxy)
            .multilineTextAlignment(.trailing)
        }
    }
}

为了保持简洁和轻便,我在视图模型中使用 getter/setter 结束了转换类型,并保留了文本类型 TextField。

快速而肮脏(大概),但它有效并且我不觉得我在战斗 SwiftUI。

查看正文

struct UserDetails: View {
    @ObservedObject var userViewModel: UserViewModel
    
    init(user: PedalUserViewModel) {
        userViewModel = user
    }


    var body: some View {
        VStack {
            Form {
                Section(header: Text("Personal Information")) {
                    TextField("Age", text: $userViewModel.userAge)
                        .keyboardType(.numberPad)
                        .modifier(DoneButton())
                }
            }
        }
    }
}

ViewModel

class UserViewModel: ObservableObject {
    
    @ObservedObject var currentUser: User
    var anyCancellable: AnyCancellable?

    
    init(currentUser: User) {
        self.currentUser = currentUser
        self.anyCancellable = self.currentUser.objectWillChange.sink{ [weak self] (_) in
            self?.objectWillChange.send()
        }
    }
    
    var userAge: String {
        get {
            String(currentUser.userAge)
        }
        set {
            currentUser.userAge = Int(newValue) ?? 0
        }
    }
}
import Foundation
import SwiftUI

struct FormattedTextField<T: Equatable>: View {
    
    let placeholder: LocalizedStringKey
    @Binding var value: T
    let formatter: Formatter
    var valueChanged: ((T) -> Void)? = nil
    var editingChanged: ((Bool) -> Void)? = nil
    var onCommit: (() -> Void)? = nil
    
    @State private var isUpdated = false
    
    var proxy: Binding<String> {
        Binding<String>(
            get: {
                formatter.string(for: value) ?? ""
            },
            set: {
                var obj: AnyObject? = nil
                formatter.getObjectValue(&obj, for: [=10=], errorDescription: nil)
                if let newValue = obj as? T {
                    let notifyUpdate = newValue == value
                    value = newValue
                    valueChanged?(value)
                    if notifyUpdate {
                        isUpdated.toggle()
                    }
                }
                
            }
        )
    }
    
    var body: some View {
        TextField(
            placeholder,
            text: proxy,
            onEditingChanged: { isEditing in
                editingChanged?(isEditing)
            },
            onCommit: {
                onCommit?()
            }
        )
        .tag(isUpdated ? 0 : 1)
    }
    
}

目前 iOS 14 个具有值初始化程序的 TextField 未更新状态。

I found a workaround for this bug and can be used NSNumber, Double ... and a NumberFormatter. This a new brand TextField that accept NSNumber and NumberFormatter

extension TextField {
    public init(_ prompt: LocalizedStringKey, value: Binding<NSNumber>, formatter: NumberFormatter) where Text == Label {
        self.init(
            prompt,
            text: .init(get: {
                formatter.string(for: value.wrappedValue) ?? String()
            }, set: {
                let string = [=10=]
                    .replacingOccurrences(of: formatter.groupingSeparator, with: "")
                value.wrappedValue = formatter.number(from: string) ?? .init(value: Float.zero)
            })
        )
    }
}

Or you can implement you own Logic inside the binding get and set methods

TextField("placeholder", text: .init(
                get: {
                    decimalFormatter.string(from: number) ?? ""
                },
                set: {
                    let string = [=11=]
                        .replacingOccurrences(of: decimalFormatter.groupingSeparator, with: "")

                    _number.wrappedValue = decimalFormatter.number(from: string)
                            ?? .init(value: Double.zero)
                }
            ))

Swift 5.5 和 iOS 15 具有新的格式化 API。

我正在寻找一个干净的货币格式化程序并偶然发现了这个文档。

请参阅此处的文档: ParseableFormatStyle

这仍然不会在您键入时更新 TextField 绑定值。但是,您不再需要按 return 来触发格式化。您可以简单地退出 TextField。当您单击返回 TextField 以编辑原始值时,它的行为也符合预期。

这是一个工作示例:

import SwiftUI

struct FormatTest: View {
    @State var myNumber: Double?
    @State var myDate: Date.FormatStyle.FormatInput?
    var body: some View {
        Form {
            TextField("", value: $myNumber, format: .currency(code: "USD"), prompt: Text("Enter a number:"))
            TextField("", value: $myDate, format: .dateTime.month(.twoDigits).day(.twoDigits).year(), prompt: Text("MM/DD/YY"))
            Text(myDate?.formatted(.dateTime.weekday(.wide)) ?? "")
        }
    
    }
}

struct FormatTest_Previews: PreviewProvider {
    static var previews: some View {
        FormatTest()
    }
}