1

I have a SwiftUI view that takes user name in a String declared as a State property. How can I add an additional attribute to the same variable?

struct Login: View {
    @Trimmed(characterSet: .whitespaces)
    @State private var userName: String = ""

    var body: some View {
        VStack {
            TextEditor(text: $userName)
        }
    }        
}

In the code above, I am trying to add a property wrapper Trimmed that will automatically keep the user input string clean.

I am getting an error saying Cannot convert value of type 'State<String>' to expected argument type 'String'

Is there a way out here?


Update:

Here is the code for the property wrapper

@propertyWrapper
struct Trimmed {
    private var value: String!
    private let characterSet: CharacterSet

   var wrappedValue: String {
        get { value }
        set { value = newValue.trimmingCharacters(in: characterSet) }
    }

   init(wrappedValue: String) {
        self.characterSet = .whitespacesAndNewlines
        self.wrappedValue = wrappedValue
    }

   init(wrappedValue: String, characterSet: CharacterSet) {
        self.characterSet = characterSet
        self.wrappedValue = wrappedValue
    }
}

Kaunteya
  • 3,107
  • 1
  • 35
  • 66

3 Answers3

1

See the Swift Evolution Proposal for how property wrapper composition works. By writing @Trimmed first, you are actually doing:

private var _userName = Trimmed(
    // that's not what Trimmed.init takes!
    wrappedValue: State(wrappedValue: "")
    characterSet: .whitespaces
)

var userName: String {
    get { _userName.wrappedValue.wrappedValue }
    set { _userName.wrappedValue.wrappedValue = newValue }
}

If you put State first,

@State @Trimmed(characterSet: .whitespaces) var userName = ""

then you get:

private var _userName = State(
    wrappedValue: Trimmed(wrappedValue: "", characterSet: .whitespaces)
)

Which is correct as far as types are concerned.

However, such a userName has a projectedValue of Binding<Trimmed>, which is probably not what you want. You can't use this in a TextField for example.

// wrong type
TextField("Foo", text: $text)

// this also doesn't work because setting the Binding is nonmutating, 
// and so doesn't call your wrappedValue's setter
TextField("Foo", text: $text.value)

So you actually have to put Trimmed first, so that Trimmed can take a State and have its own projectedValue of type Binding<String>. But that still won't work. You can't detect when the State changes and remove the whitespaces, because State has a nonmutating setter.

I think you'd have to dig into the implementation details of SwiftUI to make something like this work.

I would suggest just using the onChange view modifier instead. That is the "correct way" to detect a change in @State.

// modify the outermost view with this, and the same would apply to the children
.onChange(of: userName) { newValue in
    userName = newValue.trimmingCharacters(in: .whitespaces)
}

Extract this as a view modifier if you like.

struct Trimmed: ViewModifier {
    @Binding var text: String
    let characterSet: CharacterSet
    
    func body(content: Content) -> some View {
        content.onChange(of: text) { newValue in
            text = newValue.trimmingCharacters(in: characterSet)
        }
    }
}
Sweeper
  • 213,210
  • 22
  • 193
  • 313
0

If you use @State inside your Trimmed propertyWrapper it would work without chaining it with @State in your view:

@propertyWrapper
struct Trimmed: DynamicProperty {
    @State private var value: String
    private let characterSet: CharacterSet
    
    var wrappedValue: String {
        get {
            value
        }
        nonmutating set {
            value = newValue.trimmingCharacters(in: characterSet)
            
        }
    }
    var projectedValue: Binding<String> {
        Binding {
            return wrappedValue
        } set: { newValue in
            wrappedValue = newValue
        }
    }
    init(wrappedValue: String, characterSet: CharacterSet = .whitespacesAndNewlines) { //merged 2 initializers into 1
        self.characterSet = characterSet
        self._value = State(wrappedValue: wrappedValue.trimmingCharacters(in: characterSet))
    }
}

Then simply use @Trimmed:

struct Login: View {
    @Trimmed(characterSet: .whitespaces) private var userName: String = ""
    
    var body: some View {
        VStack {
            TextEditor(text: $userName)
        }
    }
}
Timmy
  • 4,098
  • 2
  • 14
  • 34
  • Hi. Only some parts of my app are in SwiftUI. Your solution will not work for UIKit. Also at the call site, having a State declaration would be more important than having a secondary property wrapper. – Kaunteya Aug 04 '23 at 08:59
0

Here is how I solved the above problem

struct Login: View {
  
    @State @Trimmed private var userName: String = ""

    var body: some View {
        VStack {
            TextEditor(text: Binding(get: {userName}, set: { userName = $0 }))
        }
    }        
}

As suggested by Fogmeister in the comments, I moved @Trimmed after the @State. With this, the SwiftUI views respond to the changes in userName

Regarding the text binding. I wrote a small Binding inline.

This solution is completely declarative, concise and reusable. Also, the trimming happens implicitly.

Kaunteya
  • 3,107
  • 1
  • 35
  • 66