SwiftUI TextField with formatter not working?

2020-02-23 06:56发布

I'm trying to get a numeric field updated so I'm using a TextField with the formatter: parameter set. It formats the number into the entry field just fine, but does not update the bound value when edited. The TextField works fine (on Strings) without the formatter specified. Is this a bug or am I missing something?

UPDATE: As of Xcode 11 beta 3 it kind of works. Now if you edit the numeric TextField, the bound value is updated after you hit return. The String TextField is still updated after each keypress. I guess they don't want to send the value to be formatted to the formatter with every key press, or maybe there is/will be a modifier for TextField to tell it to do that.

Note that The API has changed slightly; the old TextField init()s are deprecated and a new titleKey String field has been added as the first parameter which appears as placeholder text in the field.

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")
            }
        }
    }
}

If you type in the first (String) TextField, the value in the Text view is updated immediately. If you edit the second (Numeric), nothing happens. Similarly tapping the Button shows an updated value for the String, but not the number. I've only tried this in the simulator.

标签: swiftui
3条回答
Bombasti
2楼-- · 2020-02-23 07:00

You can use Binding to convert Double<-->String for 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: $0) {
                    self.someNumber = value.doubleValue
                }
            }
        )

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

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

You can use computed property way to solve this issue. (thanks @ 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: $0) {
                    self.someNumber = value.doubleValue
                }
            }
        )
    }

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

            Text("number: \(someNumber)")
        }
      }
}
查看更多
一纸荒年 Trace。
3楼-- · 2020-02-23 07:21

I know this has some accepted answers, but the above answers seem to have glitchy UX results when inputing values (at least for doubles). So I decided to write my own solution. It is largely inspired by the answers here so I would first try the other examples here before trying this one as it is a lot more code.

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)
    }
}

Then you can create a custom formatter for a Double like this one:

/// 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({ $0 == "" || Int($0) != nil })
        } else {
            // If the count is > 2, we have more than one decimal
            return false
        }
    }
}

To you can use this new component like this:

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

Here are a few other usages that I can think of:

/// 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
    }
}
查看更多
甜甜的少女心
4楼-- · 2020-02-23 07:22

It seems while using value: as an input, SwiftUI does not reload the view for any key that users tap on. And, as you mentioned, it reloads the view when users exit the field or commit it.

On the other hand, SwiftUI reloads the view (immediately) using text: as an input whenever a key is pressed. Nothing else comes to my mind.

in my case, I did it for someNumber2 as below:

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)")
    }
  }
}
查看更多
登录 后发表回答