swiftui-ui-patterns
SwiftUI UI Patterns
Section titled “SwiftUI UI Patterns”Quick Start
Section titled “Quick Start”Existing Project
Section titled “Existing Project”- Identify the feature and primary interaction model (list, detail, editor, settings, tabbed)
- Find nearby examples in the repo with
rg "TabView\("or similar - Apply local conventions: prefer SwiftUI-native state, keep state local
- Build with small, focused subviews and SwiftUI-native data flow
New Project Scaffolding
Section titled “New Project Scaffolding”- Wire TabView + NavigationStack + sheets
- Add minimal
AppTabenum andRouterPath - Expand route and sheet enums as new screens are added
General Rules
Section titled “General Rules”- Use modern SwiftUI state (
@State,@Binding,@Observable,@Environment) - Avoid unnecessary view models - prefer MV (Model-View) over MVVM
- Prefer composition; keep views small and focused
- Use async/await with
.taskand explicit loading/error states - Follow the project’s formatter and style guide
Sheets Best Practices
Section titled “Sheets Best Practices”- Prefer
.sheet(item:)over.sheet(isPresented:)when state represents a selected model - Avoid
if letinside a sheet body - Sheets should own their actions and call
dismiss()internally
Workflow for a New SwiftUI View
Section titled “Workflow for a New SwiftUI View”- Define the view’s state and its ownership location
- Identify dependencies to inject via
@Environment - Sketch the view hierarchy and extract repeated parts into subviews
- Implement async loading with
.taskand explicit state enum if needed - Add accessibility labels or identifiers for interactive UI
- Validate with a build and update usage callsites if needed
MV Pattern (Preferred over MVVM)
Section titled “MV Pattern (Preferred over MVVM)”SwiftUI views should be lightweight state expressions. Avoid ViewModels unless truly necessary.
struct FeedView: View { @Environment(APIClient.self) private var client
enum ViewState { case loading case error(String) case loaded([Post]) }
@State private var viewState: ViewState = .loading
var body: some View { NavigationStack { List { switch viewState { case .loading: ProgressView("Loading...") case .error(let message): ErrorView(message: message, retry: { await loadFeed() }) case .loaded(let posts): ForEach(posts) { post in PostRow(post: post) } } } .task { await loadFeed() } } }
private func loadFeed() async { do { let posts = try await client.getFeed() viewState = .loaded(posts) } catch { viewState = .error(error.localizedDescription) } }}Sheet Patterns
Section titled “Sheet Patterns”Item-driven sheet (preferred)
Section titled “Item-driven sheet (preferred)”@State private var selectedItem: Item?
.sheet(item: $selectedItem) { item in EditItemSheet(item: item)}Sheet owns its actions
Section titled “Sheet owns its actions”struct EditItemSheet: View { @Environment(\.dismiss) private var dismiss @Environment(Store.self) private var store
let item: Item @State private var isSaving = false
var body: some View { VStack { Button(isSaving ? "Saving..." : "Save") { Task { await save() } } } }
private func save() async { isSaving = true await store.save(item) dismiss() }}App-Level Environment Setup
Section titled “App-Level Environment Setup”@mainstruct MyApp: App { @State var client: APIClient = .init() @State var router: AppRouter = .init()
var body: some Scene { WindowGroup { TabView(selection: $router.selectedTab) { ForEach(AppTab.allCases) { tab in tab.rootView .tag(tab) } } .environment(client) .environment(router) } }}State Management Guidelines
Section titled “State Management Guidelines”| Wrapper | Use Case |
|---|---|
@State |
Local, ephemeral view state |
@Binding |
Two-way data flow from parent |
@Observable |
Shared state across views (iOS 17+) |
@Environment |
Dependency injection, app-wide concerns |
@Query |
SwiftData queries directly in views |
Task and onChange Patterns
Section titled “Task and onChange Patterns”// React to state changes.task(id: searchText) { guard !searchText.isEmpty else { return } await search(query: searchText)}
// Respond to state transitions.onChange(of: isActive, initial: false) { guard isActive else { return } Task { await refresh() }}Why Not MVVM?
Section titled “Why Not MVVM?”SwiftUI was designed without ViewModels in mind:
- Views are structs, lightweight and disposable
@State,@Environment,@Observablehandle all data flow needs- ViewModels add complexity, indirection, and cognitive overhead
- SwiftData’s
@Queryworks directly in views
Instead:
- Keep views as pure expressions of state
- Put business logic in services/models injected via
@Environment - Test services and models, not views
- Use SwiftUI Previews for visual regression testing