22

I am trying to validate user input in a TextField by removing certain characters using a regular expression. Unfortunately, I am running into problems with the didSet method of the text var calling itself recursively.

import SwiftUI
import Combine

class TextValidator: ObservableObject {

    @Published var text = "" {
        didSet {
            print("didSet")
            text = text.replacingOccurrences(
                of: "\\W", with: "", options: .regularExpression
            ) // `\W` is an escape sequence that matches non-word characters.
        }
    }

}


struct ContentView: View {

    @ObservedObject var textValidator = TextValidator()

    var body: some View {
        TextField("Type Here", text: $textValidator.text)
            .padding(.horizontal, 20.0)
            .textFieldStyle(RoundedBorderTextFieldStyle())

    }
}

On the swift docs (see the AudioChannel struct), Apple provides an example in which a property is re-assigned within its own didSet method and explicitly notes that this does not cause the didSet method to be called again. I did some testing in a playground and confirmed this behavior. However, things seem to work differently when I use an ObservableObject and a Published variable.

How do I prevent the didSet method from calling itself recursively?

I tried the examples in this post, but none of them worked. Apple may have changed things since then, so this post is NOT a duplicate of that one.

Also, setting the text back to oldValue within the didSet method upon encountering invalid characters would mean that if a user pastes text, then the entire text would be removed, as opposed to only the invalid characters being removed. So that option won't work.

Peter Schorn
  • 916
  • 3
  • 10
  • 20

6 Answers6

21

Since SwiftUI 2 you can check the input using the onChange method and do any validations or changes there:

TextField("", value: $text)
    .onChange(of: text) { [text] newValue in
         // do any validation or alteration here.
         // 'text' is the old value, 'newValue' is the new one.
    }
Bryan
  • 4,628
  • 3
  • 36
  • 62
mawus
  • 1,178
  • 1
  • 11
  • 25
18

Try to validate what you want in the TextField onRecive method like this:

class TextValidator: ObservableObject {

    @Published var text = ""

}

struct ContentView: View {

    @ObservedObject var textValidator = TextValidator()
    var body: some View {
        TextField("Type Here", text: $textValidator.text)
            .padding(.horizontal, 20.0)
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .onReceive(Just(textValidator.text)) { newValue in
                let value = newValue.replacingOccurrences(
                    of: "\\W", with: "", options: .regularExpression)
                if value != newValue {
                    self.textValidator.text = value
                }
                print(newValue)
        }
    }
}
Mac3n
  • 4,189
  • 3
  • 16
  • 29
6

Here is possible approach using proxy binding, which still also allow separation of view & view model logic

class TextValidator: ObservableObject {

    @Published var text = ""

    func validate(_ value: String) -> String {
        value.replacingOccurrences(
                of: "\\W", with: "", options: .regularExpression
            )
    }
}


struct ContentView: View {

    @ObservedObject var textValidator = TextValidator()

    var body: some View {
        let validatingText = Binding<String>(
                get: { self.textValidator.text },
                set: { self.textValidator.text = self.textValidator.validate($0) }
                )
        return TextField("Type Here", text: validatingText)
            .padding(.horizontal, 20.0)
            .textFieldStyle(RoundedBorderTextFieldStyle())

    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 1
    Thanks, that works. Do you why it is that the didSet method in my version of the code was recursively called? Also, could you explain your code a little? – Peter Schorn Apr 07 '20 at 05:46
1

2021 | SwiftUI 2

Custom extension usage:

TextField("New Branch name", text: $model.newNameUnified)
    .ignoreSymbols( symbols: [" ", "\n"], string: $model.newNameUnified )

Extension:

@available(OSX 11.0, *)
public extension TextField {
    func ignoreSymbols(symbols: [Character], string: Binding<String>) -> some View {
         self.modifier( IgnoreSymbols(symbols: symbols, string: string)  )
    }
}

@available(OSX 11.0, *)
public struct IgnoreSymbols: ViewModifier {
    var symbols: [Character]
    var string: Binding<String>
    
    public func body (content: Content) -> some View
    {
        content.onChange(of: string.wrappedValue) { value in
            var newValue = value
        
            for symbol in symbols {
                newValue = newValue.replace(of: "\(symbol)", to: "")
            }
        
            if value != newValue {
                string.wrappedValue = newValue
            }
        }
    }
}
Andrew_STOP_RU_WAR_IN_UA
  • 9,318
  • 5
  • 65
  • 101
1

Here's what I came up with:

struct ValidatableTextField: View {
    let placeholder: String
    @State private var text = ""
    var validation: (String) -> Bool
  
    @Binding private var sourceText: String
  
    init(_ placeholder: String, text: Binding<String>, validation: @escaping (String) -> Bool) {
        self.placeholder = placeholder
        self.validation = validation
        self._sourceText = text
    
        self.text = text.wrappedValue
    }
  
    var body: some View {
        TextField(placeholder, text: $text)
            .onChange(of: text) { newValue in
                if validation(newValue) {
                    self.sourceText = newValue
                } else {
                  self.text = sourceText
                }
            }
    }
}

Usage:

ValidatableTextField("Placeholder", text: $text, validation: { !$0.contains("%") })

Note: this code doesn't solve specifically your problem but shows how to deal with validations in general.

Change body to this to solve your problem:

TextField(placeholder, text: $text)
    .onChange(of: text) { newValue in
        let value = newValue.replacingOccurrences(of: "\\W", with: "", options: .regularExpression)
        if value != newValue {
            self.sourceText = newValue
            self.text = sourceText
        }
    }
ramzesenok
  • 5,469
  • 4
  • 30
  • 41
0

Since didSet and willSet are always called when setting values, and objectWillChange triggers an update to the TextField (which triggers didSet again), a loop was created when the underlying value is updated unconditionally in didSet.

Updating the underlying value conditionally breaks the loop. For example:

import Combine

class TextValidator: ObservableObject {
    @Published var text = "" {
        didSet {
            if oldValue == text || text == acceptableValue(oldValue)  {
                return
            }
            text = acceptableValue(text)
        }
    }
    
    var acceptableValue: (String) -> String = { $0 }
}

import SwiftUI

struct TestingValidation: View {
    @StateObject var textValidator: TextValidator = {
        let o = TextValidator()
        o.acceptableValue = { $0.replacingOccurrences(
            of: "\\W", with: "", options: .regularExpression) }
        return o
    }()
    
    @StateObject var textValidator2: TextValidator = {
        let o = TextValidator()
        o.acceptableValue = { $0.replacingOccurrences(
                of: "\\D", with: "", options: .regularExpression) }
        return o
    }()
    
   
    var body: some View {
            VStack {
                Text("Word characters only")
                TextField("Type here", text: $textValidator.text)
                
                Text("Digits only")
                TextField("Type here", text: $textValidator2.text)
            }
            .padding(.horizontal, 20.0)
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .disableAutocorrection(true)
            .autocapitalization(.none)
    }
}
nkalvi
  • 91
  • 1
  • 3