TL;DR
After building 3 iOS apps (Poker, MadLibs, QuestForge) totaling ~7,800 lines of Swift, here are the patterns that worked: MVVM with ObservableObject, value types for models, discriminated enums for state, and a consistent animation approach.
Introduction
The testkitchen iOS projects weren’t planned as a series. They evolved independently, each solving different problems. What’s interesting is how similar patterns emerged across all three.
This post documents those patterns—not theoretical best practices, but conventions that survived real development.
Architecture: MVVM, But Simple
The Pattern
Every iOS project settled on the same structure:
// Single ObservableObject as ViewModel
class GameState: ObservableObject {
@Published var players: [Player] = []
@Published var pot: Int = 0
// Computed properties for derived state
var activePlayers: [Player] {
players.filter { !$0.hasFolded && !$0.isAllIn }
}
}Why It Works
- Single source of truth - One object owns the state
- Computed properties - Derived state is never stale
- Minimal @Published - Only mark what actually triggers UI updates
The iOS 17 Evolution
MadLibs used the newer @Observable macro:
@Observable
class AppNavigation {
var path = NavigationPath()
var currentRoute: Route = .home
}When to use which:
ObservableObject- Complex state with many subscribers@Observable- Simpler objects, iOS 17+ only
Value Types: Structs Over Classes
The Rule
Models are structs. Period.
struct Card: Identifiable, Hashable {
let id = UUID()
let rank: Rank
let suit: Suit
}
struct Hand {
private(set) var cards: [Card] = []
mutating func add(_ card: Card) {
guard cards.count < 2 else { return } // Texas Hold'em
cards.append(card)
}
}Why It Matters
- Immutability by default - Can’t accidentally mutate shared state
- Copy semantics - Each view gets its own copy
- Predictable behavior - No reference shenanigans
The Exception
ViewModels are classes (they need identity for observation).
Enums: Your Secret Weapon
Discriminated Unions
The most powerful Swift pattern:
enum StorySegment: Codable, Hashable {
case text(String)
case blank(WordType, hint: String?)
}This single enum handles:
- Static story text
- Blanks with type requirements
- Optional hints
- Full Codable support for persistence
State Machines
enum BettingRound: Int, Comparable {
case preflop = 0
case flop = 1
case turn = 2
case river = 3
case showdown = 4
var next: BettingRound? {
BettingRound(rawValue: rawValue + 1)
}
}The Int raw value gives us:
- Natural ordering (Comparable for free)
- Easy progression (
nextcomputed property) - Serialization if needed
Validation: Guard Everything
The Pattern
func processAction(_ action: PlayerAction) -> Bool {
guard currentPlayerIndex >= 0 else { return false }
guard currentPlayerIndex < players.count else { return false }
guard let player = currentPlayer else { return false }
guard player.canAct else { return false }
// Now we know we're in a valid state
// Process the action...
}Input Sanitization
mutating func add(word: String, for type: WordType) {
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
words[type, default: []].append(trimmed)
}Every external input gets:
- Trimmed
- Validated
- Rejected or accepted
No exceptions.
Animations: Consistency Wins
The Standard Spring
Every project uses the same animation:
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: someState)Why these values?
- 0.3 response - Quick but not jarring
- 0.7 damping - Slight bounce, not rubbery
The Button Style
MadLibs introduced this, and it’s now standard:
struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7),
value: configuration.isPressed)
}
}Subtle scale (0.97, not 0.9) provides tactile feedback without being distracting.
File Organization
The Structure That Scaled
ProjectName/
├── Models/ # Data types, business logic
├── Views/ # SwiftUI views
├── Components/ # Reusable UI pieces
├── Services/ # Storage, utilities
├── Navigation/ # Navigation state
└── ContentView.swift
Code Organization
Every file uses MARK sections:
// MARK: - Properties
// MARK: - Initialization
// MARK: - Body
// MARK: - Methods
// MARK: - PreviewProtocol Conformance Checklist
Every model type should consider:
-
Identifiable- For SwiftUI lists and ForEach -
Hashable- For sets, dictionary keys, and NavigationPath -
Codable- For persistence -
Comparable- For sorting (if ordering matters)
Lessons Learned
What Worked
- Start with value types - Classes only when you need identity
- Enums for state - They catch impossible states at compile time
- Guard at boundaries - Validate inputs, trust internals
- Consistent animation - Users notice inconsistency more than quality
What Didn’t
- Deep view hierarchies - Keep views flat
- Shared mutable state - Always caused bugs
- Over-abstraction - Three similar lines beat a premature helper
Coming Up
Next post: TypeScript patterns from Eliza and GTD. Different language, surprisingly similar principles.
This post is part of the “Building Apps in Public” series. See the overview for context.