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
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:
struct FormatterDemo:View{
@State var number = 100
var body: some View{
Form{
TextField("inputNumber",value:$number,format: .number)
}
}
}
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.
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:
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 ofUITextFieldDelegate
- 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.
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:
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:
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,
@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.
// 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:
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:
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.
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
.
// 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
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.