In SwiftUI TextField
is the goto View
for capturing freeform input from your users. It works great out-of-the-box for capturing strings, but as with any stock API there are limitations and behavior that may catch you off-guard, especially if you try to work with optionals and other data types. This article will provide some observations, tips, and tricks I have learned to help you work effectively with TextField
. Each section is more or less independent from the others, so feel free to skip around.
A sample app is also available on GitHub if you are interested in trying out the code yourself.
Code written using XCode 12.4 an iOS 14
Binding To Optional Strings
If you have ever tried to pass a binding containing an optional string to a TextField
you will get a rude compiler error: “Cannot convert value of type 'Binding<String?>' to expected argument type 'Binding<String>'“. This is particularly annoying coming from UIKit as the .text
property of UITextField
is an optional string. You could change the nature of your underlying property from optional string to just string and change underlying code to check for an empty string instead of nil
, but I don’t like the idea of altering my data model just to fit UI API calls. In addition, the use of a concrete string creates waste in memory allocation, disk storage, and network traffic when model objects are serialized and moved around (arguably nominal, but real).
A more elegant solution, which I found on Stack Overflow, is to extend Optional
with a computed property that unwraps the optional and provides a default value when nil
is encountered (an empty string in this case). The extension looks like this:
extension Optional where Wrapped == String {
var _boundString: String? {
get {
return self
}
set {
self = newValue
}
}
public var boundString: String {
get {
return _boundString ?? ""
}
set {
_boundString = newValue.isEmpty ? nil : newValue
}
}
}
Notice that the getter and setter translate properly in both directions. In the getter when the underlying value is nil an empty string is returned and in the setter when newValue
is an empty string the underlying value is set to nil
.
Utilizing this new property in SwiftUI is as easy as TextField(“my title”, text: $optionalString.boundString)
. You didn’t need to alter your data model, you got the functionality you wanted, and your code is still nice and clean. Pretty cool huh?
On Behavior And State Updates
TextField
has two primary constructors that play a role in how and when variable state is updated. Most of us are familiar with, TextField(title: StringProtocol, text: Binding<String>)
. Text fields using this constructor update their bound strings in a continuous fashion as the user types on the keyboard.
The second constructor, TextField(title: StringProtocol, value: Binding<T>, formatter: Formatter)
allows you to pass generic values that are translated to and from their original type by a formatter, but behaves very differently. State is only updated for the bound value when .onCommit
is called. In other words, it’s only when the return key (or equivalent) is pressed that the formatter evaluates the current string in the text box and updates the underlying value if it is successful.
This can easily lead to some frustrating scenarios. If you are working with numbers and want to use a corresponding number keyboard you will quickly find out there is no return key and hence no way of having .onCommit
called. Variable state will also remain unchanged if a user taps away from your TextField
instead of pressing return. This seems like a fairly big shortcoming. Don’t waste your time like I did trying to figure out workarounds. Use a vanilla TextField
with a string and perform your own conversion using the .onEditingChanged
and .onCommit
closures. Better yet, read the next section to learn how to wrap a UITextField
using UIViewRepresentable
.
Wrapping UITextField To Work With Numeric Values
As I mentioned in the last section, TextFields
that use numeric values and an associated Formatter
are almost unusable. Numbered keyboards don’t have a return key, and state is only updated when .onCommit
is called…which in this scenario never happens. Rather than fighting with SwiftUI we can create our own view that handles numeric values by wrapping UITextField
and following the UIViewRepresentable
protocol. We will make our new view update its state whenever the view loses focus, add in some convenience formatting for currency and percentages, and present an alert to the user when invalid input is encountered.
Our struct, which we will call NumericTextField,
looks like this:
struct NumericTextField<T>: UIViewRepresentable {
private var title: String
@Binding var value: T
private var formatter: NumberFormatter
@State var errorMessage = ""
private var keyboardType: UIKeyboardType
init(title: String = "", value: Binding<T>, numberFormatter: NumberFormatter, keyboardType: UIKeyboardType) {
self.title = title
self._value = value
self.formatter = numberFormatter
self.keyboardType = keyboardType
}
class Coordinator: NSObject, UITextFieldDelegate { ... }
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.delegate = context.coordinator
if !title.isEmpty {
textField.placeholder = title
}
textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
textField.keyboardType = keyboardType
textField.borderStyle = .roundedRect
return textField
}
func makeCoordinator() -> Coordinator {
return Coordinator(value: $value, formatter: formatter, errorMessage: $errorMessage)
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = context.coordinator.textFor(value: value)
}
}
Similar to TextView
, our textfield has a title
(placeholder value), a binding to an underlying value
(ground truth), and a formatter
(responsible for translating back and forth between our stored value type and a string representation). We will hold off looking at our Coordinator
for the moment, but it will be in charge of input validation and updating our bound value
. To conform to UIViewRepresentable
our view must implement makeUIView
and updateUIView
. In makeUIView
we create a UITextField
, set the delegate to our coordinator
, and if a title is provided, set the placeholder text of the text field. We also set the rest of the aesthetics and keyboard type before returning our new view. In updateUIView
, which handles SwiftUI -> UIKit data flow we set the text of our UITextField
to the text representation of value
using the .textFor
method of our coordinator. The last function, makeCoordinator
returns an instance of our Coordinator, passing along a binding to value
, the formatter
, and a binding to a string that stores an error message.
Now lets take a look at our Coordinator class:
class Coordinator: NSObject, UITextFieldDelegate {
@Binding var value: T
var formatter: NumberFormatter
@Binding var errorMessage: String
init(value: Binding<T>, formatter: NumberFormatter, errorMessage: Binding<String>) {
self._value = value
self.formatter = formatter
self._errorMessage = errorMessage
}
func textFor<T>(value: T) -> String? {
return formatter.string(for: value)
}
func scrubbedText(currentText: String) -> String {
switch formatter.numberStyle {
case .currency:
if let prefix = formatter.currencySymbol,
!currentText.contains(prefix),
!currentText.isEmpty {
return prefix + currentText
}
case .percent: ()
if !currentText.contains("%") {
return currentText + "%"
}
default:
()
}
return currentText
}
func showAlert(errorMessage: String, view: UIView) {
let alert = UIAlertController(title: nil, message: errorMessage, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action"), style: .default))
if let parentController = view.parentViewController {
parentController.present(alert, animated: true)
}
}
//MARK: UITextFieldDelegate methods
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
guard let currentText = textField.text else { return }
let string = scrubbedText(currentText: currentText)
var valueContainer: AnyObject?
var errorContainer: NSString?
formatter.getObjectValue(&valueContainer, for: string, errorDescription: &errorContainer)
if let errorString = errorContainer as String? {
if let stringVal = formatter.string(for: value) {
textField.text = stringVal
} else {
textField.text = nil
}
showAlert(errorMessage: errorString, view: textField as UIView)
return
}
if let newValue = valueContainer as? T,
errorContainer == nil {
self.value = newValue
}
}
}
The first two functions in the Coordinator are helper methods. The textFor<T>(value: T) -> String?
method translates our bound value
to a string. We saw it used in .updateView
. The other method, scrubbedText(currentText: String) -> String
, provides a bit of extra convenience to formatting currency and percentages. The NumberFormatter
class is fairly picky when you select .currency
or .percentage
as the numberStyle
. Currency strings must be prepended with an appropriate currency symbol (e.g. “$1.00” for the US) and percentages must have a trailing percent symbol (e.g. “5%”). The formatter always returns nil
if these symbols are left off, so we will make life a little easier and add them to the input string if they are missing.
Next let’s skip to the UITextFieldDelegate
methods. Here we are only going to implement two of them. In textFieldShouldReturn
, which gets called when a return key is pressed, we will resign first responder and return true. The second method textFieldDidEndEditing
, which is called whenever the text field loses focus, is where our coordinator will do its most important work. We first unwrap the .text
optional property of our UITextField
, and scrub the text using the scrubbedText
function to ensure it contains an appropriate prefix/suffix, if needed. We then utilize our formatter to capture the current value of our object using the .getObjectValue
method. I should point out that our formatter
is actually a Formatter
from Objective C, which utilizes pointers, so if things look less Swifty than usual, that’s why. Next, we check to see if our formatter returned an error by looking at the value of errorContainer
. If an error is found we try and set the .text
property of our UITextField
to the previous value
, or to nil
if that fails. We then display an alert to the user letting them know they supplied improper input (see the sample project for full code). If there isn’t an error we attempt to cast valueContainer
as our generic type T
and set value
to newValue
.
That pretty much takes care of our new View. You can take the new view for a spin using something like NumericTextField<Double>(title: String = "Amount in dollars", value: $amountPaid, numberFormatter: currencyFormatter, keyboardType: .numbersAndPunctuation)
.
Looks good! NumericTextField
always updates its bound value when it loses focus. What’s more, we no longer have to worry about ensuring our users hit the return key to capture input and we can use whichever numbered keyboard makes sense.
Summary
For some reason I have it in my head that I should try and write as much of my code in pure SwiftUI as I can. With TextField
I broke down and don’t see any way around it for numeric values at this time. Hopefully we will see a more batteries-included TextField
in the next iteration of SwiftUI so I can become a purist again. Thanks for reading!