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 Optionalcomes 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 ParseStrategyor a Formatteras 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 Settingsdata 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 Settingsstruct 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 (parseparameter) and how to convert a value back to a string (toStringparameter).

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 {
        // ...
    }

}
InputRestrictionis basically just a bidirectional parser, such that we could easily create it from a FormatStyle or a FormatterThe 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.

Share via
Copy link
Powered by Social Snap

Get notified when our next article is born!

(no spam, just one app-development-related article
per month)