Skip to content

Instantly share code, notes, and snippets.

@Alex-Ozun
Last active October 8, 2025 19:27
Show Gist options
  • Save Alex-Ozun/1005ca295769a2390157c42db291e5c5 to your computer and use it in GitHub Desktop.
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/
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