Add SwiftUI UI patterns skill and references

Introduces the 'swiftui-ui-patterns' skill to docs/skills.json, providing best practices and example-driven guidance for building SwiftUI views and components. Adds SKILL.md and a comprehensive set of reference files covering TabView, NavigationStack, sheets, forms, controls, grids, overlays, haptics, focus handling, media, matched transitions, split views, and more.
This commit is contained in:
Thomas Ricouard 2026-01-04 18:26:56 +01:00
parent 26e23c058d
commit 70a15d08db
23 changed files with 1791 additions and 0 deletions

View file

@ -71,6 +71,97 @@
}
]
},
{
"name": "swiftui-ui-patterns",
"folder": "swiftui-ui-patterns",
"description": "Best practices and example-driven guidance for building SwiftUI views and components. Use when creating or refactoring SwiftUI UI, designing tab architecture with TabView, composing screens, or needing component-specific patterns and examples.",
"references": [
{
"title": "App scaffolding wiring",
"file": "references/app-scaffolding-wiring.md"
},
{
"title": "Components Index",
"file": "references/components-index.md"
},
{
"title": "Controls (Toggle, Slider, Picker)",
"file": "references/controls.md"
},
{
"title": "Deep links and navigation",
"file": "references/deeplinks.md"
},
{
"title": "Focus handling and field chaining",
"file": "references/focus.md"
},
{
"title": "Form",
"file": "references/form.md"
},
{
"title": "Grids",
"file": "references/grids.md"
},
{
"title": "Haptics",
"file": "references/haptics.md"
},
{
"title": "Input toolbar (bottom anchored)",
"file": "references/input-toolbar.md"
},
{
"title": "List and Section",
"file": "references/list.md"
},
{
"title": "Matched transitions",
"file": "references/matched-transitions.md"
},
{
"title": "Media (images, video, viewer)",
"file": "references/media.md"
},
{
"title": "NavigationStack",
"file": "references/navigationstack.md"
},
{
"title": "Overlay and toasts",
"file": "references/overlay.md"
},
{
"title": "ScrollView and Lazy stacks",
"file": "references/scrollview.md"
},
{
"title": "Searchable",
"file": "references/searchable.md"
},
{
"title": "Sheets",
"file": "references/sheets.md"
},
{
"title": "Split views and columns",
"file": "references/split-views.md"
},
{
"title": "TabView",
"file": "references/tabview.md"
},
{
"title": "Theming and dynamic type",
"file": "references/theming.md"
},
{
"title": "Top bar overlays (iOS 26+ and fallback)",
"file": "references/top-bar.md"
}
]
},
{
"name": "swiftui-view-refactor",
"folder": "swiftui-view-refactor",

View file

@ -0,0 +1,56 @@
---
name: swiftui-ui-patterns
description: Best practices and example-driven guidance for building SwiftUI views and components. Use when creating or refactoring SwiftUI UI, designing tab architecture with TabView, composing screens, or needing component-specific patterns and examples.
---
# SwiftUI UI Patterns
## Quick start
Choose a track based on your goal:
### Existing project
- Identify the feature or screen and the primary interaction model (list, detail, editor, settings, tabbed).
- Find a nearby example in the repo with `rg "TabView\("` or similar, then read the closest SwiftUI view.
- Apply local conventions: prefer SwiftUI-native state, keep state local when possible, and use environment injection for shared dependencies.
- Choose the relevant component reference from `references/components-index.md` and follow its guidance.
- Build the view with small, focused subviews and SwiftUI-native data flow.
### New project scaffolding
- Start with `references/app-scaffolding-wiring.md` to wire TabView + NavigationStack + sheets.
- Add a minimal `AppTab` and `RouterPath` based on the provided skeletons.
- Choose the next component reference based on the UI you need first (TabView, NavigationStack, Sheets).
- Expand the route and sheet enums as new screens are added.
## General rules to follow
- Use modern SwiftUI state (`@State`, `@Binding`, `@Observable`, `@Environment`) and avoid unnecessary view models.
- Prefer composition; keep views small and focused.
- Use async/await with `.task` and explicit loading/error states.
- Maintain existing legacy patterns only when editing legacy files.
- Follow the project's formatter and style guide.
## Workflow for a new SwiftUI view
1. Define the view's state and its ownership location.
2. Identify dependencies to inject via `@Environment`.
3. Sketch the view hierarchy and extract repeated parts into subviews.
4. Implement async loading with `.task` and explicit state enum if needed.
5. Add accessibility labels or identifiers when the UI is interactive.
6. Validate with a build and update usage callsites if needed.
## Component references
Use `references/components-index.md` as the entry point. Each component reference should include:
- Intent and best-fit scenarios.
- Minimal usage pattern with local conventions.
- Pitfalls and performance notes.
- Paths to existing examples in the current repo.
## Adding a new component reference
- Create `references/<component>.md`.
- Keep it short and actionable; link to concrete files in the current repo.
- Update `references/components-index.md` with the new entry.

View file

@ -0,0 +1,104 @@
# App scaffolding wiring
## Intent
Show how `TabView`, `NavigationStack`, and sheet routing fit together at the app root and per-tab level.
## Recommended wiring (root + per-tab)
```swift
@MainActor
struct AppView: View {
@State private var selectedTab: AppTab = .timeline
@State private var tabRouter = TabRouter()
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
let router = tabRouter.router(for: tab)
NavigationStack(path: tabRouter.binding(for: tab)) {
tab.makeContentView()
}
.withSheetDestinations(sheet: Binding(
get: { router.presentedSheet },
set: { router.presentedSheet = $0 }
))
.environment(router)
.tabItem { tab.label }
.tag(tab)
}
}
}
}
```
## Minimal AppTab skeleton
```swift
@MainActor
enum AppTab: Identifiable, Hashable, CaseIterable {
case timeline
case notifications
case settings
var id: String {
switch self {
case .timeline: return "timeline"
case .notifications: return "notifications"
case .settings: return "settings"
}
}
@ViewBuilder
func makeContentView() -> some View {
switch self {
case .timeline:
TimelineView()
case .notifications:
NotificationsView()
case .settings:
SettingsView()
}
}
@ViewBuilder
var label: some View {
switch self {
case .timeline:
Label("Timeline", systemImage: "rectangle.stack")
case .notifications:
Label("Notifications", systemImage: "bell")
case .settings:
Label("Settings", systemImage: "gear")
}
}
}
```
## Minimal RouterPath skeleton
```swift
@MainActor
@Observable
final class RouterPath {
var path: [Route] = []
var presentedSheet: SheetDestination?
}
enum Route: Hashable {
case account(id: String)
case status(id: String)
}
```
## Notes
- Each tab owns an independent navigation history via its own router.
- Sheets are routed from any child view by setting `router.presentedSheet`.
- Use the `TabRouter` pattern when tabs are data-driven; use one router per tab if tabs are fixed.
## Related references
- TabView: `references/tabview.md`
- NavigationStack: `references/navigationstack.md`
- Sheets: `references/sheets.md`

View file

@ -0,0 +1,38 @@
# Components Index
Use this file to find component-specific 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 scaffolding wiring: `references/app-scaffolding-wiring.md` — Use to wire TabView + NavigationStack + sheets at the root.
- Form and Settings: `references/form.md` — Use for settings, grouped inputs, and structured data entry.
- Split views and columns: `references/split-views.md` — Use for iPad/macOS multi-column layouts or custom secondary columns.
- List and Section: `references/list.md` — Use for feed-style content and settings rows.
- ScrollView and Lazy stacks: `references/scrollview.md` — Use for custom layouts, horizontal scrollers, or grids.
- Grids: `references/grids.md` — Use for icon pickers, media galleries, and tiled layouts.
- Theming and dynamic type: `references/theming.md` — Use for app-wide theme tokens, colors, and type scaling.
- Controls (toggles, pickers, sliders): `references/controls.md` — Use for settings controls and input selection.
- Input toolbar (bottom anchored): `references/input-toolbar.md` — Use for chat/composer screens with a sticky input bar.
- Top bar overlays (iOS 26+ and fallback): `references/top-bar.md` — Use for pinned selectors or pills above scroll content.
- Overlay and toasts: `references/overlay.md` — Use for transient UI like banners or toasts.
- Focus handling: `references/focus.md` — Use for chaining fields and keyboard focus management.
- Searchable: `references/searchable.md` — Use for native search UI with scopes and async results.
- Async images and media: `references/media.md` — Use for remote media, previews, and media viewers.
- Haptics: `references/haptics.md` — Use for tactile feedback tied to key actions.
- Matched transitions: `references/matched-transitions.md` — Use for smooth source-to-destination animations.
- Deep links and URL routing: `references/deeplinks.md` — Use for in-app navigation from URLs.
## Planned components (create files as needed)
- Web content: create `references/webview.md` — Use for embedded web content or in-app browsing.
- Status composer patterns: create `references/composer.md` — Use for composition or editor workflows.
- Text input and validation: create `references/text-input.md` — Use for forms, validation, and text-heavy input.
- Design system usage: create `references/design-system.md` — Use when applying shared styling rules.
## Adding entries
- Add the component file and link it here with a short “when to use” description.
- Keep each component reference short and actionable.

View file

@ -0,0 +1,57 @@
# Controls (Toggle, Slider, Picker)
## Intent
Use native controls for settings and configuration screens, keeping labels accessible and state bindings clear.
## Core patterns
- Bind controls directly to `@State`, `@Binding`, or `@AppStorage`.
- Prefer `Toggle` for boolean preferences.
- Use `Slider` for numeric ranges and show the current value in a label.
- Use `Picker` for discrete choices; use `.pickerStyle(.segmented)` only for 24 options.
- Keep labels visible and descriptive; avoid embedding buttons inside controls.
## Example: toggles with sections
```swift
Form {
Section("Notifications") {
Toggle("Mentions", isOn: $preferences.notificationsMentionsEnabled)
Toggle("Follows", isOn: $preferences.notificationsFollowsEnabled)
Toggle("Boosts", isOn: $preferences.notificationsBoostsEnabled)
}
}
```
## Example: slider with value text
```swift
Section("Font Size") {
Slider(value: $fontSizeScale, in: 0.5...1.5, step: 0.1)
Text("Scale: \(String(format: \"%.1f\", fontSizeScale))")
.font(.scaledBody)
}
```
## Example: picker for enums
```swift
Picker("Default Visibility", selection: $visibility) {
ForEach(Visibility.allCases, id: \.self) { option in
Text(option.title).tag(option)
}
}
```
## Design choices to keep
- Group related controls in a `Form` section.
- Use `.disabled(...)` to reflect locked or inherited settings.
- Use `Label` inside toggles to combine icon + text when it adds clarity.
## Pitfalls
- Avoid `.pickerStyle(.segmented)` for large sets; use menu or inline styles instead.
- Dont hide labels for sliders; always show context.
- Avoid hard-coding colors for controls; use theme tint sparingly.

View file

@ -0,0 +1,66 @@
# Deep links and navigation
## Intent
Route external URLs into in-app destinations while falling back to system handling when needed.
## Core patterns
- Centralize URL handling in the router (`handle(url:)`, `handleDeepLink(url:)`).
- Inject an `OpenURLAction` handler that delegates to the router.
- Use `.onOpenURL` for app scheme links and convert them to web URLs if needed.
- Let the router decide whether to navigate or open externally.
## Example: router entry points
```swift
@MainActor
final class RouterPath {
var path: [Route] = []
var urlHandler: ((URL) -> OpenURLAction.Result)?
func handle(url: URL) -> OpenURLAction.Result {
if isInternal(url) {
navigate(to: .status(id: url.lastPathComponent))
return .handled
}
return urlHandler?(url) ?? .systemAction
}
func handleDeepLink(url: URL) -> OpenURLAction.Result {
// Resolve federated URLs, then navigate.
navigate(to: .status(id: url.lastPathComponent))
return .handled
}
}
```
## Example: attach to a root view
```swift
extension View {
func withLinkRouter(_ router: RouterPath) -> some View {
self
.environment(
\.openURL,
OpenURLAction { url in
router.handle(url: url)
}
)
.onOpenURL { url in
router.handleDeepLink(url: url)
}
}
}
```
## Design choices to keep
- Keep URL parsing and decision logic inside the router.
- Avoid handling deep links in multiple places; one entry point is enough.
- Always provide a fallback to `OpenURLAction` or `UIApplication.shared.open`.
## Pitfalls
- Dont assume the URL is internal; validate first.
- Avoid blocking UI while resolving remote links; use `Task`.

View file

@ -0,0 +1,90 @@
# Focus handling and field chaining
## Intent
Use `@FocusState` to control keyboard focus, chain fields, and coordinate focus across complex forms.
## Core patterns
- Use an enum to represent focusable fields.
- Set initial focus in `onAppear`.
- Use `.onSubmit` to move focus to the next field.
- For dynamic lists of fields, use an enum with associated values (e.g., `.option(Int)`).
## Example: single field focus
```swift
struct AddServerView: View {
@State private var server = ""
@FocusState private var isServerFieldFocused: Bool
var body: some View {
Form {
TextField("Server", text: $server)
.focused($isServerFieldFocused)
}
.onAppear { isServerFieldFocused = true }
}
}
```
## Example: chained focus with enum
```swift
struct EditTagView: View {
enum FocusField { case title, symbol, newTag }
@FocusState private var focusedField: FocusField?
var body: some View {
Form {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
.onSubmit { focusedField = .symbol }
TextField("Symbol", text: $symbol)
.focused($focusedField, equals: .symbol)
.onSubmit { focusedField = .newTag }
}
.onAppear { focusedField = .title }
}
}
```
## Example: dynamic focus for variable fields
```swift
struct PollView: View {
enum FocusField: Hashable { case option(Int) }
@FocusState private var focused: FocusField?
@State private var options: [String] = ["", ""]
@State private var currentIndex = 0
var body: some View {
ForEach(options.indices, id: \.self) { index in
TextField("Option \(index + 1)", text: $options[index])
.focused($focused, equals: .option(index))
.onSubmit { addOption(at: index) }
}
.onAppear { focused = .option(0) }
}
private func addOption(at index: Int) {
options.append("")
currentIndex = index + 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
focused = .option(currentIndex)
}
}
}
```
## Design choices to keep
- Keep focus state local to the view that owns the fields.
- Use focus changes to drive UX (validation messages, helper UI).
- Pair with `.scrollDismissesKeyboard(...)` when using ScrollView/Form.
## Pitfalls
- Dont store focus state in shared objects; it is view-local.
- Avoid aggressive focus changes during animation; delay if needed.

View file

@ -0,0 +1,97 @@
# Form
## Intent
Use `Form` for structured settings, grouped inputs, and action rows. This pattern keeps layout, spacing, and accessibility consistent for data entry screens.
## Core patterns
- Wrap the form in a `NavigationStack` only when it is presented in a sheet or standalone view without an existing navigation context.
- Group related controls into `Section` blocks.
- Use `.scrollContentBackground(.hidden)` plus a custom background color when you need design-system colors.
- Apply `.formStyle(.grouped)` for grouped styling when appropriate.
- Use `@FocusState` to manage keyboard focus in input-heavy forms.
## Example: settings-style form
```swift
@MainActor
struct SettingsView: View {
@Environment(Theme.self) private var theme
var body: some View {
NavigationStack {
Form {
Section("General") {
NavigationLink("Display") { DisplaySettingsView() }
NavigationLink("Haptics") { HapticsSettingsView() }
}
Section("Account") {
Button("Edit profile") { /* open sheet */ }
.buttonStyle(.plain)
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
}
}
}
```
## Example: modal form with validation
```swift
@MainActor
struct AddRemoteServerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme
@State private var server: String = ""
@State private var isValid = false
@FocusState private var isServerFieldFocused: Bool
var body: some View {
NavigationStack {
Form {
TextField("Server URL", text: $server)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isServerFieldFocused)
.listRowBackground(theme.primaryBackgroundColor)
Button("Add") {
guard isValid else { return }
dismiss()
}
.disabled(!isValid)
.listRowBackground(theme.primaryBackgroundColor)
}
.formStyle(.grouped)
.navigationTitle("Add Server")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
.toolbar { CancelToolbarItem() }
.onAppear { isServerFieldFocused = true }
}
}
}
```
## Design choices to keep
- Prefer `Form` over custom stacks for settings and input screens.
- Keep rows tappable by using `.contentShape(Rectangle())` and `.buttonStyle(.plain)` on row buttons.
- Use list row backgrounds to keep section styling consistent with your theme.
## Pitfalls
- Avoid heavy custom layouts inside a `Form`; it can lead to spacing issues.
- If you need highly custom layouts, prefer `ScrollView` + `VStack`.
- Dont mix multiple background strategies; pick either default Form styling or custom colors.

View file

@ -0,0 +1,71 @@
# Grids
## Intent
Use `LazyVGrid` for icon pickers, media galleries, and dense visual selections where items align in columns.
## Core patterns
- Use `.adaptive` columns for layouts that should scale across device sizes.
- Use multiple `.flexible` columns when you want a fixed column count.
- Keep spacing consistent and small to avoid uneven gutters.
- Use `GeometryReader` inside grid cells when you need square thumbnails.
## Example: adaptive icon grid
```swift
let columns = [GridItem(.adaptive(minimum: 120, maximum: 1024))]
LazyVGrid(columns: columns, spacing: 6) {
ForEach(icons) { icon in
Button {
select(icon)
} label: {
ZStack(alignment: .bottomTrailing) {
Image(icon.previewName)
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(6)
if icon.isSelected {
Image(systemName: "checkmark.seal.fill")
.padding(4)
.tint(.green)
}
}
}
.buttonStyle(.plain)
}
}
```
## Example: fixed 3-column media grid
```swift
LazyVGrid(
columns: [
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
],
spacing: 4
) {
ForEach(items) { item in
GeometryReader { proxy in
ThumbnailView(item: item)
.frame(width: proxy.size.width, height: proxy.size.width)
}
.aspectRatio(1, contentMode: .fit)
}
}
```
## Design choices to keep
- Use `LazyVGrid` for large collections; avoid non-lazy grids for big sets.
- Keep tap targets full-bleed using `.contentShape(Rectangle())` when needed.
- Prefer adaptive grids for settings pickers and flexible layouts.
## Pitfalls
- Avoid heavy overlays in every grid cell; it can be expensive.
- Dont nest grids inside other grids without a clear reason.

View file

@ -0,0 +1,71 @@
# Haptics
## Intent
Use haptics sparingly to reinforce user actions (tab selection, refresh, success/error) and respect user preferences.
## Core patterns
- Centralize haptic triggers in a `HapticManager` or similar utility.
- Gate haptics behind user preferences and hardware support.
- Use distinct types for different UX moments (selection vs. notification vs. refresh).
## Example: simple haptic manager
```swift
@MainActor
final class HapticManager {
static let shared = HapticManager()
enum HapticType {
case buttonPress
case tabSelection
case dataRefresh(intensity: CGFloat)
case notification(UINotificationFeedbackGenerator.FeedbackType)
}
private let selectionGenerator = UISelectionFeedbackGenerator()
private let impactGenerator = UIImpactFeedbackGenerator(style: .heavy)
private let notificationGenerator = UINotificationFeedbackGenerator()
private init() { selectionGenerator.prepare() }
func fire(_ type: HapticType, isEnabled: Bool) {
guard isEnabled else { return }
switch type {
case .buttonPress:
impactGenerator.impactOccurred()
case .tabSelection:
selectionGenerator.selectionChanged()
case let .dataRefresh(intensity):
impactGenerator.impactOccurred(intensity: intensity)
case let .notification(style):
notificationGenerator.notificationOccurred(style)
}
}
}
```
## Example: usage
```swift
Button("Save") {
HapticManager.shared.fire(.notification(.success), isEnabled: preferences.hapticsEnabled)
}
TabView(selection: $selectedTab) { /* tabs */ }
.onChange(of: selectedTab) { _, _ in
HapticManager.shared.fire(.tabSelection, isEnabled: preferences.hapticTabSelectionEnabled)
}
```
## Design choices to keep
- Haptics should be subtle and not fire on every tiny interaction.
- Respect user preferences (toggle to disable).
- Keep haptic triggers close to the user action, not deep in data layers.
## Pitfalls
- Avoid firing multiple haptics in quick succession.
- Do not assume haptics are available; check support.

View file

@ -0,0 +1,51 @@
# Input toolbar (bottom anchored)
## Intent
Use a bottom-anchored input bar for chat, composer, or quick actions without fighting the keyboard.
## Core patterns
- Use `.safeAreaInset(edge: .bottom)` to anchor the toolbar above the keyboard.
- Keep the main content in a `ScrollView` or `List`.
- Drive focus with `@FocusState` and set initial focus when needed.
- Avoid embedding the input bar inside the scroll content; keep it separate.
## Example: scroll view + bottom input
```swift
@MainActor
struct ConversationView: View {
@FocusState private var isInputFocused: Bool
var body: some View {
ScrollViewReader { _ in
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
.padding(.horizontal, .layoutPadding)
}
.safeAreaInset(edge: .bottom) {
InputBar(text: $draft)
.focused($isInputFocused)
}
.scrollDismissesKeyboard(.interactively)
.onAppear { isInputFocused = true }
}
}
}
```
## Design choices to keep
- Keep the input bar visually separated from the scrollable content.
- Use `.scrollDismissesKeyboard(.interactively)` for chat-like screens.
- Ensure send actions are reachable via keyboard return or a clear button.
## Pitfalls
- Avoid placing the input view inside the scroll stack; it will jump with content.
- Avoid nested scroll views that fight for drag gestures.

View file

@ -0,0 +1,86 @@
# List and Section
## Intent
Use `List` for feed-style content and settings-style rows where built-in row reuse, selection, and accessibility matter.
## Core patterns
- Prefer `List` for long, vertically scrolling content with repeated rows.
- Use `Section` headers to group related rows.
- Pair with `ScrollViewReader` when you need scroll-to-top or jump-to-id.
- Use `.listStyle(.plain)` for modern feed layouts.
- Use `.listStyle(.grouped)` for multi-section discovery/search pages where section grouping helps.
- Apply `.scrollContentBackground(.hidden)` + a custom background when you need a themed surface.
- Use `.listRowInsets(...)` and `.listRowSeparator(.hidden)` to tune row spacing and separators.
- Use `.environment(\\.defaultMinListRowHeight, ...)` to control dense list layouts.
## Example: feed list with scroll-to-top
```swift
@MainActor
struct TimelineListView: View {
@Environment(\.selectedTabScrollToTop) private var selectedTabScrollToTop
@State private var scrollToId: String?
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(items) { item in
TimelineRow(item: item)
.id(item.id)
.listRowInsets(.init(top: 12, leading: 16, bottom: 6, trailing: 16))
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.environment(\\.defaultMinListRowHeight, 1)
.onChange(of: scrollToId) { _, newValue in
if let newValue {
proxy.scrollTo(newValue, anchor: .top)
scrollToId = nil
}
}
.onChange(of: selectedTabScrollToTop) { _, newValue in
if newValue == 0 {
withAnimation {
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
}
}
}
}
}
}
```
## Example: settings-style list
```swift
@MainActor
struct SettingsView: View {
var body: some View {
List {
Section("General") {
NavigationLink("Display") { DisplaySettingsView() }
NavigationLink("Haptics") { HapticsSettingsView() }
}
Section("Account") {
Button("Sign Out", role: .destructive) {}
}
}
.listStyle(.insetGrouped)
}
}
```
## Design choices to keep
- Use `List` for dynamic feeds, settings, and any UI where row semantics help.
- Use stable IDs for rows to keep animations and scroll positioning reliable.
- Prefer `.contentShape(Rectangle())` on rows that should be tappable end-to-end.
- Use `.refreshable` for pull-to-refresh feeds when the data source supports it.
## Pitfalls
- Avoid heavy custom layouts inside a `List` row; use `ScrollView` + `LazyVStack` instead.
- Be careful mixing `List` and nested `ScrollView`; it can cause gesture conflicts.

View file

@ -0,0 +1,59 @@
# Matched transitions
## Intent
Use matched transitions to create smooth continuity between a source view (thumbnail, avatar) and a destination view (sheet, detail, viewer).
## Core patterns
- Use a shared `Namespace` and a stable ID for the source.
- Use `matchedTransitionSource` + `navigationTransition(.zoom(...))` on iOS 26+.
- Use `matchedGeometryEffect` for in-place transitions within a view hierarchy.
- Keep IDs stable across view updates (avoid random UUIDs).
## Example: media preview to full-screen viewer (iOS 26+)
```swift
struct MediaPreview: View {
@Namespace private var namespace
@State private var selected: MediaAttachment?
var body: some View {
ThumbnailView()
.matchedTransitionSource(id: selected?.id ?? "", in: namespace)
.sheet(item: $selected) { item in
MediaViewer(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
}
}
}
```
## Example: matched geometry within a view
```swift
struct ToggleBadge: View {
@Namespace private var space
@State private var isOn = false
var body: some View {
Button {
withAnimation(.spring) { isOn.toggle() }
} label: {
Image(systemName: isOn ? "eye" : "eye.slash")
.matchedGeometryEffect(id: "icon", in: space)
}
}
}
```
## Design choices to keep
- Prefer `matchedTransitionSource` for cross-screen transitions.
- Keep source and destination sizes reasonable to avoid jarring scale changes.
- Use `withAnimation` for state-driven transitions.
## Pitfalls
- Dont use unstable IDs; it breaks the transition.
- Avoid mismatched shapes (e.g., square to circle) unless the design expects it.

View file

@ -0,0 +1,73 @@
# Media (images, video, viewer)
## Intent
Use consistent patterns for loading images, previewing media, and presenting a full-screen viewer.
## Core patterns
- Use `LazyImage` (or `AsyncImage`) for remote images with loading states.
- Prefer a lightweight preview component for inline media.
- Use a shared viewer state (e.g., `QuickLook`) to present a full-screen media viewer.
- Use `openWindow` for desktop/visionOS and a sheet for iOS.
## Example: inline media preview
```swift
struct MediaPreviewRow: View {
@Environment(QuickLook.self) private var quickLook
let attachments: [MediaAttachment]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(attachments) { attachment in
LazyImage(url: attachment.previewURL) { state in
if let image = state.image {
image.resizable().aspectRatio(contentMode: .fill)
} else {
ProgressView()
}
}
.frame(width: 120, height: 120)
.clipped()
.onTapGesture {
quickLook.prepareFor(
selectedMediaAttachment: attachment,
mediaAttachments: attachments
)
}
}
}
}
}
}
```
## Example: global media viewer sheet
```swift
struct AppRoot: View {
@State private var quickLook = QuickLook.shared
var body: some View {
content
.environment(quickLook)
.sheet(item: $quickLook.selectedMediaAttachment) { selected in
MediaUIView(selectedAttachment: selected, attachments: quickLook.mediaAttachments)
}
}
}
```
## Design choices to keep
- Keep previews lightweight; load full media in the viewer.
- Use shared viewer state so any view can open media without prop-drilling.
- Use a single entry point for the viewer (sheet/window) to avoid duplicates.
## Pitfalls
- Avoid loading full-size images in list rows; use resized previews.
- Dont present multiple viewer sheets at once; keep a single source of truth.

View file

@ -0,0 +1,159 @@
# NavigationStack
## Intent
Use this pattern for programmatic navigation and deep links, especially when each tab needs an independent navigation history. The key idea is one `NavigationStack` per tab, each with its own path binding and router object.
## Core architecture
- Define a route enum that is `Hashable` and represents all destinations.
- Create a lightweight router (or use a library such as `https://github.com/Dimillian/AppRouter`) that owns the `path` and any sheet state.
- Each tab owns its own router instance and binds `NavigationStack(path:)` to it.
- Inject the router into the environment so child views can navigate programmatically.
- Centralize destination mapping with a single `navigationDestination(for:)` block (or a `withAppRouter()` modifier).
## Example: custom router with per-tab stack
```swift
@MainActor
@Observable
final class RouterPath {
var path: [Route] = []
var presentedSheet: SheetDestination?
func navigate(to route: Route) {
path.append(route)
}
func reset() {
path = []
}
}
enum Route: Hashable {
case account(id: String)
case status(id: String)
}
@MainActor
struct TimelineTab: View {
@State private var routerPath = RouterPath()
var body: some View {
NavigationStack(path: $routerPath.path) {
TimelineView()
.navigationDestination(for: Route.self) { route in
switch route {
case .account(let id): AccountView(id: id)
case .status(let id): StatusView(id: id)
}
}
}
.environment(routerPath)
}
}
```
## Example: centralized destination mapping
Use a shared view modifier to avoid duplicating route switches across screens.
```swift
extension View {
func withAppRouter() -> some View {
navigationDestination(for: Route.self) { route in
switch route {
case .account(let id):
AccountView(id: id)
case .status(let id):
StatusView(id: id)
}
}
}
}
```
Then apply it once per stack:
```swift
NavigationStack(path: $routerPath.path) {
TimelineView()
.withAppRouter()
}
```
## Example: binding per tab (tabs with independent history)
```swift
@MainActor
struct TabsView: View {
@State private var timelineRouter = RouterPath()
@State private var notificationsRouter = RouterPath()
var body: some View {
TabView {
TimelineTab(router: timelineRouter)
NotificationsTab(router: notificationsRouter)
}
}
}
```
## Example: generic tabs with per-tab NavigationStack
Use this when tabs are built from data and each needs its own path without hard-coded names.
```swift
@MainActor
struct TabsView: View {
@State private var selectedTab: AppTab = .timeline
@State private var tabRouter = TabRouter()
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
NavigationStack(path: tabRouter.binding(for: tab)) {
tab.makeContentView()
}
.environment(tabRouter.router(for: tab))
.tabItem { tab.label }
.tag(tab)
}
}
}
}
```
@MainActor
@Observable
final class TabRouter {
private var routers: [AppTab: RouterPath] = [:]
func router(for tab: AppTab) -> RouterPath {
if let router = routers[tab] { return router }
let router = RouterPath()
routers[tab] = router
return router
}
func binding(for tab: AppTab) -> Binding<[Route]> {
let router = router(for: tab)
return Binding(get: { router.path }, set: { router.path = $0 })
}
}
## Design choices to keep
- One `NavigationStack` per tab to preserve independent history.
- A single source of truth for navigation state (`RouterPath` or library router).
- Use `navigationDestination(for:)` to map routes to views.
- Reset the path when app context changes (account switch, logout, etc.).
- Inject the router into the environment so child views can navigate and present sheets without prop-drilling.
- Keep sheet presentation state on the router if you want a single place to manage modals.
## Pitfalls
- Do not share one path across all tabs unless you want global history.
- Ensure route identifiers are stable and `Hashable`.
- Avoid storing view instances in the path; store lightweight route data instead.
- If using a router object, keep it outside other `@Observable` objects to avoid nested observation.

View file

@ -0,0 +1,45 @@
# Overlay and toasts
## Intent
Use overlays for transient UI (toasts, banners, loaders) without affecting layout.
## Core patterns
- Use `.overlay(alignment:)` to place global UI without changing the underlying layout.
- Keep overlays lightweight and dismissible.
- Use a dedicated `ToastCenter` (or similar) for global state if multiple features trigger toasts.
## Example: toast overlay
```swift
struct AppRootView: View {
@State private var toast: Toast?
var body: some View {
content
.overlay(alignment: .top) {
if let toast {
ToastView(toast: toast)
.transition(.move(edge: .top).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { self.toast = nil }
}
}
}
}
}
}
```
## Design choices to keep
- Prefer overlays for transient UI rather than embedding in layout stacks.
- Use transitions and short auto-dismiss timers.
- Keep the overlay aligned to a clear edge (`.top` or `.bottom`).
## Pitfalls
- Avoid overlays that block all interaction unless explicitly needed.
- Dont stack many overlays; use a queue or replace the current toast.

View file

@ -0,0 +1,87 @@
# ScrollView and Lazy stacks
## Intent
Use `ScrollView` with `LazyVStack`, `LazyHStack`, or `LazyVGrid` when you need custom layout, mixed content, or horizontal/ grid-based scrolling.
## Core patterns
- Prefer `ScrollView` + `LazyVStack` for chat-like or custom feed layouts.
- Use `ScrollView(.horizontal)` + `LazyHStack` for chips, tags, avatars, and media strips.
- Use `LazyVGrid` for icon/media grids; prefer adaptive columns when possible.
- Use `ScrollViewReader` for scroll-to-top/bottom and anchor-based jumps.
- Use `safeAreaInset(edge:)` for input bars that should stick above the keyboard.
## Example: vertical custom feed
```swift
@MainActor
struct ConversationView: View {
private enum Constants { static let bottomAnchor = "bottom" }
@State private var scrollProxy: ScrollViewProxy?
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
.id(message.id)
}
Color.clear.frame(height: 1).id(Constants.bottomAnchor)
}
.padding(.horizontal, .layoutPadding)
}
.safeAreaInset(edge: .bottom) {
MessageInputBar()
}
.onAppear {
scrollProxy = proxy
withAnimation {
proxy.scrollTo(Constants.bottomAnchor, anchor: .bottom)
}
}
}
}
}
```
## Example: horizontal chips
```swift
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(chips) { chip in
ChipView(chip: chip)
}
}
}
```
## Example: adaptive grid
```swift
let columns = [GridItem(.adaptive(minimum: 120))]
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(items) { item in
GridItemView(item: item)
}
}
.padding(8)
}
```
## Design choices to keep
- Use `Lazy*` stacks when item counts are large or unknown.
- Use non-lazy stacks for small, fixed-size content to avoid lazy overhead.
- Keep IDs stable when using `ScrollViewReader`.
- Prefer explicit animations (`withAnimation`) when scrolling to an ID.
## Pitfalls
- Avoid nesting scroll views of the same axis; it causes gesture conflicts.
- Dont combine `List` and `ScrollView` in the same hierarchy without a clear reason.
- Overuse of `LazyVStack` for tiny content can add unnecessary complexity.

View file

@ -0,0 +1,71 @@
# Searchable
## Intent
Use `searchable` to add native search UI with optional scopes and async results.
## Core patterns
- Bind `searchable(text:)` to local state.
- Use `.searchScopes` for multiple search modes.
- Use `.task(id: searchQuery)` or debounced tasks to avoid overfetching.
- Show placeholders or progress states while results load.
## Example: searchable with scopes
```swift
@MainActor
struct ExploreView: View {
@State private var searchQuery = ""
@State private var searchScope: SearchScope = .all
@State private var isSearching = false
@State private var results: [SearchResult] = []
var body: some View {
List {
if isSearching {
ProgressView()
} else {
ForEach(results) { result in
SearchRow(result: result)
}
}
}
.searchable(
text: $searchQuery,
placement: .navigationBarDrawer(displayMode: .always),
prompt: Text("Search")
)
.searchScopes($searchScope) {
ForEach(SearchScope.allCases, id: \.self) { scope in
Text(scope.title)
}
}
.task(id: searchQuery) {
await runSearch()
}
}
private func runSearch() async {
guard !searchQuery.isEmpty else {
results = []
return
}
isSearching = true
defer { isSearching = false }
try? await Task.sleep(for: .milliseconds(250))
results = await fetchResults(query: searchQuery, scope: searchScope)
}
}
```
## Design choices to keep
- Show a placeholder when search is empty or has no results.
- Debounce input to avoid spamming the network.
- Keep search state local to the view.
## Pitfalls
- Avoid running searches for empty strings.
- Dont block the main thread during fetch.

View file

@ -0,0 +1,113 @@
# Sheets
## Intent
Use a centralized sheet routing pattern so any view can present modals without prop-drilling. This keeps sheet state in one place and scales as the app grows.
## Core architecture
- Define a `SheetDestination` enum that describes every modal and is `Identifiable`.
- Store the current sheet in a router object (`presentedSheet: SheetDestination?`).
- Create a view modifier like `withSheetDestinations(...)` that maps the enum to concrete sheet views.
- Inject the router into the environment so child views can set `presentedSheet` directly.
## Example: SheetDestination enum
```swift
enum SheetDestination: Identifiable, Hashable {
case composer
case editProfile
case settings
case report(itemID: String)
var id: String {
switch self {
case .composer, .editProfile:
// Use the same id to ensure only one editor-like sheet is active at a time.
return "editor"
case .settings:
return "settings"
case .report:
return "report"
}
}
}
```
## Example: withSheetDestinations modifier
```swift
extension View {
func withSheetDestinations(
sheet: Binding<SheetDestination?>
) -> some View {
sheet(item: sheet) { destination in
Group {
switch destination {
case .composer:
ComposerView()
case .editProfile:
EditProfileView()
case .settings:
SettingsView()
case .report(let itemID):
ReportView(itemID: itemID)
}
}
}
}
}
```
## Example: presenting from a child view
```swift
struct StatusRow: View {
@Environment(RouterPath.self) private var router
var body: some View {
Button("Report") {
router.presentedSheet = .report(itemID: "123")
}
}
}
```
## Required wiring
For the child view to work, a parent view must:
- own the router instance,
- attach `withSheetDestinations(sheet: $router.presentedSheet)` (or an equivalent `sheet(item:)` handler), and
- inject it with `.environment(router)` after the sheet modifier so the modal content inherits it.
This makes the child assignment to `router.presentedSheet` drive presentation at the root.
## Example: sheets that need their own navigation
Wrap sheet content in a `NavigationStack` so it can push within the modal.
```swift
struct NavigationSheet<Content: View>: View {
var content: () -> Content
var body: some View {
NavigationStack {
content()
.toolbar { CloseToolbarItem() }
}
}
}
```
## Design choices to keep
- Centralize sheet routing so features can present modals without wiring bindings through many layers.
- Use `sheet(item:)` to guarantee a single sheet is active and to drive presentation from the enum.
- Group related sheets under the same `id` when they are mutually exclusive (e.g., editor flows).
- Keep sheet views lightweight and composed from smaller views; avoid large monoliths.
## Pitfalls
- Avoid mixing `sheet(isPresented:)` and `sheet(item:)` for the same concern; prefer a single enum.
- Do not store heavy state inside `SheetDestination`; pass lightweight identifiers or models.
- If multiple sheets can appear from the same screen, give them distinct `id` values.

View file

@ -0,0 +1,72 @@
# Split views and columns
## Intent
Provide a lightweight, customizable multi-column layout for iPad/macOS without relying on `NavigationSplitView`.
## Custom split column pattern (manual HStack)
Use this when you want full control over column sizing, behavior, and environment tweaks.
```swift
@MainActor
struct AppView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@AppStorage("showSecondaryColumn") private var showSecondaryColumn = true
var body: some View {
HStack(spacing: 0) {
primaryColumn
if shouldShowSecondaryColumn {
Divider().edgesIgnoringSafeArea(.all)
secondaryColumn
}
}
}
private var shouldShowSecondaryColumn: Bool {
horizontalSizeClass == .regular
&& showSecondaryColumn
}
private var primaryColumn: some View {
TabView { /* tabs */ }
}
private var secondaryColumn: some View {
NotificationsTab()
.environment(\.isSecondaryColumn, true)
.frame(maxWidth: .secondaryColumnWidth)
}
}
```
## Notes on the custom approach
- Use a shared preference or setting to toggle the secondary column.
- Inject an environment flag (e.g., `isSecondaryColumn`) so child views can adapt behavior.
- Prefer a fixed or capped width for the secondary column to avoid layout thrash.
## Alternative: NavigationSplitView
`NavigationSplitView` can handle sidebar + detail + supplementary columns for you, but is harder to customize in cases like:\n- a dedicated notification column independent of selection,\n- custom sizing, or\n- different toolbar behaviors per column.
```swift
@MainActor
struct AppView: View {
var body: some View {
NavigationSplitView {
SidebarView()
} content: {
MainContentView()
} detail: {
NotificationsView()
}
}
}
```
## When to choose which
- Use the manual HStack split when you need full control or a non-standard secondary column.
- Use `NavigationSplitView` when you want a standard system layout with minimal customization.

View file

@ -0,0 +1,114 @@
# TabView
## Intent
Use this pattern for a scalable, multi-platform tab architecture with:
- a single source of truth for tab identity and content,
- platform-specific tab sets and sidebar sections,
- dynamic tabs sourced from data,
- an interception hook for special tabs (e.g., compose).
## Core architecture
- `AppTab` enum defines identity, labels, icons, and content builder.
- `SidebarSections` enum groups tabs for sidebar sections.
- `AppView` owns the `TabView` and selection binding, and routes tab changes through `updateTab`.
## Example: custom binding with side effects
Use this when tab selection needs side effects, like intercepting a special tab to perform an action instead of changing selection.
```swift
@MainActor
struct AppView: View {
@Binding var selectedTab: AppTab
var body: some View {
TabView(selection: .init(
get: { selectedTab },
set: { updateTab(with: $0) }
)) {
ForEach(availableSections) { section in
TabSection(section.title) {
ForEach(section.tabs) { tab in
Tab(value: tab) {
tab.makeContentView(
homeTimeline: $timeline,
selectedTab: $selectedTab,
pinnedFilters: $pinnedFilters
)
} label: {
tab.label
}
.tabPlacement(tab.tabPlacement)
}
}
.tabPlacement(.sidebarOnly)
}
}
}
private func updateTab(with newTab: AppTab) {
if newTab == .post {
// Intercept special tabs (compose) instead of changing selection.
presentComposer()
return
}
selectedTab = newTab
}
}
```
## Example: direct binding without side effects
Use this when selection is purely state-driven.
```swift
@MainActor
struct AppView: View {
@Binding var selectedTab: AppTab
var body: some View {
TabView(selection: $selectedTab) {
ForEach(availableSections) { section in
TabSection(section.title) {
ForEach(section.tabs) { tab in
Tab(value: tab) {
tab.makeContentView(
homeTimeline: $timeline,
selectedTab: $selectedTab,
pinnedFilters: $pinnedFilters
)
} label: {
tab.label
}
.tabPlacement(tab.tabPlacement)
}
}
.tabPlacement(.sidebarOnly)
}
}
}
}
```
## Design choices to keep
- Centralize tab identity and content in `AppTab` with `makeContentView(...)`.
- Use `Tab(value:)` with `selection` binding for state-driven tab selection.
- Route selection changes through `updateTab` to handle special tabs and scroll-to-top behavior.
- Use `TabSection` + `.tabPlacement(.sidebarOnly)` for sidebar structure.
- Use `.tabPlacement(.pinned)` in `AppTab.tabPlacement` for a single pinned tab; this is commonly used for iOS 26 `.searchable` tab content, but can be used for any tab.
## Dynamic tabs pattern
- `SidebarSections` handles dynamic data tabs.
- `AppTab.anyTimelineFilter(filter:)` wraps dynamic tabs in a single enum case.
- The enum provides label/icon/title for dynamic tabs via the filter type.
## Pitfalls
- Avoid adding ViewModels for tabs; keep state local or in `@Observable` services.
- Do not nest `@Observable` objects inside other `@Observable` objects.
- Ensure `AppTab.id` values are stable; dynamic cases should hash on stable IDs.
- Special tabs (compose) should not change selection.

View file

@ -0,0 +1,71 @@
# Theming and dynamic type
## Intent
Provide a clean, scalable theming approach that keeps view code semantic and consistent.
## Core patterns
- Use a single `Theme` object as the source of truth (colors, fonts, spacing).
- Inject theme at the app root and read it via `@Environment(Theme.self)` in views.
- Prefer semantic colors (`primaryBackground`, `secondaryBackground`, `label`, `tint`) instead of raw colors.
- Keep user-facing theme controls in a dedicated settings screen.
- Apply Dynamic Type scaling through custom fonts or `.font(.scaled...)`.
## Example: Theme object
```swift
@MainActor
@Observable
final class Theme {
var tintColor: Color = .blue
var primaryBackground: Color = .white
var secondaryBackground: Color = .gray.opacity(0.1)
var labelColor: Color = .primary
var fontSizeScale: Double = 1.0
}
```
## Example: inject at app root
```swift
@main
struct MyApp: App {
@State private var theme = Theme()
var body: some Scene {
WindowGroup {
AppView()
.environment(theme)
}
}
}
```
## Example: view usage
```swift
struct ProfileView: View {
@Environment(Theme.self) private var theme
var body: some View {
VStack {
Text("Profile")
.foregroundStyle(theme.labelColor)
}
.background(theme.primaryBackground)
}
}
```
## Design choices to keep
- Keep theme values semantic and minimal; avoid duplicating system colors.
- Store user-selected theme values in persistent storage if needed.
- Ensure contrast between text and backgrounds.
## Pitfalls
- Avoid sprinkling raw `Color` values in views; it breaks consistency.
- Do not tie theme to a single views local state.
- Avoid using `@Environment(\\.colorScheme)` as the only theme control; it should complement your theme.

View file

@ -0,0 +1,49 @@
# Top bar overlays (iOS 26+ and fallback)
## Intent
Provide a custom top selector or pill row that sits above scroll content, using `safeAreaBar(.top)` on iOS 26 and a compatible fallback on earlier OS versions.
## iOS 26+ approach
Use `safeAreaBar(edge: .top)` to attach the view to the safe area bar.
```swift
if #available(iOS 26.0, *) {
content
.safeAreaBar(edge: .top) {
TopSelectorView()
.padding(.horizontal, .layoutPadding)
}
}
```
## Fallback for earlier iOS
Use `.safeAreaInset(edge: .top)` and hide the toolbar background to avoid double layers.
```swift
content
.toolbarBackground(.hidden, for: .navigationBar)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
TopSelectorView()
.padding(.vertical, 8)
.padding(.horizontal, .layoutPadding)
.background(Color.primary.opacity(0.06))
.background(Material.ultraThin)
Divider()
}
}
```
## Design choices to keep
- Use `safeAreaBar` when available; it integrates better with the navigation bar.
- Use a subtle background + divider in the fallback to keep separation from content.
- Keep the selector height compact to avoid pushing content too far down.
## Pitfalls
- Dont stack multiple top insets; it can create extra padding.
- Avoid heavy, opaque backgrounds that fight the navigation bar.