SwiftUI’s TextField is probably the most commonly used text entry component by developers in their applications. As the SwiftUI package for UITextField (NSTextField), Apple provides developers with numerous constructors and modifiers to improve their ease of use and customization. But SwiftUI also hides a lot of advanced interfaces and functionality in its package, which adds complexity for developers to implement specific needs. This article is part of the SwiftUI Advanced series. In this article, I will introduce how to implement the following functions in TextField:

  • Mask invalid characters
  • Determine whether the input content meets certain conditions
  • Real-time formatting display of input text

The original post was posted on my blog wwww.fatbobman.com

Public number: [Swift Notepad for elbow]

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

Why not encapsulate the new implementation yourself

For many developers moving from UIKit to SwiftUI, it’s natural to want to encapsulate their implementation through UIViewRepresentable when there are requirements that aren’t met by the official SwiftUI API functionality (see using UIKit Views in SwiftUI for more). In the early days of SwiftUI, this was very effective. But as SwiftUI has matured, Apple has added a number of unique features to the SwiftUI API. Abandoning the official SwiftUI scheme for certain needs is not worth the cost.

As a result, over the last few months, I’ve moved away from the idea of implementing some of my requirements by wrapping them myself or using other third-party extension libraries. When adding new features to SwiftUI, try to follow the following principles:

  • Priority should be given to finding solutions within SwiftUI native methods
  • If it is necessary to use non-native methods, try to use non-destructive implementation, new features should not be at the expense of the original functionality (compatible with the official SwiftUI modification method)

These principles are reflected in SheetKit — SwiftUI Modal View Extension library and NavigationViewKit enhanced SwiftUI navigation view.

How to implement formatting display in TextField

Existing formatting methods

In SwiftUI 3.0, TextField has a new Formatter constructor that uses both the old and new formatters. Development can directly use non-string data (such as integers, floating point numbers, dates, etc.) and format the input by Formatter. Such as:

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

Unfortunately, TextField does not format text during text entry, although we can set the final formatting style. The text is formatted only when the Submit state (COMMIT) is triggered or the focus is lost. Behavior is not consistent with our initial needs.

Possible formatting solutions

  • Activate TextField’s built-in Formatter during the input process, allowing it to format the content as the text changes
  • When the text changes, the Format method is called to Format the content in real time

For the first idea, we can now activate real-time formatting by an unusual means — replacing or canceling the delegate object for the current TextFiled.

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

The code above uses swiftui-Introspect to implement the delegate replacement of the UITextField corresponding to the specified TextField, which can complete the activation of real-time formatting. The first scheme of this paper is the concrete realization of this idea.

The second idea is to use the native SwiftUI method without black magic to format the text when the input text changes. The second scheme of this paper is the concrete realization of this idea.

How do I shield invalid characters from TextField

Existing masking character methods

In SwiftUI, some degree of entry restriction can be achieved by setting only certain keyboard types to be used. For example, the following code will only allow the user to enter numbers:

TextField("inputNumber",value:$number,format: .number)
    .keyboardType(.numberPad)
Copy the code

However, the scheme has considerable limitations.

  • Only some types of devices are supported
  • Limited keyboard types are supported

For example, keyboardType is not available on the iPad, and in a world where Apple encourages apps to support multiple device types, it’s critical that users enjoy the same experience on different devices.

In addition, due to the limited keyboard types it supports, it is not enough for many applications. The most obvious example is that numberPad does not support negative signs, meaning that it only works with positive integers. Some developers can solve this problem by customizing the keyboard or adding an inputAccessoryView, but for others who don’t have the ability or energy to simply block invalid characters, it’s a good solution.

Possible shielded character solutions

  • The use of UITextFieldDelegatetextFieldmethods
  • In SwiftUI view, useonChangeJudge and modify input changes as they occur

In the first way, we still need to use methods like Introspect to invade the UITextField behind TextField, replace its original TextField method, and judge characters in it. In practice, this is the most efficient method because it occurs before the character is validated by the UITextField. If we find that the newly added string does not meet our input requirements, we can simply return false, and the most recently typed character will not be displayed in the input box.

func textField(_ textField: UITextField.shouldChangeCharactersIn range: NSRange.replacementString string: String) -> Bool {
        // Check whether string meets the condition
        ifSatisfy the condition {return true } // Add the new character to the input box
        else { return false}}Copy the code

However, with the Delegate method, we don’t have the option to keep some characters, which means we accept all or none (if we wrap UITextField ourselves, we can implement any logic). Scheme one adopts this idea.

The second way of thinking is that we support selective preservation, but it has limitations. Because of the special wrapping of TextField’s Formatter constructor, we can’t get the contents of the input box when the binding value is not a String (such as an integer, float, date, etc.). Therefore, with this approach, we can only use strings as binding types and will not enjoy the convenience of SwiftUI’s new constructor. Scheme two adopts this idea.

How do I check content in TextField to see if it meets the specified criteria

Compared to the two goals above, it is quite convenient in SwiftUI to check whether TextField content meets the specified criteria. Such as:

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

The code above sets the color of the text display to red when the number entered is less than 100.

Of course, we can continue the idea of the delegate textField method to determine the text. However, this approach is not very applicable to types (non-string types need to be converted).

Other issues that need attention

There are a few other things to consider before using the above ideas for actual programming:

localization

Real-time processing of both Int and Double is implemented in the demo code provided with this article. Although both types are largely numeric, there are localization issues that need to be addressed.

The decimal point and group separator may be different for numbers in different locales, for example:

1.000.000.012 // Most areas
1 000 000.012 // fr
Copy the code

Therefore, to determine valid characters, we need to obtain decimalSeparator and groupingSeparator for the Locale.

If you need to determine dates or other custom format data, it’s a good idea to provide a process for localized characters in your code as well.

Formatter

SwiftUI’s TextField currently provides constructors for both old and new formatters. I prefer to use the new Formatter API. It provides a much easier and more secure way to declare the old Formatter API’s Swift native implementation. For more information on the new Formatter, read the WWDC 2021 New Formatter API: Old vs. New and how to Customize it.

However, TextField’s support for the new Formatter is still somewhat problematic, so be careful when writing code. For example,

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

In the case of binding value Int, overflow will occur when the input number exceeds 19 characters, causing the program to crash (FB has been submitted, it is estimated that there will be corrections in the later version). Fortunately, the demo code in this article provides a limit on the number of characters to be typed, which can temporarily solve this problem.

Ease of use

It’s not that complicated to just do what this article originally set out to do, but do it in a way that provides easy calls and reduces contamination of the original code.

For example, the following code shows how scheme one and two are called.

/ / a
let intDelegate = ValidationDelegate(type: .int, maxLength: 6)

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

/ / 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 }
Copy the code

There is still a lot of room for optimization and integration with the above invocation methods, such as wrapping TextField twice (using View), bridging numbers and strings using attribute wrappers in scheme two, and so on.

Plan a

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

Option 1 uses TextField’s new Formatter constructor:

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

Activate TextField’s built-in Format mechanism by replacing the delegate, masking invalid characters in Delegte’s TextField method.

Mask invalid characters:

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 {
        // Determine valid characters
        guard string.allSatisfy({ characters.contains($0)})else { return false }
        let totalText = text + string

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

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

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

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

It is important to note that different locales provide different valid character sets.

Add a View extension

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

Scheme 2

Scheme 2 adopts SwiftUI’s native method to achieve the same goal. Since TextField’s built-in functions such as Formatter and original text cannot be utilized, the implementation is more complicated than Scheme 1. In addition, in order to verify the input characters in real time, we can only use the string type as the binding type of TextField, which is also slightly more complicated to call than scheme 1 (which can be further simplified by repackaging).

To hold some temporary data, we need to create an ObservableObejct compliant class to manage the data uniformly

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
    }
Copy the code

Formatter is passed to NumberStore and called in getValue.

// Returns the validated number
    func getValue(a) -> T? {
        // Special processing (no content, only minus sign, floating point number begins with a decimal point)
        if text.isEmpty || text = = minusCharacter || (type = = .double && text = = decimalSeparator) {
            backup()
            return nil
        }

        // Use the string after the group delimiter to determine whether the character is valid
        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}}// multiple negative signs
        if minusCount > 1 {
            restore()
            return nil
        }

        // The minus sign must start with a letter
        if minusCount = = 1.!text.hasPrefix("-") {
            restore()
            return nil
        }

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

        // Convert text to numbers and then to text (make sure the text is formatted correctly)
        if let value = try? formatter.parseStrategy.parse(text) {
            let hasDecimalCharacter = text.contains(decimalSeparator)
            text = formatter.format(value)
            // Protect the last decimal point (the converted text may not contain the decimal point without special processing)
            if hasDecimalCharacter, !text.contains(decimalSeparator) {
                text.append(decimalSeparator)
            }
            backup()
            return value
        } else {
            restore()
            return nil}}Copy the code

In scenario 2, in addition to masking invalid characters, we also need to handle the Format implementation ourselves. The new Formatter API is very fault-tolerant for strings, so converting text to a numeric value through parseStrategy and then to a standard string will ensure that text in a TextField is always displayed correctly.

Also, we need to take into account that the first character is – and the last character is a decimal point, because parseStrategy will lose this information after the conversion and we need to reproduce these characters in the final conversion result.

The View extension

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 // Saves the verification status by 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 only one decimal point
            if numberStore.text.count > 1 && numberStore.text.suffix(1) = = numberStore.decimalSeparator {
                numberStore.text.removeLast()
            }
        }
    }
}
Copy the code

Unlike scheme 1, where the processing logic is spread across multiple code parts, scheme 2 invokes all the logic in onChange.

Since onChange is called after the text changes, scheme 2 causes the view to refresh twice, but the performance penalty is negligible considering the text entry scenario (if the property wrapper is used to further link values to strings, the view refresh times may be further increased).

You can download the Demo code for this article on Github.

A comparison of the two schemes

  • The efficiency of

    Scheme one is theoretically more efficient than Scheme two because it requires only one view refresh per entry, but in practice both provide smooth, timely interaction.

  • Type types supported

    In scheme 1, multiple data types can be directly used. In Scheme 2, the original value should be converted into a string in the corresponding format in the constructor of TextField. In the demo code of scenario 2, result can be used to get the numeric value of the string.

  • This parameter is optional

    The TextField constructor used in scheme one (supporting Formatter) does not support optional value types and must provide initial values. It is not easy to determine whether a user is entering new information (see How to create a real-time responsive Form in SwiftUI for more information).

    In scheme 2, no initial value is allowed and optional values are supported.

    In addition, if all characters are cleared in scheme 1, binding variables will still have values (original API behavior), which will easily cause confusion for users when typing.

  • Sustainability (SwiftUI backward compatibility)

    In theory, SwiftUI is more sustainable than SwiftUI because it is written in SwiftUI mode. However, unless the implementation logic behind SwiftUI is significantly changed, Plan 1 will still work in recent versions, and Plan 1 can support earlier versions of SwiftUI.

  • Compatibility with other decorating methods

    Both options 1 and 2 meet the full compatibility with the official API proposed earlier in this article, with additional enhancements without loss.

conclusion

Every developer wants to provide users with an efficient and elegant interaction environment. This article only covers part of TextField. In the rest of SwiftUI TextField Progression, we’ll explore more tips and ideas for creating a different text entry experience in SwiftUI.

Hope you found this article helpful.

The original post was posted on my blog wwww.fatbobman.com

Public number: [Swift Notepad for elbow]