Search code examples
swiftuitextfieldproperty-wrapper

Property Wrapper doesn't affect TextField


I wrote MaxCount propertyWrapper to limit String count in TextField. However, while Text view shows trimmed String, TextField shows full String.

I can achieve expected behavior via below ViewModifier, but this doesn't seem a good practice to me, I would like to achieve that behaviour via @propertyWrapper.

TextField("Type here...", text: $text)
       .onChange(of: text) { newText in
            // Check if newText has more characters than maxCount, if so trim it.
            guard maxCount < newText.count else { text = newText; return }
            text = String(newText.prefix(maxCount))
        }

MaxCount.swift

@propertyWrapper struct MaxCount<T: RangeReplaceableCollection>: DynamicProperty {
    
    // MARK: Properties
    private var count: Int = 0
    @State private var value: T = .init()
    
    var wrappedValue: T {
        get { value }
        nonmutating set {
            value = limitValue(newValue, count: count)
        }
    }
    
    var projectedValue: Binding<T> {
        Binding(
            get: { value },
            set: { wrappedValue = $0 }
        )
    }

    // MARK: Initilizations
    init(wrappedValue: T, _ count: Int) {
        self.count = count
        self._value = State(wrappedValue: limitValue(wrappedValue, count: count))
    }
    
    // MARK: Functions
    private func limitValue(_ value: T, count: Int) -> T {
        guard value.count > count else { return value }
        let lastIndex = value.index(value.startIndex, offsetBy: count - 1)
        let firstIndex = value.startIndex
        return T(value[firstIndex...lastIndex])
    }
    
}

ContentView.swift

struct ContentView: View {

    @MaxCount(5) private var text = "This is a test text"
    
    var body: some View {
        VStack {
            Text(text)
            TextField("Type here...", text: $text)
        }
    }
}

Solution

  • I ended up building a new TextField as below.

    Drawback: It doesn't support initialization with formatters which exists in TextField

    struct FilteredTextField<Label: View>: View {
        
        // MARK: Properties
        private let label: Label
        private var bindingText: Binding<String>
        private let prompt: Text?
        private let filter: (String) -> Bool
        @State private var stateText: String
        @State private var lastValidText: String = ""
    
        // MARK: Initializations
        init(text: Binding<String>, prompt: Text? = nil, label: () -> Label, filter: ((String) -> Bool)? = nil) {
            self.label = label()
            self.bindingText = text
            self.prompt = prompt
            self.filter = filter ?? { _ in true }
            self._stateText = State(initialValue: text.wrappedValue)
        }
        init(_ titleKey: LocalizedStringKey, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
            self.label = Text(titleKey)
            self.bindingText = text
            self.prompt = prompt
            self.filter = filter ?? { _ in true }
            self._stateText = State(initialValue: text.wrappedValue)
        }
        init(_ title: String, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
            self.label = Text(title)
            self.bindingText = text
            self.prompt = prompt
            self.filter = filter ?? { _ in true }
            self._stateText = State(initialValue: text.wrappedValue)
        }
        
        // MARK: View
        var body: some View {
            TextField(text: $stateText, prompt: prompt, label: { label })
                .onChange(of: stateText) { newValue in
                    guard newValue != bindingText.wrappedValue else { return }
                    guard filter(newValue) else { stateText = lastValidText; return }
                    bindingText.wrappedValue = newValue
                }
                .onChange(of: bindingText.wrappedValue) { newValue in
                    if filter(newValue) { lastValidText = newValue }
                    stateText = newValue
                }
        }
    }
    

    Usage

    struct ContentView: View {
        @State var test: String = ""
        var body: some View {
            VStack {
                HStack {
                    Text("Default TextField")
                    TextField(text: $test, label: { Text("Type here...") })
                }
                HStack {
                    Text("FilteredTextField")
                    FilteredTextField(text: $test, label: { Text("Type here...") }) { inputString in inputString.count <= 5 }
                }
            }
        }
    }