KRH
KRH

Reputation: 786

SwiftUI TextField with formatter not working?

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.

Upvotes: 58

Views: 34766

Answers (9)

Robac
Robac

Reputation: 425

Swift 5.5 and iOS 15 have new formatting APIs.

I was looking for a clean currency formatter and came across this documentation.

See Documentation here: ParseableFormatStyle

This still does not update the TextField bound value as you type. However, you no longer are required to press return to trigger the formatting. You can simply exit the TextField. It also behaves as expected when you click back into the TextField to edit your original value.

Here is a working example:

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

Upvotes: 5

Rajad Abdelmajid
Rajad Abdelmajid

Reputation: 1

Currently iOS 14 TextField with value initialiser is not updating the state.

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 = $0
                    .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 = $0
                        .replacingOccurrences(of: decimalFormatter.groupingSeparator, with: "")

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

Upvotes: 0

Lex
Lex

Reputation: 310

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: $0, 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)
    }
    
}

Upvotes: 0

got2jam
got2jam

Reputation: 595

In the interest of keeping it clean and lightweight, I wound up casting types with a getter/setter in the view model and keeping the text type TextField.

Quick and dirty(ish), but it works and doesn't feel like I'm fighting SwiftUI.

View Body

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

Upvotes: 2

Jakehao
Jakehao

Reputation: 1019

Inspired by above accepted proxy answer, here is a ready to use struct with fair amount of code. I really hope Apple can add an option to toggle the behavior.

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 = $0 as! T } )
        case is String?.Type:
            return Binding<String>(
                get: { (self.value.wrappedValue as? String).bound },
                set: { self.value.wrappedValue = $0 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: $0)?.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)
        }
    }
}

Upvotes: 0

Manngo
Manngo

Reputation: 16399

Plan B. Since using value: and NumberFormatter doesn’t work, we can use a customised TextField. I have wrapped the TextField inside a struct, so that you can use it as transparently as possible.

I am very new to both Swift and SwiftUI, so there is no doubt a more elegant solution.

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

and in the ContentView:

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

Basically, we work with a TextField("…", text: …), which behaves as desired, and use a proxy text field.

Unlike the version using value: and NumberFormatter, the .onReceive method responds immeditately, and we use it to set the real integer value, which is bound. While we’re at it, we check whether the text really yields an integer.

The .onAppear method is used to fill the string from the integer.

You can do the same with FloatField.

This might do the job until Apple finishes the job.

Upvotes: 9

Jacob
Jacob

Reputation: 1062

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

Upvotes: 3

Victor Kushnerov
Victor Kushnerov

Reputation: 3974

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

Upvotes: 53

FRIDDAY
FRIDDAY

Reputation: 4169

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

Upvotes: 12

Related Questions