Last active
October 8, 2025 19:27
-
-
Save Alex-Ozun/1005ca295769a2390157c42db291e5c5 to your computer and use it in GitHub Desktop.
Comprehensive example of InputState for https://swiftology.io/articles/tydd-part-2/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import Money // you need to add a dependency - https://github.com/Flight-School/Money | |
extension InputState: Equatable where Value: Equatable {} | |
enum InputState<Value> { | |
case empty | |
case valid(text: String, value: Value) | |
case invalid(text: String, errorMessage: String?) | |
// To be used as a proof of validity | |
var value: Value? { | |
return switch self { | |
case .empty, .invalid: nil | |
case let .valid(_, value): value | |
} | |
} | |
// To be displayed on UI | |
var text: String { | |
return switch self { | |
case .empty: "" | |
case let .invalid(text, _), let .valid(text, _): text | |
} | |
} | |
// To be displayed on UI | |
var errorMessage: String? { | |
return switch self { | |
case .empty, .valid: nil | |
case let .invalid(_, errorMessage): errorMessage | |
} | |
} | |
} | |
protocol StringParser { | |
associatedtype ParsingError: LocalizedError | |
init(_ rawValue: String) throws(ParsingError) | |
} | |
extension InputState where Value: StringParser { | |
// To be used for UI bindings | |
var text: String { | |
get { | |
return switch self { | |
case .empty: "" | |
case let .invalid(text, _), let .valid(text, _): text | |
} | |
} | |
set { | |
// parse every new text input | |
self = InputState(newValue) | |
} | |
} | |
init(_ inputText: String?) { | |
// truncate whitespace if needed | |
guard let inputText, !inputText.isEmpty else { | |
self = .empty | |
return | |
} | |
do throws(Value.ParsingError) { | |
let value = try Value(inputText) | |
self = .valid(text: inputText, value: value) | |
} catch { | |
self = .invalid(text: inputText, errorMessage: error.localizedDescription) | |
} | |
} | |
} | |
struct Password: StringParser, Equatable { | |
enum ParsingError: Error, LocalizedError { | |
case tooShort | |
var errorDescription: String? { | |
switch self { | |
case .tooShort: | |
"Your password is too short!" | |
} | |
} | |
} | |
let rawValue: String | |
init(_ rawValue: String) throws(ParsingError) { | |
guard rawValue.count > 8 else { | |
throw .tooShort | |
} | |
self.rawValue = rawValue | |
} | |
} | |
struct Form: View { | |
// This is a domain-specific UI component, so we use strong types like Money and Password to manage state | |
@State private var money: InputState<Money<USD>> = .empty | |
@State private var password: InputState<Password> = .empty | |
@State private var additionalMessage: String? | |
var body: some View { | |
VStack { | |
VStack { | |
// Text Field with complex behaviours like formatting, error debouncing, input rejection, etc. | |
// Works in tandem with custom InputHandlers that provide these behaviours. | |
ComplexTextField( | |
title: "Amount", | |
text: money.text, | |
errorMessage: money.errorMessage, | |
inputHandler: MoneyInputHandler { money = $0 } | |
) | |
} | |
// Vanilla Text Field that works directly with InputState that | |
// parses strings directly, without formatting. | |
VStack { | |
TextField("Password", text: $password.text) | |
if let errorMessage = password.errorMessage { | |
Text(errorMessage) | |
} | |
if let additionalMessage { | |
Text(additionalMessage) | |
} | |
} | |
// Example of changing input states externally | |
Button("Clear") { | |
password = .empty | |
money = .empty | |
}.disabled(password == .empty && money == .empty) | |
Button("Save") { | |
// Example of accessing code sections that are protected by value-as-proof requirement | |
if let password = password.value, let money = money.value{ | |
save(password: password, money: money) | |
} | |
}.disabled(password.value == nil || money.value == nil) | |
} | |
// Example of observing InputState for additional behaviours | |
.onChange(of: password) { old, new in | |
switch (old, new) { | |
case (.invalid, .invalid): | |
additionalMessage = "Try harder!" | |
case (.invalid, .valid): | |
additionalMessage = "Good job fixing your password" | |
default: | |
additionalMessage = nil | |
} | |
} | |
} | |
// Example of code sections protected by a value-as-proof requirement | |
func save(password: Password, money: Money<USD>) { | |
additionalMessage = "Saved \(Array(repeating: "*", count: password.rawValue.count - 2).joined())" + password.rawValue.suffix(2) + " and $\(money.amount)" | |
} | |
} | |
struct ComplexTextField: View { | |
let title: LocalizedStringKey | |
let text: String | |
let errorMessage: String? | |
let inputHandler: any InputHandler | |
// This is a generic UI component, so we use primitive types to manage internal state | |
@FocusState private var isFocused: Bool | |
@State private var oldText: String = "" | |
@State private var currentText: String = "" | |
@State private var debouncedErrorMessage: String? | |
var body: some View { | |
VStack { | |
TextField(title, text: $currentText) | |
.focused($isFocused) | |
.onChange(of: isFocused, initial: true) { _, isFocused in | |
inputHandler.process(currentText, event: isFocused ? .onFocus : .onBlur) | |
} | |
// changes from within | |
.onChange(of: currentText, initial: true) { _, newValue in | |
if inputHandler.shouldAccept(newValue) { | |
inputHandler.process(newValue, event: isFocused ? .onFocus : .onBlur) | |
} else { | |
currentText = oldText | |
} | |
} | |
// changes from the outside | |
.onChange(of: text, initial: true) { _, newValue in | |
currentText = newValue | |
oldText = newValue | |
} | |
// error message debouncing | |
.task(id: errorMessage) { | |
do { | |
if let errorMessage { | |
try await Task.sleep(for: .seconds(0.5)) | |
debouncedErrorMessage = errorMessage | |
} else { | |
debouncedErrorMessage = nil | |
} | |
} catch { } | |
} | |
if let debouncedErrorMessage { | |
Text(debouncedErrorMessage) | |
} | |
} | |
} | |
} | |
enum InputEvent { | |
case onChange | |
case onFocus | |
case onBlur | |
} | |
protocol InputHandler<Value> { | |
associatedtype Value | |
var onChange: (_ inputState: InputState<Value>) -> Void { get } | |
func process(_ input: String, event: InputEvent) | |
func shouldAccept(_ proposedInput: String) -> Bool | |
} | |
struct MoneyInputHandler<Currency: CurrencyType>: InputHandler { | |
var onChange: (_ inputState: InputState<Money<Currency>>) -> Void | |
func shouldAccept(_ proposedInput: String) -> Bool { | |
if let money = Money<Currency>(proposedInput), !proposedInput.isEmpty { | |
// Reject invalid money fractions | |
// ❌ 0.333 | |
// ✅ 0.33 | |
return money == money.rounded | |
} else { | |
return true | |
} | |
} | |
func process(_ inputText: String, event: InputEvent) { | |
guard !inputText.isEmpty else { | |
onChange(.empty) | |
return | |
} | |
// Type-safe validation (aka parsing) is still performed by | |
// a strong parser type like Money that produces value-as-proof of validation. | |
// Input handler just provide frills like formatting on top of it. | |
if let money = Money<Currency>(inputText) { | |
let text = switch event { | |
case .onFocus, .onChange: inputText // unformatted | |
case .onBlur: String(money) // formatted | |
} | |
onChange(.valid(text: text, value: money)) | |
} else { | |
onChange(.invalid(text: inputText, errorMessage: "not a valid amount")) | |
} | |
} | |
} | |
#Preview { | |
Form() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment