Enhance swiftui-patterns skill

This commit is contained in:
Thomas Ricouard 2026-03-15 10:50:34 +01:00
parent 1ec2d7af93
commit 098389dd35
6 changed files with 238 additions and 54 deletions

View file

@ -30,13 +30,15 @@ Choose a track based on your goal:
- Use modern SwiftUI state (`@State`, `@Binding`, `@Observable`, `@Environment`) and avoid unnecessary view models.
- If the deployment target includes iOS 16 or earlier and cannot use the Observation API introduced in iOS 17, fall back to `ObservableObject` with `@StateObject` for root ownership, `@ObservedObject` for injected observation, and `@EnvironmentObject` only for truly shared app-level state.
- Prefer composition; keep views small and focused.
- Use async/await with `.task` and explicit loading/error states.
- Use async/await with `.task` and explicit loading/error states. For restart, cancellation, and debouncing guidance, read `references/async-state.md`.
- Keep shared app services in `@Environment`, but prefer explicit initializer injection for feature-local dependencies and models. For root wiring patterns, read `references/app-wiring.md`.
- Prefer the newest SwiftUI API that fits the deployment target and call out the minimum OS whenever a pattern depends on it.
- Maintain existing legacy patterns only when editing legacy files.
- Follow the project's formatter and style guide.
- **Sheets**: Prefer `.sheet(item:)` over `.sheet(isPresented:)` when state represents a selected model. Avoid `if let` inside a sheet body. Sheets should own their actions and call `dismiss()` internally instead of forwarding `onCancel`/`onConfirm` closures.
- **Scroll-driven reveals**: Prefer deriving a normalized progress value from scroll offset and driving the visual state from that single source of truth. Avoid parallel gesture state machines unless scroll alone cannot express the interaction.
## State ownership matrix
## State ownership summary
Use the narrowest state tool that matches the ownership model:
@ -45,51 +47,21 @@ Use the narrowest state tool that matches the ownership model:
| Local UI state owned by one view | `@State` |
| Child mutates parent-owned value state | `@Binding` |
| Root-owned reference model on iOS 17+ | `@State` with an `@Observable` type |
| Child reads or mutates an injected `@Observable` model | Pass it explicitly as a stored property |
| Child reads or mutates an injected model | Pass it explicitly as a stored property |
| Shared app service or configuration | `@Environment(Type.self)` |
| Legacy reference model on iOS 16 and earlier | `@StateObject` at the root, `@ObservedObject` when injected |
Choose the ownership location first, then pick the wrapper. Do not introduce a reference model when plain value state is enough.
## Navigation and routing
## Cross-cutting references
- Use `NavigationStack` and local route state for most features. Keep navigation ownership close to the feature unless multiple entry points truly need shared routing.
- In tabbed apps, prefer one navigation history per tab instead of a single shared stack for the entire app.
- Use enum-driven sheet, alert, and destination routing when presentation is mutually exclusive or deep-linkable.
- Centralize route enums only when the feature has deep links, handoff from multiple surfaces, or cross-feature navigation requirements.
- Avoid global routers for simple push flows that can stay local to one screen tree.
- See `references/navigationstack.md`, `references/sheets.md`, and `references/deeplinks.md` when the view needs more than straightforward local navigation.
## Preview guidance
- Add `#Preview` coverage for the primary state plus important secondary states such as loading, empty, and error.
- Use deterministic fixtures, mocks, and sample data. Do not make previews depend on live network calls, real databases, or global singletons.
- Install required environment dependencies directly in the preview so the view can render in isolation.
- Keep preview setup close to the view until it becomes noisy; then extract lightweight preview helpers or fixtures.
- If a preview crashes, fix the state initialization or dependency wiring before expanding the feature further.
## Async and task lifecycle
- Use `.task` for load-on-appear work that belongs to the view lifecycle.
- Use `.task(id:)` when async work should restart for a changing input such as a query, selection, or identifier.
- Treat cancellation as a normal path for view-driven tasks. Check `Task.isCancelled` in longer flows and avoid surfacing cancellation as a user-facing error.
- Debounce or coalesce user-driven async work such as search before it fans out into repeated requests.
- Keep UI-facing models and mutations main-actor-safe; do background work in services, then publish the result back to UI state.
## Performance guardrails
- Give `ForEach` and list content stable identity. Do not use unstable indices as identity when the collection can reorder or mutate.
- Keep expensive filtering, sorting, and formatting out of `body`; precompute or move it into a model/helper when it is not trivial.
- Narrow observation scope so only the views that read changing state need to update.
- Prefer lazy containers for larger scrolling content and extract subviews when only part of a screen changes frequently.
- Avoid swapping entire top-level view trees for small state changes; keep a stable root view and vary localized sections or modifiers.
## Environment injection policy
- Use `@Environment` for app-level services, shared clients, theme/configuration, and values that many descendants genuinely need.
- Prefer initializer injection for feature-local dependencies and models. Do not move a dependency into the environment just to avoid passing one or two arguments.
- Keep mutable feature state out of the environment unless it is intentionally shared across broad parts of the app.
- Use `@EnvironmentObject` only as a legacy fallback or when the project already standardizes on it for a truly shared object.
- `references/navigationstack.md`: navigation ownership, per-tab history, and enum routing.
- `references/sheets.md`: centralized modal presentation and enum-driven sheets.
- `references/deeplinks.md`: URL handling and routing external links into app destinations.
- `references/app-wiring.md`: root dependency graph, environment usage, and app shell wiring.
- `references/async-state.md`: `.task`, `.task(id:)`, cancellation, debouncing, and async UI state.
- `references/previews.md`: `#Preview`, fixtures, mock environments, and isolated preview setup.
- `references/performance.md`: stable identity, observation scope, lazy containers, and render-cost guardrails.
## Anti-patterns
@ -99,21 +71,14 @@ Choose the ownership location first, then pick the wrapper. Do not introduce a r
- Reaching for `AnyView` to work around type mismatches that should be solved with better composition.
- Defaulting every shared dependency to `@EnvironmentObject` or a global router without a clear ownership reason.
## Platform and version guidance
- Prefer the newest SwiftUI API that fits the deployment target, but call out the minimum OS whenever guidance depends on it.
- When using iOS 17+ Observation or iOS 26+ UI APIs, include a fallback for older targets if the skill is meant to support them.
- Keep compatibility notes next to the rule or example they affect so the fallback is easy to apply.
- Avoid mixing the new Observation system and legacy Combine-based observation in the same feature unless compatibility requires it.
## Workflow for a new SwiftUI view
1. Define the view's state, ownership location, and minimum OS assumptions before writing UI code.
2. Identify which dependencies belong in `@Environment` and which should stay as explicit initializer inputs.
3. Sketch the view hierarchy, routing model, and presentation points; extract repeated parts into subviews. **Build and verify no compiler errors before proceeding.**
4. Implement async loading with `.task` or `.task(id:)`, plus explicit loading and error states when needed.
5. Add previews for the primary and secondary states, then add accessibility labels or identifiers when the UI is interactive.
6. Validate with a build: confirm no compiler errors, check that previews render without crashing, ensure state changes propagate correctly, and sanity-check that list identity and observation scope will not cause avoidable re-renders. For common SwiftUI compilation errors — missing `@State` annotations, ambiguous `ViewBuilder` closures, or mismatched generic types — resolve them before updating callsites. **If the build fails:** read the error message carefully, fix the identified issue, then rebuild before proceeding to the next step. If a preview crashes, isolate the offending subview, confirm its state initialisation is valid, and re-run the preview before continuing.
3. Sketch the view hierarchy, routing model, and presentation points; extract repeated parts into subviews. For complex navigation, read `references/navigationstack.md`, `references/sheets.md`, or `references/deeplinks.md`. **Build and verify no compiler errors before proceeding.**
4. Implement async loading with `.task` or `.task(id:)`, plus explicit loading and error states when needed. Read `references/async-state.md` when the work depends on changing inputs or cancellation.
5. Add previews for the primary and secondary states, then add accessibility labels or identifiers when the UI is interactive. Read `references/previews.md` when the view needs fixtures or injected mock dependencies.
6. Validate with a build: confirm no compiler errors, check that previews render without crashing, ensure state changes propagate correctly, and sanity-check that list identity and observation scope will not cause avoidable re-renders. Read `references/performance.md` if the screen is large, scroll-heavy, or frequently updated. For common SwiftUI compilation errors — missing `@State` annotations, ambiguous `ViewBuilder` closures, or mismatched generic types — resolve them before updating callsites. **If the build fails:** read the error message carefully, fix the identified issue, then rebuild before proceeding to the next step. If a preview crashes, isolate the offending subview, confirm its state initialisation is valid, and re-run the preview before continuing.
## Component references

View file

@ -10,6 +10,13 @@ Show how to wire the app shell (TabView + NavigationStack + sheets) and install
2) A dedicated view modifier installs global dependencies and lifecycle tasks (auth state, streaming watchers, push tokens, data containers).
3) Feature views pull only what they need from the environment; feature-specific state stays local.
## Dependency selection
- Use `@Environment` for app-level services, shared clients, theme/configuration, and values that many descendants genuinely need.
- Prefer initializer injection for feature-local dependencies and models. Do not move a dependency into the environment just to avoid passing one or two arguments.
- Keep mutable feature state out of the environment unless it is intentionally shared across broad parts of the app.
- Use `@EnvironmentObject` only as a legacy fallback or when the project already standardizes on it for a truly shared object.
## Root shell example (generic)
```swift

View file

@ -0,0 +1,96 @@
# Async state and task lifecycle
## Intent
Use this pattern when a view loads data, reacts to changing input, or coordinates async work that should follow the SwiftUI view lifecycle.
## Core rules
- Use `.task` for load-on-appear work that belongs to the view lifecycle.
- Use `.task(id:)` when async work should restart for a changing input such as a query, selection, or identifier.
- Treat cancellation as a normal path for view-driven tasks. Check `Task.isCancelled` in longer flows and avoid surfacing cancellation as a user-facing error.
- Debounce or coalesce user-driven async work such as search before it fans out into repeated requests.
- Keep UI-facing models and mutations main-actor-safe; do background work in services, then publish the result back to UI state.
## Example: load on appear
```swift
struct DetailView: View {
let id: String
@State private var state: LoadState<Item> = .idle
@Environment(ItemClient.self) private var client
var body: some View {
content
.task {
await load()
}
}
@ViewBuilder
private var content: some View {
switch state {
case .idle, .loading:
ProgressView()
case .loaded(let item):
ItemContent(item: item)
case .failed(let error):
ErrorView(error: error)
}
}
private func load() async {
state = .loading
do {
state = .loaded(try await client.fetch(id: id))
} catch is CancellationError {
return
} catch {
state = .failed(error)
}
}
}
```
## Example: restart on input change
```swift
struct SearchView: View {
@State private var query = ""
@State private var results: [ResultItem] = []
@Environment(SearchClient.self) private var client
var body: some View {
List(results) { item in
Text(item.title)
}
.searchable(text: $query)
.task(id: query) {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled, !query.isEmpty else {
results = []
return
}
do {
results = try await client.search(query)
} catch is CancellationError {
return
} catch {
results = []
}
}
}
}
```
## When to move work out of the view
- If the async flow spans multiple screens or must survive view dismissal, move it into a service or model.
- If the view is mostly coordinating app-level lifecycle or account changes, wire it at the app shell in `app-wiring.md`.
- If retry, caching, or offline policy becomes complex, keep the policy in the client/service and leave the view with simple state transitions.
## Pitfalls
- Do not start network work directly from `body`.
- Do not ignore cancellation for searches, typeahead, or rapidly changing selections.
- Avoid storing derived async state in multiple places when one source of truth is enough.

View file

@ -1,13 +1,12 @@
# Components Index
Use this file to find component-specific guidance. Each entry lists when to use it.
Use this file to find component and cross-cutting guidance. Each entry lists when to use it.
## Available components
- TabView: `references/tabview.md` — Use when building a tab-based app or any tabbed feature set.
- NavigationStack: `references/navigationstack.md` — Use when you need push navigation and programmatic routing, especially per-tab history.
- Sheets and modal routing: `references/sheets.md` — Use when you want centralized, enum-driven sheet presentation.
- App wiring and dependency graph: `references/app-wiring.md` — Use to wire TabView + NavigationStack + sheets at the root and install global dependencies.
- Form and Settings: `references/form.md` — Use for settings, grouped inputs, and structured data entry.
- macOS Settings: `references/macos-settings.md` — Use when building a macOS Settings window with SwiftUI's Settings scene.
- Split views and columns: `references/split-views.md` — Use for iPad/macOS multi-column layouts or custom secondary columns.
@ -31,6 +30,13 @@ Use this file to find component-specific guidance. Each entry lists when to use
- Loading & placeholders: `references/loading-placeholders.md` — Use for redacted skeletons, empty states, and loading UX.
- Lightweight clients: `references/lightweight-clients.md` — Use for small, closure-based API clients injected into stores.
## Cross-cutting references
- App wiring and dependency graph: `references/app-wiring.md` — Use to wire the app shell, install shared dependencies, and decide what belongs in the environment.
- Async state and task lifecycle: `references/async-state.md` — Use when a view loads data, reacts to changing input, or needs cancellation/debouncing guidance.
- Previews: `references/previews.md` — Use when adding `#Preview`, fixtures, mock environments, or isolated preview setup.
- Performance guardrails: `references/performance.md` — Use when a screen is large, scroll-heavy, frequently updated, or showing signs of avoidable re-renders.
## Planned components (create files as needed)
- Web content: create `references/webview.md` — Use for embedded web content or in-app browsing.

View file

@ -0,0 +1,62 @@
# Performance guardrails
## Intent
Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated, or at risk of unnecessary recomputation.
## Core rules
- Give `ForEach` and list content stable identity. Do not use unstable indices as identity when the collection can reorder or mutate.
- Keep expensive filtering, sorting, and formatting out of `body`; precompute or move it into a model/helper when it is not trivial.
- Narrow observation scope so only the views that read changing state need to update.
- Prefer lazy containers for larger scrolling content and extract subviews when only part of a screen changes frequently.
- Avoid swapping entire top-level view trees for small state changes; keep a stable root view and vary localized sections or modifiers.
## Example: stable identity
```swift
ForEach(items) { item in
Row(item: item)
}
```
Prefer that over index-based identity when the collection can change order:
```swift
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
Row(item: item)
}
```
## Example: move expensive work out of body
```swift
struct FeedView: View {
let items: [FeedItem]
private var sortedItems: [FeedItem] {
items.sorted(using: KeyPathComparator(\.createdAt, order: .reverse))
}
var body: some View {
List(sortedItems) { item in
FeedRow(item: item)
}
}
}
```
If the work is more expensive than a small derived property, move it into a model, store, or helper that updates less often.
## When to investigate further
- Janky scrolling in long feeds or grids
- Typing lag from search or form validation
- Overly broad view updates when one small piece of state changes
- Large screens with many conditionals or repeated formatting work
## Pitfalls
- Recomputing heavy transforms every render
- Observing a large object from many descendants when only one field matters
- Building custom scroll containers when `List`, `LazyVStack`, or `LazyHGrid` would already solve the problem

View file

@ -0,0 +1,48 @@
# Previews
## Intent
Use previews to validate layout, state wiring, and injected dependencies without relying on a running app or live services.
## Core rules
- Add `#Preview` coverage for the primary state plus important secondary states such as loading, empty, and error.
- Use deterministic fixtures, mocks, and sample data. Do not make previews depend on live network calls, real databases, or global singletons.
- Install required environment dependencies directly in the preview so the view can render in isolation.
- Keep preview setup close to the view until it becomes noisy; then extract lightweight preview helpers or fixtures.
- If a preview crashes, fix the state initialization or dependency wiring before expanding the feature further.
## Example: simple preview states
```swift
#Preview("Loaded") {
ProfileView(profile: .fixture)
}
#Preview("Empty") {
ProfileView(profile: nil)
}
```
## Example: preview with injected dependencies
```swift
#Preview("Search results") {
SearchView()
.environment(SearchClient.preview(results: [.fixture, .fixture2]))
.environment(Theme.preview)
}
```
## Preview checklist
- Does the preview install every required environment dependency?
- Does it cover at least one success path and one non-happy path?
- Are fixtures stable and small enough to be read quickly?
- Can the preview render without network, auth, or app-global initialization?
## Pitfalls
- Do not hide preview crashes by making dependencies optional if the production view requires them.
- Avoid huge inline fixtures when a named sample is easier to read.
- Do not couple previews to global shared singletons unless the project has no alternative.