Search code examples
swiftswiftuitextfieldproperty-wrapper

Property wrapper like dynamic property / TextField / SwiftUI



I need the wrappedValue in TextField to be immediately uppercased as text is entered.

In this video, it works:

https://www.youtube.com/watch?v=aQE3kbCA0nk&ab_channel=SwiftandTips

But here, for some reason, it doesn't want to work. I might be missing something, but the text is formatted as uppercased only in Text(text)...


Importantly:


Everything should work inside the property wrapper and update in the view (TextField). There should be no modifiers like onChange or onReceive near the TextField, meaning no implementation in the view.


Code:


import SwiftUI

@propertyWrapper
struct StringFormat: DynamicProperty {
    
    @State private var value: String = ""
    
    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue.uppercased()
    }
    
    var wrappedValue: String {
        get { value }
        nonmutating set { value = newValue.uppercased() }
    }
    
    var projectedValue: Binding<String> {
        Binding(
            get: { wrappedValue },
            set: { wrappedValue = $0.uppercased() }
        )
    }
}

struct ContentView: View {
    
    @StringFormat var text = ""
    
    var body: some View {
        VStack(spacing: 24) {
            Text(text)
                .font(.title3)
            TextField("TextField", text: $text)
                .font(.title3)
                .disableAutocorrection(true)
                .padding()
        }
    }
}

#Preview {
    ContentView()
}

Now:


Now:


Must be:


Must be:



Solution

  • It is not possible to do this without additional modifiers on the built-in TextField, at least on iOS 17.

    The wrappedValue of the Binding you pass to the TextField, is not in sync with the actual text in the TextField at all times. Though the design of the API might give strong impressions that the text: binding will be synchronised with the actual at all times, that's simply not how TextField is implemented.

    Your StringFormat property wrapper works as expected. Its wrappedValue is indeed all caps, as demonstrated by the Text. It's just that TextField does not display that value. This is TextField's problem, not StringFormat's.


    This is like asking why TextDisplay in the following code doesn't display "Foo".

    struct ContentView: View {
        
        @State var text = "Foo"
        
        var body: some View {
            TextDisplay(text: $text)
        }
    }
    

    Does this show that @State is broken? Of course not! You just haven't seen how TextDisplay is implemented. You had assumed that it will display the text in the binding because of its name and parameters. It's actually implemented like this:

    struct TextDisplay: View {
        @Binding var text: String
        
        var body: some View {
            Text("I'm not using the Binding at all!")
        }
    }
    

    The quote from the documentation of DynamicProperty.update is a weak argument. The documentation simply says that it will call body when the property changes.

    SwiftUI calls this function before rendering a view’s body to ensure the view has the most recent value.

    Assuming TextField does store the binding you passed to it somewhere in its stored properties, the documentation just says that TextField.body will get called. What does TextField.body do exactly? God knows. SwiftUI is not open source.

    A stronger argument for this is a bug is the documentation of TextField.init, where the text parameter is described as "the text to display and edit". One could argue that the text parameter is not "displayed" in this case.

    That said, from a user's perspective, I personally think that this design is better. I think the TextField should update what it's displaying after the user ends editing. Having the text field change what I entered is very annoying in my opinion.