swift-code-reviewer-skill 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -162
- package/README.md +91 -21
- package/SKILL.md +107 -725
- package/bin/install.js +87 -22
- package/package.json +16 -2
- package/references/companion-skills.md +70 -0
- package/skills/README.md +43 -0
- package/skills/swift-concurrency/NOTICE.md +18 -0
- package/skills/swift-concurrency/SKILL.md +235 -0
- package/skills/swift-concurrency/references/actors.md +640 -0
- package/skills/swift-concurrency/references/async-await-basics.md +249 -0
- package/skills/swift-concurrency/references/async-sequences.md +635 -0
- package/skills/swift-concurrency/references/core-data.md +533 -0
- package/skills/swift-concurrency/references/glossary.md +96 -0
- package/skills/swift-concurrency/references/linting.md +38 -0
- package/skills/swift-concurrency/references/memory-management.md +542 -0
- package/skills/swift-concurrency/references/migration.md +721 -0
- package/skills/swift-concurrency/references/performance.md +574 -0
- package/skills/swift-concurrency/references/sendable.md +578 -0
- package/skills/swift-concurrency/references/tasks.md +604 -0
- package/skills/swift-concurrency/references/testing.md +565 -0
- package/skills/swift-concurrency/references/threading.md +452 -0
- package/skills/swift-expert/NOTICE.md +18 -0
- package/skills/swift-expert/SKILL.md +226 -0
- package/skills/swift-expert/references/async-concurrency.md +363 -0
- package/skills/swift-expert/references/memory-performance.md +380 -0
- package/skills/swift-expert/references/protocol-oriented.md +357 -0
- package/skills/swift-expert/references/swiftui-patterns.md +294 -0
- package/skills/swift-expert/references/testing-patterns.md +402 -0
- package/skills/swift-testing/NOTICE.md +18 -0
- package/skills/swift-testing/SKILL.md +295 -0
- package/skills/swift-testing/references/async-testing.md +245 -0
- package/skills/swift-testing/references/dump-snapshot-testing.md +265 -0
- package/skills/swift-testing/references/fixtures.md +193 -0
- package/skills/swift-testing/references/integration-testing.md +189 -0
- package/skills/swift-testing/references/migration-xctest.md +301 -0
- package/skills/swift-testing/references/parameterized-tests.md +171 -0
- package/skills/swift-testing/references/snapshot-testing.md +201 -0
- package/skills/swift-testing/references/test-doubles.md +243 -0
- package/skills/swift-testing/references/test-organization.md +231 -0
- package/skills/swiftui-expert-skill/NOTICE.md +18 -0
- package/skills/swiftui-expert-skill/SKILL.md +281 -0
- package/skills/swiftui-expert-skill/references/accessibility-patterns.md +151 -0
- package/skills/swiftui-expert-skill/references/animation-advanced.md +403 -0
- package/skills/swiftui-expert-skill/references/animation-basics.md +284 -0
- package/skills/swiftui-expert-skill/references/animation-transitions.md +326 -0
- package/skills/swiftui-expert-skill/references/charts-accessibility.md +135 -0
- package/skills/swiftui-expert-skill/references/charts.md +602 -0
- package/skills/swiftui-expert-skill/references/image-optimization.md +203 -0
- package/skills/swiftui-expert-skill/references/latest-apis.md +464 -0
- package/skills/swiftui-expert-skill/references/layout-best-practices.md +266 -0
- package/skills/swiftui-expert-skill/references/liquid-glass.md +414 -0
- package/skills/swiftui-expert-skill/references/list-patterns.md +394 -0
- package/skills/swiftui-expert-skill/references/macos-scenes.md +318 -0
- package/skills/swiftui-expert-skill/references/macos-views.md +357 -0
- package/skills/swiftui-expert-skill/references/macos-window-styling.md +303 -0
- package/skills/swiftui-expert-skill/references/performance-patterns.md +403 -0
- package/skills/swiftui-expert-skill/references/scroll-patterns.md +293 -0
- package/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md +363 -0
- package/skills/swiftui-expert-skill/references/state-management.md +417 -0
- package/skills/swiftui-expert-skill/references/view-structure.md +389 -0
- package/skills/swiftui-ui-patterns/NOTICE.md +18 -0
- package/skills/swiftui-ui-patterns/SKILL.md +95 -0
- package/skills/swiftui-ui-patterns/references/app-wiring.md +201 -0
- package/skills/swiftui-ui-patterns/references/async-state.md +96 -0
- package/skills/swiftui-ui-patterns/references/components-index.md +50 -0
- package/skills/swiftui-ui-patterns/references/controls.md +57 -0
- package/skills/swiftui-ui-patterns/references/deeplinks.md +66 -0
- package/skills/swiftui-ui-patterns/references/focus.md +90 -0
- package/skills/swiftui-ui-patterns/references/form.md +97 -0
- package/skills/swiftui-ui-patterns/references/grids.md +71 -0
- package/skills/swiftui-ui-patterns/references/haptics.md +71 -0
- package/skills/swiftui-ui-patterns/references/input-toolbar.md +51 -0
- package/skills/swiftui-ui-patterns/references/lightweight-clients.md +93 -0
- package/skills/swiftui-ui-patterns/references/list.md +86 -0
- package/skills/swiftui-ui-patterns/references/loading-placeholders.md +38 -0
- package/skills/swiftui-ui-patterns/references/macos-settings.md +71 -0
- package/skills/swiftui-ui-patterns/references/matched-transitions.md +59 -0
- package/skills/swiftui-ui-patterns/references/media.md +73 -0
- package/skills/swiftui-ui-patterns/references/menu-bar.md +101 -0
- package/skills/swiftui-ui-patterns/references/navigationstack.md +159 -0
- package/skills/swiftui-ui-patterns/references/overlay.md +45 -0
- package/skills/swiftui-ui-patterns/references/performance.md +62 -0
- package/skills/swiftui-ui-patterns/references/previews.md +48 -0
- package/skills/swiftui-ui-patterns/references/scroll-reveal.md +133 -0
- package/skills/swiftui-ui-patterns/references/scrollview.md +87 -0
- package/skills/swiftui-ui-patterns/references/searchable.md +71 -0
- package/skills/swiftui-ui-patterns/references/sheets.md +155 -0
- package/skills/swiftui-ui-patterns/references/split-views.md +72 -0
- package/skills/swiftui-ui-patterns/references/tabview.md +114 -0
- package/skills/swiftui-ui-patterns/references/theming.md +71 -0
- package/skills/swiftui-ui-patterns/references/title-menus.md +93 -0
- package/skills/swiftui-ui-patterns/references/top-bar.md +49 -0
- package/templates/agents/swift-code-reviewer.md +78 -0
- package/templates/commands/review.md +56 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Menu Bar
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
Use this when adding or customizing the macOS/iPadOS menu bar with SwiftUI commands.
|
|
6
|
+
|
|
7
|
+
## Core patterns
|
|
8
|
+
|
|
9
|
+
- Add commands at the `Scene` level with `.commands { ... }`.
|
|
10
|
+
- Use `SidebarCommands()` when your UI includes a navigation sidebar.
|
|
11
|
+
- Use `CommandMenu` for app-specific menus and group related actions.
|
|
12
|
+
- Use `CommandGroup` to insert items before/after system groups or replace them.
|
|
13
|
+
- Use `FocusedValue` for context-sensitive menu items that depend on the active scene.
|
|
14
|
+
|
|
15
|
+
## Example: basic command menu
|
|
16
|
+
|
|
17
|
+
```swift
|
|
18
|
+
@main
|
|
19
|
+
struct MyApp: App {
|
|
20
|
+
var body: some Scene {
|
|
21
|
+
WindowGroup {
|
|
22
|
+
ContentView()
|
|
23
|
+
}
|
|
24
|
+
.commands {
|
|
25
|
+
CommandMenu("Actions") {
|
|
26
|
+
Button("Run", action: run)
|
|
27
|
+
.keyboardShortcut("R")
|
|
28
|
+
Button("Stop", action: stop)
|
|
29
|
+
.keyboardShortcut(".")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private func run() {}
|
|
35
|
+
private func stop() {}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Example: insert and replace groups
|
|
40
|
+
|
|
41
|
+
```swift
|
|
42
|
+
WindowGroup {
|
|
43
|
+
ContentView()
|
|
44
|
+
}
|
|
45
|
+
.commands {
|
|
46
|
+
CommandGroup(before: .systemServices) {
|
|
47
|
+
Button("Check for Updates") { /* open updater */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
CommandGroup(after: .newItem) {
|
|
51
|
+
Button("New from Clipboard") { /* create item */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
CommandGroup(replacing: .help) {
|
|
55
|
+
Button("User Manual") { /* open docs */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Example: focused menu state
|
|
61
|
+
|
|
62
|
+
```swift
|
|
63
|
+
@Observable
|
|
64
|
+
final class DataModel {
|
|
65
|
+
var items: [String] = []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
struct ContentView: View {
|
|
69
|
+
@State private var model = DataModel()
|
|
70
|
+
|
|
71
|
+
var body: some View {
|
|
72
|
+
List(model.items, id: \.self) { item in
|
|
73
|
+
Text(item)
|
|
74
|
+
}
|
|
75
|
+
.focusedSceneValue(model)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
struct ItemCommands: Commands {
|
|
80
|
+
@FocusedValue(DataModel.self) private var model: DataModel?
|
|
81
|
+
|
|
82
|
+
var body: some Commands {
|
|
83
|
+
CommandGroup(after: .newItem) {
|
|
84
|
+
Button("New Item") {
|
|
85
|
+
model?.items.append("Untitled")
|
|
86
|
+
}
|
|
87
|
+
.disabled(model == nil)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Menu bar and Settings
|
|
94
|
+
|
|
95
|
+
- Defining a `Settings` scene adds the Settings menu item on macOS automatically.
|
|
96
|
+
- If you need a custom entry point inside the app, use `OpenSettingsAction` or `SettingsLink`.
|
|
97
|
+
|
|
98
|
+
## Pitfalls
|
|
99
|
+
|
|
100
|
+
- Avoid registering the same keyboard shortcut in multiple command groups.
|
|
101
|
+
- Don’t use menu items as the only discoverable entry point for critical features.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# NavigationStack
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Core architecture
|
|
8
|
+
|
|
9
|
+
- Define a route enum that is `Hashable` and represents all destinations.
|
|
10
|
+
- Create a lightweight router (or use a library such as `https://github.com/Dimillian/AppRouter`) that owns the `path` and any sheet state.
|
|
11
|
+
- Each tab owns its own router instance and binds `NavigationStack(path:)` to it.
|
|
12
|
+
- Inject the router into the environment so child views can navigate programmatically.
|
|
13
|
+
- Centralize destination mapping with a single `navigationDestination(for:)` block (or a `withAppRouter()` modifier).
|
|
14
|
+
|
|
15
|
+
## Example: custom router with per-tab stack
|
|
16
|
+
|
|
17
|
+
```swift
|
|
18
|
+
@MainActor
|
|
19
|
+
@Observable
|
|
20
|
+
final class RouterPath {
|
|
21
|
+
var path: [Route] = []
|
|
22
|
+
var presentedSheet: SheetDestination?
|
|
23
|
+
|
|
24
|
+
func navigate(to route: Route) {
|
|
25
|
+
path.append(route)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func reset() {
|
|
29
|
+
path = []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
enum Route: Hashable {
|
|
34
|
+
case account(id: String)
|
|
35
|
+
case status(id: String)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@MainActor
|
|
39
|
+
struct TimelineTab: View {
|
|
40
|
+
@State private var routerPath = RouterPath()
|
|
41
|
+
|
|
42
|
+
var body: some View {
|
|
43
|
+
NavigationStack(path: $routerPath.path) {
|
|
44
|
+
TimelineView()
|
|
45
|
+
.navigationDestination(for: Route.self) { route in
|
|
46
|
+
switch route {
|
|
47
|
+
case .account(let id): AccountView(id: id)
|
|
48
|
+
case .status(let id): StatusView(id: id)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
.environment(routerPath)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Example: centralized destination mapping
|
|
58
|
+
|
|
59
|
+
Use a shared view modifier to avoid duplicating route switches across screens.
|
|
60
|
+
|
|
61
|
+
```swift
|
|
62
|
+
extension View {
|
|
63
|
+
func withAppRouter() -> some View {
|
|
64
|
+
navigationDestination(for: Route.self) { route in
|
|
65
|
+
switch route {
|
|
66
|
+
case .account(let id):
|
|
67
|
+
AccountView(id: id)
|
|
68
|
+
case .status(let id):
|
|
69
|
+
StatusView(id: id)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then apply it once per stack:
|
|
77
|
+
|
|
78
|
+
```swift
|
|
79
|
+
NavigationStack(path: $routerPath.path) {
|
|
80
|
+
TimelineView()
|
|
81
|
+
.withAppRouter()
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Example: binding per tab (tabs with independent history)
|
|
86
|
+
|
|
87
|
+
```swift
|
|
88
|
+
@MainActor
|
|
89
|
+
struct TabsView: View {
|
|
90
|
+
@State private var timelineRouter = RouterPath()
|
|
91
|
+
@State private var notificationsRouter = RouterPath()
|
|
92
|
+
|
|
93
|
+
var body: some View {
|
|
94
|
+
TabView {
|
|
95
|
+
TimelineTab(router: timelineRouter)
|
|
96
|
+
NotificationsTab(router: notificationsRouter)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Example: generic tabs with per-tab NavigationStack
|
|
103
|
+
|
|
104
|
+
Use this when tabs are built from data and each needs its own path without hard-coded names.
|
|
105
|
+
|
|
106
|
+
```swift
|
|
107
|
+
@MainActor
|
|
108
|
+
struct TabsView: View {
|
|
109
|
+
@State private var selectedTab: AppTab = .timeline
|
|
110
|
+
@State private var tabRouter = TabRouter()
|
|
111
|
+
|
|
112
|
+
var body: some View {
|
|
113
|
+
TabView(selection: $selectedTab) {
|
|
114
|
+
ForEach(AppTab.allCases) { tab in
|
|
115
|
+
NavigationStack(path: tabRouter.binding(for: tab)) {
|
|
116
|
+
tab.makeContentView()
|
|
117
|
+
}
|
|
118
|
+
.environment(tabRouter.router(for: tab))
|
|
119
|
+
.tabItem { tab.label }
|
|
120
|
+
.tag(tab)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
@MainActor
|
|
128
|
+
@Observable
|
|
129
|
+
final class TabRouter {
|
|
130
|
+
private var routers: [AppTab: RouterPath] = [:]
|
|
131
|
+
|
|
132
|
+
func router(for tab: AppTab) -> RouterPath {
|
|
133
|
+
if let router = routers[tab] { return router }
|
|
134
|
+
let router = RouterPath()
|
|
135
|
+
routers[tab] = router
|
|
136
|
+
return router
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func binding(for tab: AppTab) -> Binding<[Route]> {
|
|
140
|
+
let router = router(for: tab)
|
|
141
|
+
return Binding(get: { router.path }, set: { router.path = $0 })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
## Design choices to keep
|
|
146
|
+
|
|
147
|
+
- One `NavigationStack` per tab to preserve independent history.
|
|
148
|
+
- A single source of truth for navigation state (`RouterPath` or library router).
|
|
149
|
+
- Use `navigationDestination(for:)` to map routes to views.
|
|
150
|
+
- Reset the path when app context changes (account switch, logout, etc.).
|
|
151
|
+
- Inject the router into the environment so child views can navigate and present sheets without prop-drilling.
|
|
152
|
+
- Keep sheet presentation state on the router if you want a single place to manage modals.
|
|
153
|
+
|
|
154
|
+
## Pitfalls
|
|
155
|
+
|
|
156
|
+
- Do not share one path across all tabs unless you want global history.
|
|
157
|
+
- Ensure route identifiers are stable and `Hashable`.
|
|
158
|
+
- Avoid storing view instances in the path; store lightweight route data instead.
|
|
159
|
+
- If using a router object, keep it outside other `@Observable` objects to avoid nested observation.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Overlay and toasts
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
Use overlays for transient UI (toasts, banners, loaders) without affecting layout.
|
|
6
|
+
|
|
7
|
+
## Core patterns
|
|
8
|
+
|
|
9
|
+
- Use `.overlay(alignment:)` to place global UI without changing the underlying layout.
|
|
10
|
+
- Keep overlays lightweight and dismissible.
|
|
11
|
+
- Use a dedicated `ToastCenter` (or similar) for global state if multiple features trigger toasts.
|
|
12
|
+
|
|
13
|
+
## Example: toast overlay
|
|
14
|
+
|
|
15
|
+
```swift
|
|
16
|
+
struct AppRootView: View {
|
|
17
|
+
@State private var toast: Toast?
|
|
18
|
+
|
|
19
|
+
var body: some View {
|
|
20
|
+
content
|
|
21
|
+
.overlay(alignment: .top) {
|
|
22
|
+
if let toast {
|
|
23
|
+
ToastView(toast: toast)
|
|
24
|
+
.transition(.move(edge: .top).combined(with: .opacity))
|
|
25
|
+
.onAppear {
|
|
26
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
27
|
+
withAnimation { self.toast = nil }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Design choices to keep
|
|
37
|
+
|
|
38
|
+
- Prefer overlays for transient UI rather than embedding in layout stacks.
|
|
39
|
+
- Use transitions and short auto-dismiss timers.
|
|
40
|
+
- Keep the overlay aligned to a clear edge (`.top` or `.bottom`).
|
|
41
|
+
|
|
42
|
+
## Pitfalls
|
|
43
|
+
|
|
44
|
+
- Avoid overlays that block all interaction unless explicitly needed.
|
|
45
|
+
- Don’t stack many overlays; use a queue or replace the current toast.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Performance guardrails
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated, or at risk of unnecessary recomputation.
|
|
6
|
+
|
|
7
|
+
## Core rules
|
|
8
|
+
|
|
9
|
+
- Give `ForEach` and list content stable identity. Do not use unstable indices as identity when the collection can reorder or mutate.
|
|
10
|
+
- Keep expensive filtering, sorting, and formatting out of `body`; precompute or move it into a model/helper when it is not trivial.
|
|
11
|
+
- Narrow observation scope so only the views that read changing state need to update.
|
|
12
|
+
- Prefer lazy containers for larger scrolling content and extract subviews when only part of a screen changes frequently.
|
|
13
|
+
- Avoid swapping entire top-level view trees for small state changes; keep a stable root view and vary localized sections or modifiers.
|
|
14
|
+
|
|
15
|
+
## Example: stable identity
|
|
16
|
+
|
|
17
|
+
```swift
|
|
18
|
+
ForEach(items) { item in
|
|
19
|
+
Row(item: item)
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Prefer that over index-based identity when the collection can change order:
|
|
24
|
+
|
|
25
|
+
```swift
|
|
26
|
+
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
|
27
|
+
Row(item: item)
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Example: move expensive work out of body
|
|
32
|
+
|
|
33
|
+
```swift
|
|
34
|
+
struct FeedView: View {
|
|
35
|
+
let items: [FeedItem]
|
|
36
|
+
|
|
37
|
+
private var sortedItems: [FeedItem] {
|
|
38
|
+
items.sorted(using: KeyPathComparator(\.createdAt, order: .reverse))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var body: some View {
|
|
42
|
+
List(sortedItems) { item in
|
|
43
|
+
FeedRow(item: item)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If the work is more expensive than a small derived property, move it into a model, store, or helper that updates less often.
|
|
50
|
+
|
|
51
|
+
## When to investigate further
|
|
52
|
+
|
|
53
|
+
- Janky scrolling in long feeds or grids
|
|
54
|
+
- Typing lag from search or form validation
|
|
55
|
+
- Overly broad view updates when one small piece of state changes
|
|
56
|
+
- Large screens with many conditionals or repeated formatting work
|
|
57
|
+
|
|
58
|
+
## Pitfalls
|
|
59
|
+
|
|
60
|
+
- Recomputing heavy transforms every render
|
|
61
|
+
- Observing a large object from many descendants when only one field matters
|
|
62
|
+
- Building custom scroll containers when `List`, `LazyVStack`, or `LazyHGrid` would already solve the problem
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Previews
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
Use previews to validate layout, state wiring, and injected dependencies without relying on a running app or live services.
|
|
6
|
+
|
|
7
|
+
## Core rules
|
|
8
|
+
|
|
9
|
+
- Add `#Preview` coverage for the primary state plus important secondary states such as loading, empty, and error.
|
|
10
|
+
- Use deterministic fixtures, mocks, and sample data. Do not make previews depend on live network calls, real databases, or global singletons.
|
|
11
|
+
- Install required environment dependencies directly in the preview so the view can render in isolation.
|
|
12
|
+
- Keep preview setup close to the view until it becomes noisy; then extract lightweight preview helpers or fixtures.
|
|
13
|
+
- If a preview crashes, fix the state initialization or dependency wiring before expanding the feature further.
|
|
14
|
+
|
|
15
|
+
## Example: simple preview states
|
|
16
|
+
|
|
17
|
+
```swift
|
|
18
|
+
#Preview("Loaded") {
|
|
19
|
+
ProfileView(profile: .fixture)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#Preview("Empty") {
|
|
23
|
+
ProfileView(profile: nil)
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Example: preview with injected dependencies
|
|
28
|
+
|
|
29
|
+
```swift
|
|
30
|
+
#Preview("Search results") {
|
|
31
|
+
SearchView()
|
|
32
|
+
.environment(SearchClient.preview(results: [.fixture, .fixture2]))
|
|
33
|
+
.environment(Theme.preview)
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Preview checklist
|
|
38
|
+
|
|
39
|
+
- Does the preview install every required environment dependency?
|
|
40
|
+
- Does it cover at least one success path and one non-happy path?
|
|
41
|
+
- Are fixtures stable and small enough to be read quickly?
|
|
42
|
+
- Can the preview render without network, auth, or app-global initialization?
|
|
43
|
+
|
|
44
|
+
## Pitfalls
|
|
45
|
+
|
|
46
|
+
- Do not hide preview crashes by making dependencies optional if the production view requires them.
|
|
47
|
+
- Avoid huge inline fixtures when a named sample is easier to read.
|
|
48
|
+
- Do not couple previews to global shared singletons unless the project has no alternative.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Scroll-reveal detail surfaces
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
Use this pattern when a detail screen has a primary surface first and secondary content behind it, and you want the user to reveal that secondary layer by scrolling or swiping instead of tapping a separate button.
|
|
6
|
+
|
|
7
|
+
Typical fits:
|
|
8
|
+
|
|
9
|
+
- media detail screens that reveal actions or metadata
|
|
10
|
+
- maps, cards, or canvases that transition into structured detail
|
|
11
|
+
- full-screen viewers with a second "actions" or "insights" page
|
|
12
|
+
|
|
13
|
+
## Core pattern
|
|
14
|
+
|
|
15
|
+
Build the interaction as a paged vertical `ScrollView` with two sections:
|
|
16
|
+
|
|
17
|
+
1. a primary section sized to the viewport
|
|
18
|
+
2. a secondary section below it
|
|
19
|
+
|
|
20
|
+
Derive a normalized `progress` value from the vertical content offset and drive all visual changes from that one value.
|
|
21
|
+
|
|
22
|
+
Avoid treating the reveal as a separate gesture system unless scroll alone cannot express it.
|
|
23
|
+
|
|
24
|
+
## Minimal structure
|
|
25
|
+
|
|
26
|
+
```swift
|
|
27
|
+
private enum DetailSection: Hashable {
|
|
28
|
+
case primary
|
|
29
|
+
case secondary
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
struct DetailSurface: View {
|
|
33
|
+
@State private var revealProgress: CGFloat = 0
|
|
34
|
+
@State private var secondaryHeight: CGFloat = 1
|
|
35
|
+
|
|
36
|
+
var body: some View {
|
|
37
|
+
GeometryReader { geometry in
|
|
38
|
+
ScrollViewReader { proxy in
|
|
39
|
+
ScrollView(.vertical, showsIndicators: false) {
|
|
40
|
+
VStack(spacing: 0) {
|
|
41
|
+
PrimaryContent(progress: revealProgress)
|
|
42
|
+
.frame(height: geometry.size.height)
|
|
43
|
+
.id(DetailSection.primary)
|
|
44
|
+
|
|
45
|
+
SecondaryContent(progress: revealProgress)
|
|
46
|
+
.id(DetailSection.secondary)
|
|
47
|
+
.onGeometryChange(for: CGFloat.self) { geo in
|
|
48
|
+
geo.size.height
|
|
49
|
+
} action: { newHeight in
|
|
50
|
+
secondaryHeight = max(newHeight, 1)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
.scrollTargetLayout()
|
|
54
|
+
}
|
|
55
|
+
.scrollTargetBehavior(.paging)
|
|
56
|
+
.onScrollGeometryChange(for: CGFloat.self, of: { scroll in
|
|
57
|
+
scroll.contentOffset.y + scroll.contentInsets.top
|
|
58
|
+
}) { _, offset in
|
|
59
|
+
revealProgress = (offset / secondaryHeight).clamped(to: 0...1)
|
|
60
|
+
}
|
|
61
|
+
.safeAreaInset(edge: .bottom) {
|
|
62
|
+
ChevronAffordance(progress: revealProgress) {
|
|
63
|
+
withAnimation(.smooth) {
|
|
64
|
+
let target: DetailSection = revealProgress < 0.5 ? .secondary : .primary
|
|
65
|
+
proxy.scrollTo(target, anchor: .top)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Design choices to keep
|
|
76
|
+
|
|
77
|
+
- Make the primary section exactly viewport-sized when the interaction should feel like paging between states.
|
|
78
|
+
- Compute `progress` from real scroll offset, not from duplicated booleans like `isExpanded`, `isShowingSecondary`, and `isSnapped`.
|
|
79
|
+
- Use `progress` to drive `offset`, `opacity`, `blur`, `scaleEffect`, and toolbar state so the whole surface stays synchronized.
|
|
80
|
+
- Use `ScrollViewReader` for programmatic snapping from taps on the primary content or chevron affordances.
|
|
81
|
+
- Use `onScrollTargetVisibilityChange` when you need a settled section state for haptics, tooltip dismissal, analytics, or accessibility announcements.
|
|
82
|
+
|
|
83
|
+
## Morphing a shared control
|
|
84
|
+
|
|
85
|
+
If a control appears to move from the primary surface into the secondary content, do not render two fully visible copies.
|
|
86
|
+
|
|
87
|
+
Instead:
|
|
88
|
+
|
|
89
|
+
- expose a source anchor in the primary area
|
|
90
|
+
- expose a destination anchor in the secondary area
|
|
91
|
+
- render one overlay that interpolates position and size using `progress`
|
|
92
|
+
|
|
93
|
+
```swift
|
|
94
|
+
Color.clear
|
|
95
|
+
.anchorPreference(key: ControlAnchorKey.self, value: .bounds) { anchor in
|
|
96
|
+
["source": anchor]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
Color.clear
|
|
100
|
+
.anchorPreference(key: ControlAnchorKey.self, value: .bounds) { anchor in
|
|
101
|
+
["destination": anchor]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.overlayPreferenceValue(ControlAnchorKey.self) { anchors in
|
|
105
|
+
MorphingControlOverlay(anchors: anchors, progress: revealProgress)
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This keeps the motion coherent and avoids duplicate-hit-target bugs.
|
|
110
|
+
|
|
111
|
+
## Haptics and affordances
|
|
112
|
+
|
|
113
|
+
- Use light threshold haptics when the reveal begins and stronger haptics near the committed state.
|
|
114
|
+
- Keep a visible affordance like a chevron or pill while `progress` is near zero.
|
|
115
|
+
- Flip, fade, or blur the affordance as the secondary section becomes active.
|
|
116
|
+
|
|
117
|
+
## Interaction guards
|
|
118
|
+
|
|
119
|
+
- Disable vertical scrolling when a conflicting mode is active, such as pinch-to-zoom, crop, or full-screen media manipulation.
|
|
120
|
+
- Disable hit testing on overlays that should disappear once the secondary content is revealed.
|
|
121
|
+
- Avoid same-axis nested scroll views unless the inner view is effectively static or disabled during the reveal.
|
|
122
|
+
|
|
123
|
+
## Pitfalls
|
|
124
|
+
|
|
125
|
+
- Do not hard-code the progress divisor. Measure the secondary section height or another real reveal distance.
|
|
126
|
+
- Do not mix multiple animation sources for the same property. If `progress` drives it, keep other animations off that property.
|
|
127
|
+
- Do not store derived state like `isSecondaryVisible` unless another API requires it. Prefer deriving it from `progress` or visible scroll targets.
|
|
128
|
+
- Beware of layout feedback loops when measuring heights. Clamp zero values and update only when the measured height actually changes.
|
|
129
|
+
|
|
130
|
+
## Concrete example
|
|
131
|
+
|
|
132
|
+
- Pool iOS tile detail reveal: `/Users/dimillian/Documents/Dev/Pool/pool-ios/Pool/Sources/Features/Tile/Detail/TileDetailView.swift`
|
|
133
|
+
- Secondary content anchor example: `/Users/dimillian/Documents/Dev/Pool/pool-ios/Pool/Sources/Features/Tile/Detail/TileDetailIntentListView.swift`
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# ScrollView and Lazy stacks
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
Use `ScrollView` with `LazyVStack`, `LazyHStack`, or `LazyVGrid` when you need custom layout, mixed content, or horizontal/ grid-based scrolling.
|
|
6
|
+
|
|
7
|
+
## Core patterns
|
|
8
|
+
|
|
9
|
+
- Prefer `ScrollView` + `LazyVStack` for chat-like or custom feed layouts.
|
|
10
|
+
- Use `ScrollView(.horizontal)` + `LazyHStack` for chips, tags, avatars, and media strips.
|
|
11
|
+
- Use `LazyVGrid` for icon/media grids; prefer adaptive columns when possible.
|
|
12
|
+
- Use `ScrollViewReader` for scroll-to-top/bottom and anchor-based jumps.
|
|
13
|
+
- Use `safeAreaInset(edge:)` for input bars that should stick above the keyboard.
|
|
14
|
+
|
|
15
|
+
## Example: vertical custom feed
|
|
16
|
+
|
|
17
|
+
```swift
|
|
18
|
+
@MainActor
|
|
19
|
+
struct ConversationView: View {
|
|
20
|
+
private enum Constants { static let bottomAnchor = "bottom" }
|
|
21
|
+
@State private var scrollProxy: ScrollViewProxy?
|
|
22
|
+
|
|
23
|
+
var body: some View {
|
|
24
|
+
ScrollViewReader { proxy in
|
|
25
|
+
ScrollView {
|
|
26
|
+
LazyVStack {
|
|
27
|
+
ForEach(messages) { message in
|
|
28
|
+
MessageRow(message: message)
|
|
29
|
+
.id(message.id)
|
|
30
|
+
}
|
|
31
|
+
Color.clear.frame(height: 1).id(Constants.bottomAnchor)
|
|
32
|
+
}
|
|
33
|
+
.padding(.horizontal, .layoutPadding)
|
|
34
|
+
}
|
|
35
|
+
.safeAreaInset(edge: .bottom) {
|
|
36
|
+
MessageInputBar()
|
|
37
|
+
}
|
|
38
|
+
.onAppear {
|
|
39
|
+
scrollProxy = proxy
|
|
40
|
+
withAnimation {
|
|
41
|
+
proxy.scrollTo(Constants.bottomAnchor, anchor: .bottom)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Example: horizontal chips
|
|
50
|
+
|
|
51
|
+
```swift
|
|
52
|
+
ScrollView(.horizontal, showsIndicators: false) {
|
|
53
|
+
LazyHStack(spacing: 8) {
|
|
54
|
+
ForEach(chips) { chip in
|
|
55
|
+
ChipView(chip: chip)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Example: adaptive grid
|
|
62
|
+
|
|
63
|
+
```swift
|
|
64
|
+
let columns = [GridItem(.adaptive(minimum: 120))]
|
|
65
|
+
|
|
66
|
+
ScrollView {
|
|
67
|
+
LazyVGrid(columns: columns, spacing: 8) {
|
|
68
|
+
ForEach(items) { item in
|
|
69
|
+
GridItemView(item: item)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
.padding(8)
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Design choices to keep
|
|
77
|
+
|
|
78
|
+
- Use `Lazy*` stacks when item counts are large or unknown.
|
|
79
|
+
- Use non-lazy stacks for small, fixed-size content to avoid lazy overhead.
|
|
80
|
+
- Keep IDs stable when using `ScrollViewReader`.
|
|
81
|
+
- Prefer explicit animations (`withAnimation`) when scrolling to an ID.
|
|
82
|
+
|
|
83
|
+
## Pitfalls
|
|
84
|
+
|
|
85
|
+
- Avoid nesting scroll views of the same axis; it causes gesture conflicts.
|
|
86
|
+
- Don’t combine `List` and `ScrollView` in the same hierarchy without a clear reason.
|
|
87
|
+
- Overuse of `LazyVStack` for tiny content can add unnecessary complexity.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Searchable
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
Use `searchable` to add native search UI with optional scopes and async results.
|
|
6
|
+
|
|
7
|
+
## Core patterns
|
|
8
|
+
|
|
9
|
+
- Bind `searchable(text:)` to local state.
|
|
10
|
+
- Use `.searchScopes` for multiple search modes.
|
|
11
|
+
- Use `.task(id: searchQuery)` or debounced tasks to avoid overfetching.
|
|
12
|
+
- Show placeholders or progress states while results load.
|
|
13
|
+
|
|
14
|
+
## Example: searchable with scopes
|
|
15
|
+
|
|
16
|
+
```swift
|
|
17
|
+
@MainActor
|
|
18
|
+
struct ExploreView: View {
|
|
19
|
+
@State private var searchQuery = ""
|
|
20
|
+
@State private var searchScope: SearchScope = .all
|
|
21
|
+
@State private var isSearching = false
|
|
22
|
+
@State private var results: [SearchResult] = []
|
|
23
|
+
|
|
24
|
+
var body: some View {
|
|
25
|
+
List {
|
|
26
|
+
if isSearching {
|
|
27
|
+
ProgressView()
|
|
28
|
+
} else {
|
|
29
|
+
ForEach(results) { result in
|
|
30
|
+
SearchRow(result: result)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
.searchable(
|
|
35
|
+
text: $searchQuery,
|
|
36
|
+
placement: .navigationBarDrawer(displayMode: .always),
|
|
37
|
+
prompt: Text("Search")
|
|
38
|
+
)
|
|
39
|
+
.searchScopes($searchScope) {
|
|
40
|
+
ForEach(SearchScope.allCases, id: \.self) { scope in
|
|
41
|
+
Text(scope.title)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
.task(id: searchQuery) {
|
|
45
|
+
await runSearch()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private func runSearch() async {
|
|
50
|
+
guard !searchQuery.isEmpty else {
|
|
51
|
+
results = []
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
isSearching = true
|
|
55
|
+
defer { isSearching = false }
|
|
56
|
+
try? await Task.sleep(for: .milliseconds(250))
|
|
57
|
+
results = await fetchResults(query: searchQuery, scope: searchScope)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Design choices to keep
|
|
63
|
+
|
|
64
|
+
- Show a placeholder when search is empty or has no results.
|
|
65
|
+
- Debounce input to avoid spamming the network.
|
|
66
|
+
- Keep search state local to the view.
|
|
67
|
+
|
|
68
|
+
## Pitfalls
|
|
69
|
+
|
|
70
|
+
- Avoid running searches for empty strings.
|
|
71
|
+
- Don’t block the main thread during fetch.
|