SwiftUI Development Patterns
Modern SwiftUI patterns for building performant, maintainable user interfaces.
When to Apply
- Building new SwiftUI views or refactoring existing ones
- Choosing state management strategy (@State, @Observable, TCA)
- Optimizing list/scroll performance (lazy containers, equatable views)
- Implementing forms with validation
- Adding animations and transitions
- Setting up navigation (NavigationStack, coordinator)
- Writing #Preview blocks for any SwiftUI view
Quick Reference
| Pattern | When to Use | Reference |
|---|---|---|
| Card / Compound Components | Reusable UI building blocks | view-composition.md |
| ViewBuilder Closures | Generic containers with custom content | view-composition.md |
| Custom ViewModifier | Reusable styling and behavior | view-composition.md |
| PreferenceKey | Reading child geometry (size, offset) | view-composition.md |
| Custom Layout (iOS 16+) | FlowLayout, tag clouds | view-composition.md |
| @Observable + Reducer | Predictable state with actions | state-management.md |
| @Observable / @Bindable | Simple iOS 17+ state management | state-management.md |
| Environment DI | Testable dependency injection | state-management.md |
| TCA | Composable Architecture apps | state-management.md |
| NavigationStack + Route | Type-safe navigation (iOS 16+) | state-management.md |
| LoadingState enum | Idle/loading/loaded/failed states | state-management.md |
| Pagination | Infinite scroll with prefetch | state-management.md |
| Implicit / Explicit Animation | Transitions and spring animations | animation.md |
| Matched Geometry Effect | Hero transitions between views | animation.md |
| Reduce Motion | Accessibility-safe animations | animation.md |
| #Preview Smart Generation | Auto-embedding rules for previews | preview-rules.md |
Key Patterns
View Composition
// Card pattern with @ViewBuilder
struct Card<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
content
}
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 2)
}
}
State Management Overview
// @State: Local value state
@State private var count = 0
// @Binding: Two-way reference to parent state
@Binding var isPresented: Bool
// @Observable (iOS 17+): Reference type state
@Observable
final class AppState {
var user: User?
var isAuthenticated: Bool { user != nil }
}
// Usage in views
struct ContentView: View {
@State private var appState = AppState()
var body: some View {
MainView()
.environment(appState)
}
}
struct ProfileView: View {
@Environment(AppState.self) private var appState
var body: some View {
if let user = appState.user {
Text(user.name)
}
}
}
Performance: Lazy Containers
// GOOD: Lazy loading for long lists
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item: item)
}
}
.padding()
}
// BAD: Regular VStack loads all views immediately
ScrollView {
VStack { // All 1000 views rendered at once
ForEach(items) { item in
ItemCard(item: item)
}
}
}
Performance: Equatable Views
struct ItemCard: View, Equatable {
let item: Item
static func == (lhs: ItemCard, rhs: ItemCard) -> Bool {
lhs.item.id == rhs.item.id &&
lhs.item.name == rhs.item.name
}
var body: some View {
VStack {
Text(item.name)
StatusBadge(status: item.status)
}
}
}
// Usage
ForEach(items) { item in
EquatableView(content: ItemCard(item: item))
}
Performance: Computed Properties vs State
// GOOD: Computed properties for derived data
var filteredItems: [Item] {
guard !searchQuery.isEmpty else { return items }
return items.filter { $0.name.localizedCaseInsensitiveContains(searchQuery) }
}
// BAD: Storing derived state (out-of-sync risk)
@State private var filteredItems: [Item] = [] // Redundant state
Form Handling
struct CreateItemForm: View {
@State private var name = ""
@State private var description = ""
@State private var errors: [FieldError] = []
enum FieldError: Identifiable {
case nameTooShort, nameTooLong, descriptionEmpty
var id: String { String(describing: self) }
var message: String {
switch self {
case .nameTooShort: "Name is required"
case .nameTooLong: "Name must be under 200 characters"
case .descriptionEmpty: "Description is required"
}
}
}
private var isValid: Bool {
errors.isEmpty && !name.isEmpty && !description.isEmpty
}
var body: some View {
Form {
Section("Details") {
TextField("Name", text: $name)
if let error = errors.first(where: { $0 == .nameTooShort || $0 == .nameTooLong }) {
Text(error.message)
.foregroundColor(.red)
.font(.caption)
}
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
Button("Submit") { submit() }
.disabled(!isValid)
}
.onChange(of: name) { validate() }
.onChange(of: description) { validate() }
}
private func validate() { /* validate fields, populate errors */ }
private func submit() {
validate()
guard isValid else { return }
}
}
Animation Overview
// Implicit animation
List(items) { item in
ItemRow(item: item)
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .trailing)),
removal: .opacity.combined(with: .move(edge: .leading))
))
}
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: items)
// Explicit animation
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isExpanded.toggle()
}
References
- View Composition Patterns - Card, compound components, ViewBuilder, ViewModifier, PreferenceKey, custom Layout
- State Management Patterns - @Observable, reducer, DI, TCA, navigation, loading state, pagination, memory
- Animation Patterns - Implicit/explicit animations, matched geometry, reduce motion, task modifiers
- Preview Rules - #Preview smart generation, auto-embedding, accessibility patterns
Common Mistakes
1. Using VStack instead of LazyVStack in ScrollView
Regular VStack inside ScrollView instantiates all child views immediately. Always use LazyVStack for lists with more than ~20 items.
2. Storing Derived State
Do not store filtered/sorted/mapped versions of existing state in separate @State properties. Use computed properties instead to avoid synchronization bugs.
3. Using Legacy PreviewProvider
// NEVER use PreviewProvider (legacy)
struct MyView_Previews: PreviewProvider {
static var previews: some View { ... }
}
// ALWAYS use #Preview macro
#Preview { MyView() }
4. Missing weak self in Combine/Timer Closures
Always use [weak self] in escaping closures (Combine sinks, Timer callbacks) to prevent retain cycles. The .task modifier handles cancellation automatically.
5. Overusing @StateObject / @ObservedObject
On iOS 17+, prefer @Observable with @State (for ownership) or @Environment (for injection) instead of ObservableObject + @StateObject / @ObservedObject.
Remember: Choose patterns that fit your project complexity. Start simple and add abstractions only when needed.