mirror of
https://github.com/Dimillian/Skills.git
synced 2026-03-25 08:55:54 +00:00
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:
parent
26e23c058d
commit
70a15d08db
23 changed files with 1791 additions and 0 deletions
|
|
@ -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",
|
"name": "swiftui-view-refactor",
|
||||||
"folder": "swiftui-view-refactor",
|
"folder": "swiftui-view-refactor",
|
||||||
|
|
|
||||||
56
swiftui-ui-patterns/SKILL.md
Normal file
56
swiftui-ui-patterns/SKILL.md
Normal 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.
|
||||||
104
swiftui-ui-patterns/references/app-scaffolding-wiring.md
Normal file
104
swiftui-ui-patterns/references/app-scaffolding-wiring.md
Normal 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`
|
||||||
38
swiftui-ui-patterns/references/components-index.md
Normal file
38
swiftui-ui-patterns/references/components-index.md
Normal 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.
|
||||||
57
swiftui-ui-patterns/references/controls.md
Normal file
57
swiftui-ui-patterns/references/controls.md
Normal 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 2–4 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.
|
||||||
|
- Don’t hide labels for sliders; always show context.
|
||||||
|
- Avoid hard-coding colors for controls; use theme tint sparingly.
|
||||||
66
swiftui-ui-patterns/references/deeplinks.md
Normal file
66
swiftui-ui-patterns/references/deeplinks.md
Normal 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
|
||||||
|
|
||||||
|
- Don’t assume the URL is internal; validate first.
|
||||||
|
- Avoid blocking UI while resolving remote links; use `Task`.
|
||||||
90
swiftui-ui-patterns/references/focus.md
Normal file
90
swiftui-ui-patterns/references/focus.md
Normal 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
|
||||||
|
|
||||||
|
- Don’t store focus state in shared objects; it is view-local.
|
||||||
|
- Avoid aggressive focus changes during animation; delay if needed.
|
||||||
97
swiftui-ui-patterns/references/form.md
Normal file
97
swiftui-ui-patterns/references/form.md
Normal 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`.
|
||||||
|
- Don’t mix multiple background strategies; pick either default Form styling or custom colors.
|
||||||
71
swiftui-ui-patterns/references/grids.md
Normal file
71
swiftui-ui-patterns/references/grids.md
Normal 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.
|
||||||
|
- Don’t nest grids inside other grids without a clear reason.
|
||||||
71
swiftui-ui-patterns/references/haptics.md
Normal file
71
swiftui-ui-patterns/references/haptics.md
Normal 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.
|
||||||
51
swiftui-ui-patterns/references/input-toolbar.md
Normal file
51
swiftui-ui-patterns/references/input-toolbar.md
Normal 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.
|
||||||
86
swiftui-ui-patterns/references/list.md
Normal file
86
swiftui-ui-patterns/references/list.md
Normal 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.
|
||||||
59
swiftui-ui-patterns/references/matched-transitions.md
Normal file
59
swiftui-ui-patterns/references/matched-transitions.md
Normal 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
|
||||||
|
|
||||||
|
- Don’t use unstable IDs; it breaks the transition.
|
||||||
|
- Avoid mismatched shapes (e.g., square to circle) unless the design expects it.
|
||||||
73
swiftui-ui-patterns/references/media.md
Normal file
73
swiftui-ui-patterns/references/media.md
Normal 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.
|
||||||
|
- Don’t present multiple viewer sheets at once; keep a single source of truth.
|
||||||
159
swiftui-ui-patterns/references/navigationstack.md
Normal file
159
swiftui-ui-patterns/references/navigationstack.md
Normal 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.
|
||||||
45
swiftui-ui-patterns/references/overlay.md
Normal file
45
swiftui-ui-patterns/references/overlay.md
Normal 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.
|
||||||
|
- Don’t stack many overlays; use a queue or replace the current toast.
|
||||||
87
swiftui-ui-patterns/references/scrollview.md
Normal file
87
swiftui-ui-patterns/references/scrollview.md
Normal 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.
|
||||||
|
- Don’t combine `List` and `ScrollView` in the same hierarchy without a clear reason.
|
||||||
|
- Overuse of `LazyVStack` for tiny content can add unnecessary complexity.
|
||||||
71
swiftui-ui-patterns/references/searchable.md
Normal file
71
swiftui-ui-patterns/references/searchable.md
Normal 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.
|
||||||
|
- Don’t block the main thread during fetch.
|
||||||
113
swiftui-ui-patterns/references/sheets.md
Normal file
113
swiftui-ui-patterns/references/sheets.md
Normal 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.
|
||||||
72
swiftui-ui-patterns/references/split-views.md
Normal file
72
swiftui-ui-patterns/references/split-views.md
Normal 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.
|
||||||
114
swiftui-ui-patterns/references/tabview.md
Normal file
114
swiftui-ui-patterns/references/tabview.md
Normal 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.
|
||||||
71
swiftui-ui-patterns/references/theming.md
Normal file
71
swiftui-ui-patterns/references/theming.md
Normal 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 view’s local state.
|
||||||
|
- Avoid using `@Environment(\\.colorScheme)` as the only theme control; it should complement your theme.
|
||||||
49
swiftui-ui-patterns/references/top-bar.md
Normal file
49
swiftui-ui-patterns/references/top-bar.md
Normal 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
|
||||||
|
|
||||||
|
- Don’t stack multiple top insets; it can create extra padding.
|
||||||
|
- Avoid heavy, opaque backgrounds that fight the navigation bar.
|
||||||
Loading…
Reference in a new issue