diff --git a/docs/skills.json b/docs/skills.json index d547f11..60bed22 100644 --- a/docs/skills.json +++ b/docs/skills.json @@ -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", diff --git a/swiftui-ui-patterns/SKILL.md b/swiftui-ui-patterns/SKILL.md new file mode 100644 index 0000000..9cd61e4 --- /dev/null +++ b/swiftui-ui-patterns/SKILL.md @@ -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/.md`. +- Keep it short and actionable; link to concrete files in the current repo. +- Update `references/components-index.md` with the new entry. diff --git a/swiftui-ui-patterns/references/app-scaffolding-wiring.md b/swiftui-ui-patterns/references/app-scaffolding-wiring.md new file mode 100644 index 0000000..a569a72 --- /dev/null +++ b/swiftui-ui-patterns/references/app-scaffolding-wiring.md @@ -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` diff --git a/swiftui-ui-patterns/references/components-index.md b/swiftui-ui-patterns/references/components-index.md new file mode 100644 index 0000000..504a37a --- /dev/null +++ b/swiftui-ui-patterns/references/components-index.md @@ -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. diff --git a/swiftui-ui-patterns/references/controls.md b/swiftui-ui-patterns/references/controls.md new file mode 100644 index 0000000..5a70848 --- /dev/null +++ b/swiftui-ui-patterns/references/controls.md @@ -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. diff --git a/swiftui-ui-patterns/references/deeplinks.md b/swiftui-ui-patterns/references/deeplinks.md new file mode 100644 index 0000000..6bb7d5c --- /dev/null +++ b/swiftui-ui-patterns/references/deeplinks.md @@ -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`. diff --git a/swiftui-ui-patterns/references/focus.md b/swiftui-ui-patterns/references/focus.md new file mode 100644 index 0000000..da31faf --- /dev/null +++ b/swiftui-ui-patterns/references/focus.md @@ -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. diff --git a/swiftui-ui-patterns/references/form.md b/swiftui-ui-patterns/references/form.md new file mode 100644 index 0000000..e7de4d4 --- /dev/null +++ b/swiftui-ui-patterns/references/form.md @@ -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. diff --git a/swiftui-ui-patterns/references/grids.md b/swiftui-ui-patterns/references/grids.md new file mode 100644 index 0000000..4a822a8 --- /dev/null +++ b/swiftui-ui-patterns/references/grids.md @@ -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. diff --git a/swiftui-ui-patterns/references/haptics.md b/swiftui-ui-patterns/references/haptics.md new file mode 100644 index 0000000..41942be --- /dev/null +++ b/swiftui-ui-patterns/references/haptics.md @@ -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. diff --git a/swiftui-ui-patterns/references/input-toolbar.md b/swiftui-ui-patterns/references/input-toolbar.md new file mode 100644 index 0000000..9506d3f --- /dev/null +++ b/swiftui-ui-patterns/references/input-toolbar.md @@ -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. diff --git a/swiftui-ui-patterns/references/list.md b/swiftui-ui-patterns/references/list.md new file mode 100644 index 0000000..7b570fa --- /dev/null +++ b/swiftui-ui-patterns/references/list.md @@ -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. diff --git a/swiftui-ui-patterns/references/matched-transitions.md b/swiftui-ui-patterns/references/matched-transitions.md new file mode 100644 index 0000000..94922db --- /dev/null +++ b/swiftui-ui-patterns/references/matched-transitions.md @@ -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. diff --git a/swiftui-ui-patterns/references/media.md b/swiftui-ui-patterns/references/media.md new file mode 100644 index 0000000..2201e05 --- /dev/null +++ b/swiftui-ui-patterns/references/media.md @@ -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. diff --git a/swiftui-ui-patterns/references/navigationstack.md b/swiftui-ui-patterns/references/navigationstack.md new file mode 100644 index 0000000..decf5c4 --- /dev/null +++ b/swiftui-ui-patterns/references/navigationstack.md @@ -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. diff --git a/swiftui-ui-patterns/references/overlay.md b/swiftui-ui-patterns/references/overlay.md new file mode 100644 index 0000000..a2a9a63 --- /dev/null +++ b/swiftui-ui-patterns/references/overlay.md @@ -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. diff --git a/swiftui-ui-patterns/references/scrollview.md b/swiftui-ui-patterns/references/scrollview.md new file mode 100644 index 0000000..849c58c --- /dev/null +++ b/swiftui-ui-patterns/references/scrollview.md @@ -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. diff --git a/swiftui-ui-patterns/references/searchable.md b/swiftui-ui-patterns/references/searchable.md new file mode 100644 index 0000000..6d7a2f1 --- /dev/null +++ b/swiftui-ui-patterns/references/searchable.md @@ -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. diff --git a/swiftui-ui-patterns/references/sheets.md b/swiftui-ui-patterns/references/sheets.md new file mode 100644 index 0000000..9ba07d6 --- /dev/null +++ b/swiftui-ui-patterns/references/sheets.md @@ -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 + ) -> 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: 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. diff --git a/swiftui-ui-patterns/references/split-views.md b/swiftui-ui-patterns/references/split-views.md new file mode 100644 index 0000000..fe37349 --- /dev/null +++ b/swiftui-ui-patterns/references/split-views.md @@ -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. diff --git a/swiftui-ui-patterns/references/tabview.md b/swiftui-ui-patterns/references/tabview.md new file mode 100644 index 0000000..2a44bfe --- /dev/null +++ b/swiftui-ui-patterns/references/tabview.md @@ -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. diff --git a/swiftui-ui-patterns/references/theming.md b/swiftui-ui-patterns/references/theming.md new file mode 100644 index 0000000..ec976fe --- /dev/null +++ b/swiftui-ui-patterns/references/theming.md @@ -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. diff --git a/swiftui-ui-patterns/references/top-bar.md b/swiftui-ui-patterns/references/top-bar.md new file mode 100644 index 0000000..f5ea899 --- /dev/null +++ b/swiftui-ui-patterns/references/top-bar.md @@ -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.