Restricted TextFields In SwiftUI – A Reusable Implementation
Forms are hard to get right! When you don’t control user input in a TextField and solely rely on validation upon submission, it often leads to a subpar user experience, as users are forced to revisit and correct each TextField, one by one. Unfortunately, SwiftUI doesn’t inherently offer an easy, reusable solution for limiting input within TextFields, particularly for more complex use cases. This article is here to change that! In the following sections, we’ll provide you with a comprehensive guide on implementing a powerful and flexible restricted input system in SwiftUI that can effortlessly handle a variety of complex form scenarios.
Introduction
Let’s say we want to let the user configure an age between 18 and 30, a shirt size between M and XL, and a floating-point percentage between 0 and 100. Wouldn’t it be cool to let the user just enter (almost) correct values and directly prevent invalid input? And all that while having a clean view free of business logic like this? 😎
struct SettingsView: View { @ObservedObject private var viewModel = SettingsViewModel() var body: some View { List { RestrictedTextField(for: $viewModel.settings.age) .labelled(label: "Age:") RestrictedTextField(for: $viewModel.settings.shirtSize, allowedInvalidInput: InputRestriction.allowedInvalidInput) .labelled(label: "Shirt size:") RestrictedTextField(for: $viewModel.settings.percentage) .labelled(label: "Percentage:") } } }
A first approach ✨
As a first step let’s model the settings with a simple struct holding our three desired values using an unsigned integer, an enum, and a double as the underlying types. Additionally, we define default values, that can be used initially.
// enums can be comparable by default since Swift 5.3 enum ShirtSize: Comparable, CaseIterable { case s case m case l case xl case xxl } struct Settings { var age: UInt var shirtSize: ShirtSize var percentage: Double } extension Settings { static let `default` = Settings(age: 25, //Should be between 18 and 30 shirtSize: .m, // Should be between m and xl percentage: 82.2) // Should be between 0.0 and 100.0 }
We can easily see that not just the values but also their restrictions should be encoded to stay generic. An easy approach for that is to store them as closed ranges next to the values:
struct Settings { var age: UInt let ageRestriction: ClosedRange<UInt> var shirtSize: ShirtSize let shirtSizeRestriction: ClosedRange<ShirtSize> var percentage: Double let percentageRestriction: ClosedRange<Double> } extension Settings { static let `default` = Settings(age: 25, ageRestriction: 18...30, shirtSize: .m, shirtSizeRestriction: .m ... .xl, percentage: 82.2, percentageRestriction: 0...100) }
This is already quite nice, so let’s use it for our settings view: First, we create string states @State
, which we can use as bindings for the input of text fields using init
. Then we listen to the changes of these strings in onChange
and update the settings in the view model accordingly.
class SettingsViewModel: ObservableObject { // Storing the settings persistently @Published public var settings: Settings = .default } struct SettingsView: View { @ObservedObject private var viewModel = SettingsViewModel() @State private var ageText: String = "" @State private var shirtSizeText: String = "" @State private var percentageText: String = "" var body: some View { List { TextField( viewModel.settings.ageRestriction.description, text: $ageText ).labelled(label: "Age:") TextField( viewModel.settings.shirtSizeRestriction.description, text: $shirtSizeText ).labelled(label: "Shirt size:") TextField( viewModel.settings.percentageRestriction.description, text: $percentageText ).labelled(label: "Percentage:") } .onChange(of: ageText) { value in let age = UInt(ageText).filter { age in viewModel.settings.ageRestriction.contains(age) } guard let age else { return } viewModel.settings.age = age } .onChange(of: shirtSizeText) { value in /* same as age, with a conversion of String to ShirtSize */ } .onChange(of: percentageText) { value in /* same as age */ } } }
If you wonder where the filter on Optional
comes from, look at this cool extension:
extension Optional { // Cool 😎 func filter(condition: (Wrapped) -> Bool) -> Self { flatMap { condition($0) ? $0 : nil } } }
The setting values are just simple examples. The age and shirt size for instance could be in this case easily represented by a picker view. But imagine for example a range between 1 and 1.000.000, there nobody would want to scroll through a picker 😅. This solution is already acceptable if we are fine with letting the users enter whatever they want and us just storing correct inputs. One important improvement would be to move the parsing logic out of the view since business logic does not belong there. We could either move it into the view model, generating a little boilerplate code, or better use a ParseStrategy
or a Formatter
as described in this article.
Hint 📚: The article has a small bug in the RangeIntegerStrategy
, which does not parse only integers in the specified range, but all of them. It would need to look like this:
struct RangeIntegerStrategy: ParseStrategy { let range: ClosedRange<UInt> func parse(_ value: String) throws -> UInt { guard let int = UInt(value), range.contains(int) else { throw ParseError() } return int } } private struct ParseError: Error {}
Additionally, we would probably want to indicate invalid inputs to the user, ending up with a solution like this:
struct SettingsView: View { @ObservedObject private var viewModel = SettingsViewModel() @State private var age: UInt? // ... var body: some View { List { TextField(viewModel.settings.ageRestriction.description, value: $age, format: .ranged(viewModel.settings.ageRestriction)) .foregroundColor(age != nil ? .primary : .red) .labelled(label: "Age:") .onChange(of: age) { newValue in guard let newValue else { return } viewModel.settings.age = newValue } // ... } } }
Restrictions⛓️
Just as it is a good practice to restrict a function as much as possible taking only valid inputs (We described the reason in our NonEmptyList article) it would be nice to also directly restrict the user’s input. The first step is to use the correct keyboard layout: .numberPad
for integers, .decimalPad
for decimals, etc. But let’s not stop there: If a user for example enters a percentage over 100, why would we want to even display it? We could simply just stick to the last valid input.
As a first convenience on our way there, we define the following struct to nicely hide the restriction logic:
struct Restricted<Value> where Value: Comparable { let value: Value let range: ClosedRange<Value> init?(_ value: Value, in range: ClosedRange<Value>) { guard range.contains(value) else { return nil } self.value = value self.range = range } } extension Restricted { static func clamped(_ value: Value, in range: ClosedRange<Value>) -> Restricted<Value> { var clampedValue: Value = value if value < range.lowerBound { clampedValue = range.lowerBound } else if range.upperBound < value { clampedValue = range.upperBound } return .init(clampedValue, in: range)! } }
We store a comparable value and a range of the same type as the restriction. Furthermore, we give two possibilities to create such a data type: A failable initializer that returns nil
if the restriction is not met and a factory function clamped
that clamps the input if needed.The restriction can be easily generalized from ranges to predicates. We will not use the power of the generalization in our example, but it can come in very handy in a lot of use cases (a simple example would be just allowing even numbers) such that we do not want to withhold it from you:
// Generic restriction using a predicate protocol Restriction { associatedtype Value func condition(value: Value) -> Bool } struct Restricted<Value: Comparable, RestrictionType: Restriction> where RestrictionType.Value == Value { let value: Value let restriction: RestrictionType init?(_ value: Value, _ restriction: RestrictionType) { guard restriction.condition(value: value) else { return nil } self.value = value self.restriction = restriction } } extension Restricted { // Would be cool if such copy functions could be auto-generated func copy(value: Value? = nil, restriction: RestrictionType? = nil) -> Self? { .init(value ?? self.value, restriction ?? self.restriction) } }
We can then use the generic Restricted
type to create an again specific version that uses ranges:
struct RangeRestriction<Value: Comparable>: Restriction { let range: ClosedRange<Value> func condition(value: Value) -> Bool { range.contains(value) } } // Same factory functions as before extension Restricted where RestrictionType == RangeRestriction<Value> { init?(_ value: Value, in range: ClosedRange<Value>) { self.init(value, RangeRestriction(range: range)) } static func clamped(_ value: Value, in range: ClosedRange<Value>) -> Self { let clampedValue: Value if value < range.lowerBound { clampedValue = range.lowerBound } else if range.upperBound < value { clampedValue = range.upperBound } else { clampedValue = value } return .init(clampedValue, in: range)! } } // Shorthand to make the type signature simpler typealias RestrictedToRange<Value: Comparable> = Restricted<Value, RangeRestriction<Value>>
We can already refine our Settings
data type to be more concise:
struct Settings { var age: RestrictedToRange<UInt> var shirtSize: RestrictedToRange<ShirtSize> var percentage: RestrictedToRange<Double> } extension Settings { static let `default` = Settings(age: .clamped(25, in: 18...30), shirtSize: .clamped(.m, in: .m ... .xl), percentage: .clamped(82.2, in: 0...100)) }
A cool thing about our generic Restricted
implementation is, that we could now easily defer the decision on the restriction type by giving the Settings
struct some generics.
Evaluating the input ➗
We arrived at the core of our implementation: The parsing and evaluation of the input. For that, we create a type called InputRestriction
, which evaluates an input string and the last valid corresponding value to a new valid string value pair. For that, it just needs to know which strings represent a valid value (parse
parameter) and how to convert a value back to a string (toString
parameter).
struct InputRestriction<Value: Equatable> { private let parse: (String) -> Value? private let toString: (Value) -> String struct Result { let value: Value let string: String fileprivate init(_ value: Value, _ string: String) { self.value = value self.string = string } } init(parse: @escaping (String) -> Value?, toString: @escaping (Value) -> String) { self.parse = parse self.toString = toString } func evaluate(value: Value, string: String? = nil) -> Result { // ... } }
InputRestriction
is basically just a bidirectional parser, such that we could easily create it from a FormatStyle
or a Formatter
. The evaluation checks if the current input string can be parsed to a valid value. If yes, it is simply returned and otherwise we keep the previous input string. Additionally, we need to add the possibility to clear the input field, since it would not be possible to enter all possible values otherwise.func evaluate(value: Value, string: String? = nil) -> Result { guard let string else { return .init(value, toString(value)) } func handleInvalidInput() -> Result { let currentValueString = toString(value) // Making it possible to have an empty input field if currentValueString.hasPrefix(string) { return .init(value, "") } else { return .init(value, toString(value)) } } if let newValue = parse(string) { return .init(newValue, toString(newValue)) } else { return handleInvalidInput() } }
More precisely, we already make it impossible to enter all valid values: In our example we allow ages to be entered between “18” and “25”, meaning “1” would be an invalid input but without temporarily entering “1” we also cannot arrive at “18”. To overcome this issue, we extend the InputRestriction
to allow certain invalid inputs:
init(parse: @escaping (String) -> Value?, toString: @escaping (Value) -> String, allowedInvalidInput: @escaping (String) -> AllowedInvalidInput? = { _ in nil })
Now we can easily create an InputRestriction
for numbers like integers or doubles using that they are LosslessStringConvertible
and ExpressibleByIntegerLiteral
:
extension InputRestriction where Value: LosslessStringConvertible, Value: Comparable, Value: ExpressibleByIntegerLiteral { init(restricted: RestrictedToRange<Value>) { let range = restricted.restriction.range let parse: (String) -> Value? = { guard let value = Value.init($0), range.contains(value) else { return nil } return value } let allowedInvalidRange = 0..<range.lowerBound let allowedInvalidInput = { (string: String) in Value.init(string) .filter { allowedInvalidRange.contains($0) } .map(AllowedInvalidInput.init) } self.init(parse: parse, toString: \.description, allowedInvalidInput: allowedInvalidInput) } }
Note: We just use LosslessStringConvertible
for demonstration purposes. Since it is not localized, we recommend to use a localized alternative in production.
What a nice view! 🪟
We now put all the pieces together and build the actual SwiftUI view.
struct RestrictedView<Value: Equatable, Content: View>: View { ... }
The RestrictedView
is generic in the value type that can be entered into it and the actual view type. In our case that will be a TextField
, but also other views like a TextEditor
, Slider
or Picker
would be possible.To create a RestrictedView
we expect a binding of the represented value, which can for example directly be a persistent storage. Like that, every input would be immediately stored. Moreover, we also expect an InputRestriction
and a closure that builds a view with the binding of the input string and an indication of its validity. The logic of the RestrictedView
is quite simple: We evaluate every new input string using the InputRestriction
and update value and string bindings with its result.
struct RestrictedView<Value: Equatable, Content: View>: View { private let restriction: InputRestriction<Value> private let content: (Binding<String>, Bool) -> Content @Binding private var value: Value @State private var string: String @State private var isValid: Bool var body: some View { content($string, isValid).onChange(of: string, perform: update) } init(for value: Binding<Value>, by restriction: InputRestriction<Value>, @ViewBuilder content: @escaping (Binding<String>, Bool) -> Content) { self._value = value self.restriction = restriction self.content = content // Evaluate the initial value let initial = restriction.evaluate(value: value.wrappedValue) self._isValid = .init(initialValue: initial.isValid) self._string = .init(initialValue: initial.string) } private func update(_ string: String) { // Call the input restriction let result = restriction.evaluate(value: value, string: string) // Update state value = result.value self.string = result.string isValid = result.isValid } }
A specialized version of the RestrictedView
is the RestrictedTextField
which gets its input through a TextField
. For that, we simply create a TextField
in the view builder closure of the RestrictedView
:
struct RestrictedTextField<Value: Equatable, Modifier: ViewModifier>: View { private let value: Binding<Value> private let restriction: InputRestriction<Value> private let prompt: String private let onInput: (Bool) -> Modifier var body: some View { // Easily created using a closure RestrictedView(for: value, by: restriction) { $text, isValid in TextField(prompt, text: $text) .modifier(onInput(isValid)) } } init(for value: Binding<Value>, by restriction: InputRestriction<Value>, prompt: String = "", // Modify the view based on valid/ invalid displayed input onInput: @escaping (Bool) -> Modifier) { self.value = value self.restriction = restriction self.prompt = prompt self.onInput = onInput } }
To support a visual indication for invalid inputs, we can configure it using a ViewModifier
. For instance, if we want to make the text red for invalid inputs, we can use a modifier like this:
struct DefaultInputViewModifier: ViewModifier { private let isValid: Bool func body(content: Content) -> some View { content.foregroundColor(isValid ? .primary : .red) } init(isValid: Bool) { self.isValid = isValid } }
Victory 🥇
Finally, we arrived at our goal and can create concisely generic restricted input views. We use it for all kinds of values that can be entered using a string. Also, the input can come from any standard SwiftUI view and even custom ones. And on the way, we saw a lot of cool Swift/ SwiftUI concepts and features.
struct SettingsView: View { @ObservedObject private var viewModel = SettingsViewModel() var body: some View { List { RestrictedTextField(for: $viewModel.settings.age) .labelled(label: "Age:") .keyboardType(.numberPad) RestrictedTextField(for: $viewModel.settings.shirtSize, allowedInvalidInput: InputRestriction.allowedInvalidInput) .labelled(label: "Shirt size:") RestrictedTextField(for: $viewModel.settings.percentage) .labelled(label: "Percentage:") .keyboardType(.decimalPad) } } }
You can find our final code here.
Thanks for reading the article 🙏 If you enjoyed this article you might also want to checkout our article about Passkeys that were introduced in iOS 16.