肘子的 Swift 记事本

Advanced SwiftUI TextField: Formatting and Validation

Published on

Get weekly handpicked updates on Swift and SwiftUI!

TextField is probably the most commonly used text input component for developers in SwiftUI applications. As a SwiftUI wrapper for UITextField (NSTextField), Apple provides developers with numerous constructors and modifiers to improve its usability and customization. However, SwiftUI also shields many advanced interfaces and features in its encapsulation, increasing the complexity for developers to implement certain specific needs. In this article, I will introduce how to implement the following features in TextField:

  • Shield invalid characters
  • Determine whether the input content meets specific conditions
  • Real-time format and display the input text

https://cdn.fatbobman.com/textfieldDemo1-3998601.gif

The purpose of this article is not to provide a universal solution, but to explore several ideas so that readers can have a reference when faced with similar requirements.

Why not wrap a new implementation yourself

For many developers who switch from UIKit to SwiftUI, it is natural to think about wrapping their own implementation through UIViewRepresentable when the official SwiftUI API cannot meet certain requirements (see Using UIKit Views in SwiftUI for more information). In the early days of SwiftUI, this was indeed a very effective approach. However, as SwiftUI gradually matures, Apple has provided a large number of unique features for the SwiftUI API. It is not worth giving up using the official SwiftUI solution just for some requirements.

Therefore, in the past few months, I have gradually abandoned the idea of implementing certain requirements through self-wrapping or using other third-party extension libraries. When adding new features to SwiftUI, I try to follow the following principles as much as possible:

  • Prioritize whether a solution can be found in the native methods of SwiftUI
  • If non-native methods are really needed, try to adopt a non-destructive implementation, and the new function cannot be at the expense of sacrificing the original function (it needs to be compatible with the official SwiftUI modifier method)

The above principles are reflected in SheetKit and NavigationViewKit.

How to implement formatted display in TextField

Existing formatting methods

In SwiftUI 3.0, TextField has added two construction methods that use new and old formatters. Developers can directly use non-String types of data (such as integers, floating-point numbers, dates, etc.) and format the entered content through Formatter. For example:

Swift
struct FormatterDemo:View{
    @State var number = 100
    var body: some View{
        Form{
            TextField("inputNumber",value:$number,format: .number)
        }
    }
}

https://cdn.fatbobman.com/textFieldDemo2.gif

However, it is unfortunate that although we can set the final formatting style, TextField cannot format text during the text input process. The text will only be formatted when the submit state (commit) is triggered or loses focus. This behavior differs from our initial requirements.

Possible formatting solutions

  • Activate the built-in Formatter in TextField during the input process to enable it to format the content when the text changes.
  • Call our own implementation of the Format method to format the content in real-time when the text changes.

For the first approach, we can currently use an unconventional method to activate real-time formatting by replacing or canceling the current delegate object of the TextField.

Swift
            TextField("inputNumber",value:$number,format: .number)
                .introspectTextField{ td in
                    td.delegate = nil
                }

The above code uses SwiftUI-Introspect to replace the delegate of the UITextField corresponding to the specified TextField behind it, which completes the activation of real-time formatting. This article’s solution one is a specific implementation of this approach.

The second approach is to not use any “black magic” and only use the native SwiftUI method to format the text when the input changes. This article’s solution two is a specific implementation of this approach.

How to block invalid characters in TextField

Existing character blocking methods

In SwiftUI, input restrictions can be achieved to some extent by setting specific keyboard types. For example, the following code only allows users to input numbers:

Swift
TextField("inputNumber",value:$number,format: .number)
    .keyboardType(.numberPad)

However, the above solution still has considerable limitations:

  • Only supports certain types of devices
  • Limited support for keyboard types

For example, keyboardType is invalid on iPad. In today’s world where Apple encourages application support for multiple device types, it is crucial to provide users with the same experience on different devices.

Also, due to limited support for keyboard types, it is often difficult to use in many application scenarios. The most typical example is that numberPad does not support negative numbers, meaning it can only be used for positive integers. Some developers can solve this by customizing the keyboard or adding inputAccessoryView, but for other developers who lack the ability or energy, it is also a good solution to directly block invalid characters entered.

Possible blocked character solutions:

  • Use the textField method of UITextFieldDelegate
  • In SwiftUI views, use onChange to check and modify input when changes occur

For the first approach, we still need to use a method like Introspect to invade the UITextField behind the TextField, replacing its original textField method and performing character checks within it. In practice, this is the most efficient solution because the check occurs before the character is confirmed by UITextField. If we find that the new string does not meet our input requirements, we can directly return false, and the most recently entered character will not be displayed in the input box.

Swift
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        // Check if the string meets the requirements
        if meetsRequirements { return true } // Add the new character to the input box
        else { return false}
 }

However, with the Delegate method, we cannot choose to keep partial characters, meaning we either accept all or none (if we wrap UITextField ourselves, we can implement any logic). Approach 1 uses this solution.

For the second approach, we support selective saving, but it also has limitations. Due to the special wrapping method used by the Formatter constructor of the TextField, we cannot obtain the contents of the input box when the binding value is not a String (such as integers, floating-point numbers, dates, etc.). Therefore, using this approach, we can only use strings as the binding type, and cannot enjoy the convenience brought by SwiftUI’s new construction method. Scheme 2 adopts this approach.

How to check if the content in TextField meets the specified conditions in SwiftUI

Compared to the above two goals, checking whether the content in TextField meets the specified conditions in SwiftUI is quite easy. For example:

Swift
TextField("inputNumber", value: $number, format: .number)
                .foregroundColor(number < 100 ? .red : .primary)

The above code will set the text color to red when the entered number is less than 100.

Of course, we can also continue the above solution by checking the text in the delegate’s textfield method. However, this approach is not very versatile for non-string types (which require conversion).

Other issues to consider

Before using the above approach for actual programming, we need to consider a few other issues:

Localization

The demo code provided in this article (https://github.com/fatbobman/TextFieldFomatAndValidateDemo) implements real-time processing for two types of data: Int and Double. Although these two types are mainly numeric, localization issues still need to be considered when processing them.

For different regions, the decimal point and grouping separator for numbers may be different, for example:

Bash
1,000,000.012 // Most regions
1 000 000,012 // fr

Therefore, when determining valid characters, we need to use Locale to obtain the decimalSeparator and groupingSeparator for that region.

If you need to handle date or other custom format data, it is also best to provide a processing procedure for localized characters in the code.

Formatter

Currently, SwiftUI’s TextField provides corresponding construction methods for both new and old Formatters. I prefer to use the new Formatter API, which is a Swift native implementation of the old Formatter API, providing a more convenient and safer declaration method. For more information on the new Formatter, please read Apple’s New Formatter API: Comparison of Old and New and How to Customize.

However, TextField still has some issues with supporting the new Formatter, so special attention needs to be paid when writing code. For example,

Swift
@State var number = 100
TextField("inputNumber", value: $number, format: .number)

Usability

If we only aim to achieve the originally set goal of this article, it’s not that complicated. However, the implementation should provide convenient calling methods and minimize the pollution to the original code.

For example, the following code shows the calling methods for Solution 1 and Solution 2.

Swift
// Solution 1
let intDelegate = ValidationDelegate(type: .int, maxLength: 6)

TextField("0...1000", value: $intValue, format: .number)
       .addTextFieldDelegate(delegate: intDelegate)
       .numberValidator(value: intValue) { $0 < 0 || $0 > 1000 }

// Solution 2
@StateObject var intStore = NumberStore(text: "",
                                        type: .int,
                                        maxLength: 5,
                                        allowNagative: true,
                                        formatter: IntegerFormatStyle<Int>())

TextField("-1000...1000", text: $intStore.text)
       .formatAndValidate(intStore) { $0 < -1000 || $0 > 1000 }

There is still great room for optimization and integration in the above calling methods. For example, we could wrap TextField again (using View), and use property wrappers to bridge numbers and strings in Solution 2.

Solution 1

You can download the demo code for this article on Github. Only part of the code is explained in the article, please refer to the source code for complete implementation.

Plan one uses the new Formatter constructor method of TextField:

Swift
public init<F>(_ titleKey: LocalizedStringKey, value: Binding<F.FormatInput>, format: F, prompt: Text? = nil) where F : ParseableFormatStyle, F.FormatOutput == String

Activate the built-in format mechanism of TextField by replacing the delegate, and block invalid characters in the textfield method of the delegate.

Block invalid characters:

Swift
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    let text = textField.text ?? ""
    return validator(text: text, replacementString: string)
}

private func validator(text: String, replacementString string: String) -> Bool {
    // Check valid characters
    guard string.allSatisfy({ characters.contains($0) }) else { return false }
    let totalText = text + string

    // Check decimal point
    if type == .double, text.contains(decimalSeparator), string.contains(decimalSeparator) {
        return false
    }

    // Check negative sign
    let minusCount = totalText.components(separatedBy: minusCharacter).count - 1

    if minusCount > 1 {
        return false
    }
    if minusCount == 1, !totalText.hasPrefix("-") {
        return false
    }

    // Check length
    guard totalText.count < maxLength + minusCount else {
        return false
    }
    return true
}

Note that different locales will provide different sets of valid characters (characters).

Adding View extensions

extension View {
    // Adjust text color based on whether the specified condition is met
    func numberValidator<T: Numeric>(value: T, errorCondition: (T) -> Bool) -> some View {
        foregroundColor(errorCondition(value) ? .red : .primary)
    }
    // Replace delegate
    func addTextFieldDelegate(delegate: UITextFieldDelegate) -> some View {
        introspectTextField { td in
            td.delegate = delegate
        }
    }
}

Solution 2

Solution 2 uses native SwiftUI methods to achieve the same goal. Since it is not possible to use the Formatter or raw text features built into TextField, the implementation is slightly more complex than Solution 1. In addition, in order to validate the entered characters in real time, only string types can be used as the binding type of TextField, which makes the call slightly more complex than Solution 1 (which can be further simplified by wrapping it again).

To save some temporary data, we need to create a class that conforms to ObservableObject to manage the data uniformly.

Swift
class NumberStore<T: Numeric, F: ParseableFormatStyle>: ObservableObject where F.FormatOutput == String, F.FormatInput == T {
    @Published var text: String
    let type: ValidationType
    let maxLength: Int
    let allowNagative: Bool
    private var backupText: String
    var error: Bool = false
    private let locale: Locale
    let formatter: F

    init(text: String = "",
         type: ValidationType,
         maxLength: Int = 18,
         allowNagative: Bool = false,
         formatter: F,
         locale: Locale = .current)
    {
        self.text = text
        self.type = type
        self.allowNagative = allowNagative
        self.formatter = formatter
        self.locale = locale
        backupText = text
        self.maxLength = maxLength == .max ? .max - 1 : maxLength
    }

Pass the formatter to NumberStore and call it in getValue.

Swift
// Return the validated number.
    func getValue() -> T? {
        // Special handling (empty, minus sign only, decimal point as the first character for floating point numbers).
        if text.isEmpty || text == minusCharacter || (type == .double && text == decimalSeparator) {
            backup()
            return nil
        }

        // Check if the characters are valid by removing the grouping separator from the string.
        let pureText = text.replacingOccurrences(of: groupingSeparator, with: "")
        guard pureText.allSatisfy({ characters.contains($0) }) else {
            restore()
            return nil
        }

        // Handle multiple decimal points.
        if type == .double {
            if text.components(separatedBy: decimalSeparator).count > 2 {
                restore()
                return nil
            }
        }

        // Handle multiple minus signs.
        if minusCount > 1 {
            restore()
            return nil
        }

        // The minus sign must be the first character.
        if minusCount == 1, !text.hasPrefix("-") {
            restore()
            return nil
        }

        // Check the length.
        guard text.count < maxLength + minusCount else {
            restore()
            return nil
        }

        // Convert the text to a number, then convert it to a string (to ensure the text format is correct).
        if let value = try? formatter.parseStrategy.parse(text) {
            let hasDecimalCharacter = text.contains(decimalSeparator)
            text = formatter.format(value)
            // Protect the last decimal point (otherwise, the converted text might not contain the decimal point).
            if hasDecimalCharacter, !text.contains(decimalSeparator) {
                text.append(decimalSeparator)
            }
            backup()
            return value
        } else {
            restore()
            return nil
        }
    }

In solution two, in addition to filtering out invalid characters, we also need to implement our own formatter. The new Formatter API has excellent fault tolerance for strings, so converting the text to a number through the parseStrategy and then back to a standard string will ensure that the text in the TextField always displays correctly.

Additionally, we need to consider cases where the first character is “ and the last character is a decimal point, as parseStrategy may lose this information during conversion. We need to reproduce these characters in the final conversion result.

View Extension

Swift
extension View {
    @ViewBuilder
    func formatAndValidate<T: Numeric, F: ParseableFormatStyle>(_ numberStore: NumberStore<T, F>, errorCondition: @escaping (T) -> Bool) -> some View {
        onChange(of: numberStore.text) { text in
            if let value = numberStore.getValue(),!errorCondition(value) {
                numberStore.error = false // Save validation status through NumberStore
            } else if text.isEmpty || text == numberStore.minusCharacter {
                numberStore.error = false
            } else { numberStore.error = true }
        }
        .foregroundColor(numberStore.error ? .red : .primary)
        .disableAutocorrection(true)
        .autocapitalization(.none)
        .onSubmit { // Handle the case when there is only one decimal point
            if numberStore.text.count > 1 && numberStore.text.suffix(1) == numberStore.decimalSeparator {
                numberStore.text.removeLast()
            }
        }
    }
}

In contrast to Solution 1, where the processing logic is scattered across multiple code sections, Solution 2 calls all the logic within onChange.

Because onChange is only called after the text has changed, Solution 2 will cause the view to refresh twice. However, considering the application scenario of text input, the performance loss can be ignored (If the link between the value and the string is further linked using property wrappers, it may increase the number of view refreshes even further).

You can download the demo code of this article on Github.

Comparison of Two Solutions

  • Efficiency

Since solution one only needs to refresh the view once per input, its execution efficiency is theoretically higher than solution two. However, in actual use, both can provide a smooth and timely interaction effect.

  • Supported data types

Solution one can directly use multiple data types, while solution two needs to convert the original value into the corresponding format string in the TextField constructor. In the demo code of solution two, the numerical value corresponding to the string can be obtained through result.

  • Optional value support

The TextField constructor used in solution one (supporting formatter) does not support optional value types and requires an initial value. This is not conducive to judging whether the user has entered new information (more information can be found in How to Create a Real-Time Responsive Form in SwiftUI).

Solution two allows not providing an initial value and supports optional values.

In addition, in solution one, if all characters are cleared, the bound variable will still have a value (original API behavior), which may cause confusion for users during input.

  • Sustainability (SwiftUI backward compatibility)

Solution two is completely written in SwiftUI, so its sustainability should theoretically be stronger than solution one. However, unless SwiftUI makes significant changes to the underlying implementation logic, solution one will still work normally in recent versions, and solution one can support earlier versions of SwiftUI.

  • Compatibility with other decoration methods

Both solution one and solution two meet the full compatibility with official API proposed earlier in this article and have obtained other function improvements without loss.

Conclusion

Every developer hopes to provide users with an efficient and elegant interaction environment. This article only involves part of the TextField content. In other sections of Advanced SwiftUI TextField: Events, Focus, Keyboard, we will explore more techniques and ideas to allow developers to create different text input experiences in SwiftUI.

This article covers formatting and validating text input in SwiftUI’s TextField with attention to localization, Formatter, usability, and extensions for View.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways