In SwiftUI, any way to intercept each character entered in a TextField?

Can anyone confirm that there is at present no equivalent on the TextField view to the UITextFieldDelegate method
Code Block
textField(_:shouldChangeCharactersIn:replacementString:)
i.e. one that can intercept each keystroke and and paste to ensure that the value is always valid?

In SwiftUI the onEditingChanged parameter in the constructor is not triggered with each character entered.

Accepted Reply

If you just need to know when the text changes, this works for TextEditor, so I assume will work on TextField:

Code Block
TextEditor(text: $text)
    .onChange(of: text, perform: { newValue in
        print(newValue)
    })

  • I tried this out more recently and it works now.

Add a Comment

Replies

If you just need to know when the text changes, this works for TextEditor, so I assume will work on TextField:

Code Block
TextEditor(text: $text)
    .onChange(of: text, perform: { newValue in
        print(newValue)
    })

  • I tried this out more recently and it works now.

Add a Comment
No, sorry travis78701. I know about that handler. I need to intercept each character. onChange only deals with changes to the whole work when you leave the TextField.

I've had good luck providing a Formatter which just passes through its string value, in which you can override methods like

func isPartialStringValid(_ partialStringPtr: AutoreleasingUnsafeMutablePointer<NSString>, 
    proposedSelectedRange proposedSelRangePtr: NSRangePointer?, 
           originalString origString: String, 
    originalSelectedRange origSelRange: NSRange, 
         errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool

and do whatever you want with the user's entered string, change it, move the selection, etc...

  • Did you get this to work. This method is never called for me.

Add a Comment

yes you can, try this: (newVal will give it to you)

struct ContentView: View {
    @State var theText = ""
    var body: some View {
        TextField("type something", text: $theText)
            .onReceive(theText.publisher) { newVal in
                // do your checks here on newVal
                print("---> theText: \(theText)   newVal: \(newVal) ")
            }
    }
}

you can also try this:

        TextField("type something", text: $theText)
            .onChange(of: theText) { newVal in
                if let lastChar = newVal.last {
                    // do your check here on lastChar
                    print("--> last char: \(lastChar)")
                }
            }

here is another way:

        TextField("type something", text: Binding(
            get: { theText },
            set: { newVal in
            if let lastChar = newVal.last {
                // do your checks here on lastChar
                print("--> last char: \(lastChar)    newVal: \(newVal)    theText: \(theText)")
                theText = newVal   // if pass the checks
            }
        }))

Thanks for your answers, guys. It may be that SwiftUI has been debugged or improved in some way since I ran into this, but in the end, I was able to simply add this line to the TextField:

  TextField("0.0", text: $entry)
    .onChange(of: entry, perform: checkEntry)

given these declarations:

@AppStorage("entry") private var entry = "0"

(Of course the @AppStorage is not part of this solution)

and

 func checkEntry(value: String) { entryChecker.check(entry: $entry) }

None of these still answers the main question. How to intercept/prevent/take control of any incoming characters, and when you don't want specific one, you can respond something like false to not allow it to be entered. Trying to override binding value will result in a crash, any other hack solutions will result in binding wrapped value storing different value from the one shown in UI. It's almost like some hidden storage container to which we do not get any access.

Also, I need to detect typing delete into an empty SwiftUI TextEditor. Since that does not change the value, it will not trigger onChange(of:).

  • I don't know about that one.

Add a Comment

@jonasmw:

I am able to reject invalid strings - which can be created by adding an invalid character from the keyboard or pasting in, in my function entryChecker.check(entry)

In my code I have this:

TextField("0.0", text: $entry)
  .focused($entryFocused)
  .onChange(of: entry, perform: checkEntry)
func checkEntry(value: String) { entryChecker.check(entry: $entry) }
private var entryChecker = FloatEntryValidator(maxlen: 10) 
class FloatEntryValidator {
    init(maxlen: Int? = nil) { self.maxlen = maxlen}
    
    private let maxlen: Int?
    private var prevValidEntry = ""
    
    func check(entry: Binding<String>) {
        let value = entry.wrappedValue
        if value.isValidFloatEntry(maxlen: maxlen) {
            prevValidEntry = value
        } else {
            entry.wrappedValue = prevValidEntry // Reset
        }
    }// check
    
}// FloatEntryValidator
public let minusSign: Character = NumberFormatter().minusSign?.first ?? "-"
public let decimalSep: Character = NSLocale.current.decimalSeparator?.first ?? "."

extension String {
    func isValidFloatEntry(maxlen: Int? = nil) -> Bool {  // Values that a TextEdit can be allowed to have as the user enters a floating point number
        if let maxlen = maxlen, count > maxlen {
           return false
        }
        var parser = StringParser(self)
        parser.skipOptional(char: minusSign)
        parser.skipDecimals()
        parser.skipOptional(char: decimalSep)
        parser.skipDecimals()
        return parser.finished
    }// isValidFloatEntry
}
struct StringParser {  // For verifying that a string conforms to specific pattern of sequence. It is valid if finished is true at the end
    
    public init(_ s: String) { string = s; index = s.startIndex }
    
    public let string: String
    private var index: String.Index
    
    public var finished: Bool { return index == string.endIndex }
    
    public mutating func skipOptional(char: Character) {  // Skip single character that is permitted at the
        if !finished && string[index] == char {
            index = string.index(after: index)
        }
    }// skipOptional(Character)
    
    public mutating func skipDecimals() { // Advances index to first place that is not a decimal digit
        while !finished {
            let uss = string[index].unicodeScalars
            if uss.count > 1 || !CharacterSet.decimalDigits.contains(uss.first!) {
                return
            }
            index = string.index(after: index)
        }
    }// skipDecimal
    
}// StringParser

I know this is a few months old, but I've been looking at this too. (On a side note, I think it's abysmal that Apple didn't include a per character filter and a way to reject the value.)

I ended up writing a floating point parser (simple state machine) to validate text. It returns true if the text is a valid starting part of the number and has a computed property that tells me it's a complete number of not. This is required because "-" is a valid beginning of a floating point number, so is ".", etc. Of course ".." is not so you have to have something to validate these strings.

Because there is no per character event that I could find, I store the previous value (keep in mind that it can be shorter or longer - they user may enter a character and delete it). If the new value is not valid, I set the old value.

The examples I've seen here, may not work when you have an observable object set as an environment variable. I ran into infinite loops - so I only assign the new value if it's different than the current.

Here's my onReceive for the TextField.

				.onReceive(Just(data.text))
				{ newValue in
					/* Filter the new value */
					let filtered = newValue.filter { Set(Self.allowedChars).contains($0) }

					/* If valid or empty, save the old value and set the new */

					if (filtered.count == 0) || (validator.check(value: filtered))
					{
						oldValue = data.text
						if self.data.text != filtered
						{
							self.data.text = filtered
						}
					}
					/* Otherwise, set the old value */
					else
					{
						self.data.text = oldValue
					}
				}

Validator checks the string to ensure it is part of, or a complete floating point number. Mine allows numbers like -0.355e-05.

Now if I only knew what keyboard I was going to use. There doesn't appear to be a standard keyboard (except for the full one) that can be used for floating point.

You can try something like this. I tried this for Credit card validation. You can play around with your own validation in the set method.

@State private var value = ""

 TextField("Enter your text", text: Binding(
                       get: { value },
                       set: { newVal in
                           if value.removeSpaces.count < 16 {
                               if newVal.removeSpaces.elementsEqual(value.removeSpaces) &&
                                    newVal.removeSpaces.count.isMultiple(of: 4) {
                                   value = newVal + " "
                               } else {
                                   value = newVal
                               }
                           }
                   }))

And, I have written a small extension to string that removes the spaces.

extension String {
    var removeSpaces: String {
        replacingOccurrences(of: " ", with: "")
    }
}

Not sure, if this exactly fits your purpose. But, you can always playground with setter based on the condition you want, taking mine as reference. Cheers!!!

There are a number of interesting responses here and great ideas. Encountering a situation where I needed to enforce input using a SwiftUI TextField the same way I had done with the textField(_:shouldChangeCharactersIn:replacementString:) delegate method, I found simple filtering works nicely.

@State var textInput: String = ""
...

TextField("numerical entries only", text: $textInput)
                    .onChange(of: textInput) { newValue in
                        let numberCharacterSet = "0123456789"
                        textInput = newValue.filter { numberCharacterSet.contains($0) }
                    }

Checking for delete entry.

With the UIKit shouldChangeCharactersIn... delegate method the backspace is the equivalent of an empty string. In the SwiftUI TextField .onChange() block it is necessary to use a capture list for the @State property being updated and do a comparison between that and the new value in the closure.

    @State var textInput: String = ""

    var body: some View {        
                TextField("numerical entries only", text: $textInput)
                    .onChange(of: textInput) { [textInput] newValue in
                        if newValue.count < textInput.count { print("- backspace detected") }                                   
                    }
...