'SwiftUI validate input in textfields
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.
Solution 1:[1]
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.
}
Solution 2:[2]
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())
}
}
Solution 3:[3]
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
}
}
Solution 4:[4]
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)
}
}
Solution 5:[5]
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
}
}
}
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | Bryan |
| Solution 2 | Asperi |
| Solution 3 | ramzesenok |
| Solution 4 | nkalvi |
| Solution 5 |
