swiftui-patterns
Use when building native iOS with SwiftUI - small composable views, observable state models, atomic components, Swift 6.2+ concurrency, and system styling over hardcoded values
SKILL.mdcatalog/agents/mobile-developer/skills/swiftui-patterns/SKILL.md
GitHubSwiftUI Patterns
Overview
Native iOS (SwiftUI) is chosen when a task needs deep Apple-platform integration or the app is already native (see native-vs-flutter-decision). Same discipline as Flutter: small composed views, state out of the view, reusable atomic components.
Core principle: Views are a function of state. Data and business logic live in an observable model, not in the View.
Rules
- Small views, composed. Extract subviews as their own
Viewtypes (not@ViewBuilderfuncs) so each has its own body and preview. - State ownership is explicit:
@Statefor local ephemeral UI only (and it is alwaysprivate); app/business state in an@MainActor @Observablemodel injected viainit/@Environment. Views send intents to the model; the model owns the logic and the networking. - Atomic components: a shared component library (atoms → molecules → organisms) of reusable views. Reuse before building; grep before adding.
- System styling over hardcoded values: colors from the asset catalog /
Colorsemantic roles, spacing/typography from a tokens file (seemobile-design-system-foundation) — not scattered magic numbers. Support Dynamic Type and dark mode. #Previewevery reusable view in its key states (loading/data/error), and in the matrix: Dynamic Type.accessibility3, dark,traits: .landscapeLeft.- Value types (
struct) for models and view state; reference types only where identity is needed. - Never name a domain type
Task— it shadows_Concurrency.Task, which everyasync/.task {}call site needs; a rename (BoardItem,WorkItem, …) is a five-minute fix versus a confusing compile error later.
Concurrency
- Follow the target's default actor isolation: a new Xcode 26 project defaults to
@MainActorisolation, so a view model that touches UI state should be@MainActor @Observableexplicitly rather than relying on inference holding across Swift versions. - No GCD (
DispatchQueue.main.async, etc.) in new code —async/await,Task { },Task.sleep(for:). - Kick off async work from
.task { }on the view, notonAppear+ a detachedTask.
Worked Example
struct BoardItem: Identifiable, Sendable { let id: Int; let title: String } // never name a model `Task`
enum LoadState<T> { case loading, empty, data(T), failed(String) }
@MainActor @Observable final class ItemListModel {
private(set) var state: LoadState<[BoardItem]> = .loading
private let repo: any BoardItemRepository
init(repo: any BoardItemRepository) { self.repo = repo }
func load() async {
state = .loading
do { let items = try await repo.list(); state = items.isEmpty ? .empty : .data(items) }
catch { state = .failed(error.localizedDescription) }
}
}
struct ItemListScreen: View {
@State private var model: ItemListModel
init(model: ItemListModel) { _model = State(initialValue: model) }
var body: some View {
Group {
switch model.state {
case .loading: AppSpinner()
case .empty: ContentUnavailableView("No items yet", systemImage: "tray")
case .failed(let message):
ContentUnavailableView {
Label("Couldn't load items", systemImage: "exclamationmark.triangle")
} description: { Text(message) } actions: {
Button("Try again") { Task { await model.load() } }
}
case .data(let items): ItemList(items: items)
}
}
.task { await model.load() }
}
}
Typechecks with swiftc -swift-version 6 (verified). The original example's domain struct Task shadowed _Concurrency.Task, giving error: missing argument for parameter 'title' in call at every Task { } call site — renamed to BoardItem here. Note the explicit .empty case rendered with ContentUnavailableView and a retry action on .failed, and the init that seeds State(initialValue:) so the model can be injected.
Previews at the matrix (typechecked against the iOS 18 simulator SDK):
#Preview("Phone, AX3, dark") { ItemListScreen(model: .preview).environment(\.dynamicTypeSize, .accessibility3).preferredColorScheme(.dark) }
#Preview("Landscape", traits: .landscapeLeft) { ItemListScreen(model: .preview) }
For a layout that must switch at large accessibility sizes: typeSize.isAccessibilitySize ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout()). For an icon-only control: Button("Add to cart", systemImage: "cart.badge.plus", action:) with .labelStyle(.iconOnly) and .frame(minWidth: 44, minHeight: 44) (see mobile-accessibility).
Rules for testing
- Unit-test the
@Observablemodel with a mocked repository (no UI). - New tests in Swift Testing (
import Testing,@Test,#expect) where the target already uses Xcode 16+; XCTest stays for existing UI tests andapp.performAccessibilityAudit(). Test-first.
Modern API
foregroundStyle(notforegroundColor),.clipShape(.rect(cornerRadius:))(not.cornerRadius), theTabAPI for tab bars, the two-parameteronChange(of:) { old, new in },.topBarTrailingplacement,containerRelativeFrameinstead ofGeometryReaderwhen it covers the case, neverUIScreen.main.bounds.Color.withValuesis not a thing in SwiftUI (that's the Flutter equivalent) — use asset-catalog colours orColor(.sRGB, ...)/.opacity()as the repo already does.
Liquid Glass (iOS 26+)
- Standard bars, tab views and sheets adopt Liquid Glass automatically when built with Xcode 26+. Don't paint an opaque custom background over a toolbar/tab bar to "fix" this.
- Use
.glassEffectonly on custom floating controls, and only when the task asks for it. UIDesignRequiresCompatibilityis ignored when building with the iOS 27 SDK — don't rely on it to opt back out of the new look.
Common Mistakes
- Networking or business rules inside
body/View. @ViewBuilderhelper funcs instead of real subviews.- Hardcoded colors/sizes instead of asset-catalog + tokens.
- No previews, so every change needs a full build to see.
- A domain model named
Task. - GCD calls in new code instead of
async/await.
Red Flags
- A
Viewwith a URLSession call in it. - Magic
Color(red:...)and paddings sprinkled across views. - A 200-line
body. - A type named
Taskanywhere in the diff.