@mmerterden/multi-agent-pipeline 20.17.1 → 20.17.2
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 +11 -0
- package/docs/facts.json +6 -6
- package/install/catalog-history.json +1 -1
- package/manifest.json +75 -37
- package/package.json +1 -1
- package/pipeline/skills/.skill-manifest.json +33 -13
- package/pipeline/skills/.skills-index.json +66 -11
- package/pipeline/skills/shared/README.md +18 -13
- package/pipeline/skills/shared/external/NOTICE-avdlee-swiftui-agent-skill.md +3 -1
- package/pipeline/skills/shared/external/NOTICE-dimillian-skills.md +2 -1
- package/pipeline/skills/shared/external/hig-app-icons/SKILL.md +159 -0
- package/pipeline/skills/shared/external/hig-app-icons/evals/evals.json +48 -0
- package/pipeline/skills/shared/external/hig-app-icons/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/hig-app-icons/references/icon-workflow.md +115 -0
- package/pipeline/skills/shared/external/hig-components-layout/SKILL.md +32 -4
- package/pipeline/skills/shared/external/hig-components-layout/evals/evals.json +47 -0
- package/pipeline/skills/shared/external/hig-components-layout/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/hig-components-layout/references/bars-and-tabs.md +231 -0
- package/pipeline/skills/shared/external/hig-components-layout/references/containers.md +5 -30
- package/pipeline/skills/shared/external/hig-components-status/SKILL.md +1 -1
- package/pipeline/skills/shared/external/hig-foundations/SKILL.md +23 -4
- package/pipeline/skills/shared/external/hig-foundations/evals/evals.json +47 -0
- package/pipeline/skills/shared/external/hig-foundations/evals/trigger-eval.json +21 -0
- package/pipeline/skills/shared/external/hig-foundations/references/foundations-detail.md +34 -26
- package/pipeline/skills/shared/external/hig-foundations/references/type-color-spacing.md +219 -0
- package/pipeline/skills/shared/external/hig-inputs/SKILL.md +5 -4
- package/pipeline/skills/shared/external/hig-patterns/SKILL.md +19 -3
- package/pipeline/skills/shared/external/hig-patterns/references/onboarding-and-states.md +188 -0
- package/pipeline/skills/shared/external/hig-patterns/references/patterns.md +5 -11
- package/pipeline/skills/shared/external/hig-platforms/SKILL.md +17 -5
- package/pipeline/skills/shared/external/hig-platforms/references/ipad-layout.md +218 -0
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +4 -1
- package/pipeline/skills/shared/external/ios-accessibility/references/large-content-and-appearance.md +135 -0
- package/pipeline/skills/shared/external/ios-design-review/SKILL.md +146 -0
- package/pipeline/skills/shared/external/ios-design-review/evals/evals.json +48 -0
- package/pipeline/skills/shared/external/ios-design-review/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/ios-design-review/references/appearance-matrix.md +135 -0
- package/pipeline/skills/shared/external/ios-design-review/references/checklist.md +193 -0
- package/pipeline/skills/shared/external/ios-haptics/SKILL.md +191 -0
- package/pipeline/skills/shared/external/ios-haptics/evals/evals.json +49 -0
- package/pipeline/skills/shared/external/ios-haptics/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/ios-haptics/references/core-haptics.md +215 -0
- package/pipeline/skills/shared/external/ios-iphone-duo/NOTICE.md +53 -0
- package/pipeline/skills/shared/external/ios-iphone-duo/SKILL.md +170 -0
- package/pipeline/skills/shared/external/ios-iphone-duo/evals/evals.json +50 -0
- package/pipeline/skills/shared/external/ios-iphone-duo/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/ios-iphone-duo/references/api-map.md +212 -0
- package/pipeline/skills/shared/external/ios-iphone-duo/references/fixed-assumptions.md +145 -0
- package/pipeline/skills/shared/external/ios-iphone-duo/references/poses-and-panes.md +122 -0
- package/pipeline/skills/shared/external/ios-sf-symbols/SKILL.md +177 -0
- package/pipeline/skills/shared/external/ios-sf-symbols/evals/evals.json +49 -0
- package/pipeline/skills/shared/external/ios-sf-symbols/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/ios-sf-symbols/references/effects-and-custom-symbols.md +135 -0
- package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +38 -50
- package/pipeline/skills/shared/external/swiftui-animation/evals/evals.json +47 -0
- package/pipeline/skills/shared/external/swiftui-animation/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +7 -73
- package/pipeline/skills/shared/external/swiftui-animation/references/scroll-effects.md +185 -0
- package/pipeline/skills/shared/external/swiftui-expert-skill/SKILL.md +6 -6
- package/pipeline/skills/shared/external/swiftui-expert-skill/references/animation-advanced.md +3 -253
- package/pipeline/skills/shared/external/swiftui-expert-skill/references/animation-basics.md +4 -47
- package/pipeline/skills/shared/external/swiftui-expert-skill/references/animation-transitions.md +3 -111
- package/pipeline/skills/shared/external/swiftui-expert-skill/references/liquid-glass.md +11 -381
- package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +184 -224
- package/pipeline/skills/shared/external/swiftui-liquid-glass/evals/evals.json +47 -0
- package/pipeline/skills/shared/external/swiftui-liquid-glass/evals/trigger-eval.json +22 -0
- package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +246 -33
- package/pipeline/skills/shared/external/swiftui-liquid-glass/references/uikit-glass.md +227 -0
- package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +15 -18
- package/pipeline/skills/shared/external/swiftui-ui-patterns/references/components-index.md +1 -1
- package/pipeline/skills/shared/external/swiftui-ui-patterns/references/haptics.md +6 -68
- package/pipeline/skills/skills-index.md +16 -11
- package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +0 -786
|
@@ -1,786 +0,0 @@
|
|
|
1
|
-
# Design Polish
|
|
2
|
-
|
|
3
|
-
Human Interface Guidelines applied in SwiftUI (layout, type, color, navigation
|
|
4
|
-
chrome, feedback, accessibility, empty states), plus theming, haptics, matched
|
|
5
|
-
transitions, loading placeholders and form focus.
|
|
6
|
-
|
|
7
|
-
## Contents
|
|
8
|
-
|
|
9
|
-
1. [HIG: Layout and Spacing](#1-hig-layout-and-spacing)
|
|
10
|
-
2. [HIG: Typography](#2-hig-typography)
|
|
11
|
-
3. [HIG: Color](#3-hig-color)
|
|
12
|
-
4. [HIG: Navigation Chrome](#4-hig-navigation-chrome)
|
|
13
|
-
5. [HIG: Feedback](#5-hig-feedback)
|
|
14
|
-
6. [HIG: Accessibility](#6-hig-accessibility)
|
|
15
|
-
7. [HIG: Error and Empty States](#7-hig-error-and-empty-states)
|
|
16
|
-
8. [Theming and Dynamic Type](#8-theming-and-dynamic-type)
|
|
17
|
-
9. [Haptics](#9-haptics)
|
|
18
|
-
10. [Core Haptics](#10-core-haptics)
|
|
19
|
-
11. [Matched Transitions](#11-matched-transitions)
|
|
20
|
-
12. [Loading and Placeholders](#12-loading-and-placeholders)
|
|
21
|
-
13. [Form Focus](#13-form-focus)
|
|
22
|
-
|
|
23
|
-
## 1. HIG: Layout and Spacing
|
|
24
|
-
|
|
25
|
-
Start by leaving `spacing:` off stacks; the adaptive default is usually right.
|
|
26
|
-
When you do choose a value on purpose, take it from a 4 point grid. That grid is
|
|
27
|
-
a widespread design convention, not something Apple requires, and values
|
|
28
|
-
between its stops should not be invented.
|
|
29
|
-
|
|
30
|
-
| Points | Token | Typical use |
|
|
31
|
-
|---|---|---|
|
|
32
|
-
| 4 | `xxSmall` | Icon to label, inline badge offset |
|
|
33
|
-
| 8 | `xSmall` | Related items in one group, tight stack gaps |
|
|
34
|
-
| 12 | `small` | Row inner padding, label to secondary text |
|
|
35
|
-
| 16 | `medium` | Standard margin, default gap between sections |
|
|
36
|
-
| 20 | `mediumLarge` | Room between separate controls |
|
|
37
|
-
| 24 | `large` | Section separators, card inner padding |
|
|
38
|
-
| 32 | `xLarge` | Major groups, header to content |
|
|
39
|
-
| 40 | `xxLarge` | Large section breaks |
|
|
40
|
-
| 48 | `xxxLarge` | Hero, splash and onboarding layouts |
|
|
41
|
-
|
|
42
|
-
```swift
|
|
43
|
-
enum Spacing {
|
|
44
|
-
static let xxSmall: CGFloat = 4
|
|
45
|
-
static let xSmall: CGFloat = 8
|
|
46
|
-
static let small: CGFloat = 12
|
|
47
|
-
static let medium: CGFloat = 16
|
|
48
|
-
static let mediumLarge: CGFloat = 20
|
|
49
|
-
static let large: CGFloat = 24
|
|
50
|
-
static let xLarge: CGFloat = 32
|
|
51
|
-
static let xxLarge: CGFloat = 40
|
|
52
|
-
static let xxxLarge: CGFloat = 48
|
|
53
|
-
}
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Margins shared across screens can be named insets:
|
|
57
|
-
|
|
58
|
-
```swift
|
|
59
|
-
private enum Margin {
|
|
60
|
-
static let regular: CGFloat = 16
|
|
61
|
-
static let tight: CGFloat = 8
|
|
62
|
-
static let wide: CGFloat = 24
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
extension EdgeInsets {
|
|
66
|
-
static let regular = EdgeInsets(top: Margin.regular, leading: Margin.regular,
|
|
67
|
-
bottom: Margin.regular, trailing: Margin.regular)
|
|
68
|
-
static let rowContent = EdgeInsets(top: 12, leading: Margin.regular,
|
|
69
|
-
bottom: 12, trailing: Margin.regular)
|
|
70
|
-
}
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Respect the safe area and pin persistent actions with `safeAreaInset`:
|
|
74
|
-
|
|
75
|
-
```swift
|
|
76
|
-
ScrollView {
|
|
77
|
-
LazyVStack { ForEach(lines) { OrderLineRow(line: $0) } }
|
|
78
|
-
.padding(.horizontal)
|
|
79
|
-
}
|
|
80
|
-
.safeAreaInset(edge: .bottom) {
|
|
81
|
-
HStack {
|
|
82
|
-
Button("Back", action: goBack).buttonStyle(.bordered)
|
|
83
|
-
Button("Place order", action: placeOrder).buttonStyle(.borderedProminent)
|
|
84
|
-
}
|
|
85
|
-
.padding()
|
|
86
|
-
.background(.regularMaterial)
|
|
87
|
-
}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
Adapt compact and regular widths with the size class:
|
|
91
|
-
|
|
92
|
-
```swift
|
|
93
|
-
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
94
|
-
|
|
95
|
-
private var gridColumns: [GridItem] {
|
|
96
|
-
let count = sizeClass == .regular ? 3 : 1
|
|
97
|
-
return Array(repeating: GridItem(.flexible()), count: count)
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
## 2. HIG: Typography
|
|
102
|
-
|
|
103
|
-
Text styles scale with Dynamic Type without extra work. Sizes below are the
|
|
104
|
-
iOS defaults at the Large content size.
|
|
105
|
-
|
|
106
|
-
| Style | Size and weight | Use |
|
|
107
|
-
|---|---|---|
|
|
108
|
-
| `.largeTitle` | 34 pt regular | Screen title |
|
|
109
|
-
| `.title` | 28 pt regular | Section header |
|
|
110
|
-
| `.title2` | 22 pt regular | Sub-section header |
|
|
111
|
-
| `.title3` | 20 pt regular | Group header |
|
|
112
|
-
| `.headline` | 17 pt semibold | Row title |
|
|
113
|
-
| `.body` | 17 pt regular | Main content |
|
|
114
|
-
| `.callout` | 16 pt regular | Secondary content |
|
|
115
|
-
| `.subheadline` | 15 pt regular | Supporting text |
|
|
116
|
-
| `.footnote` | 13 pt regular | Tertiary information |
|
|
117
|
-
| `.caption` | 12 pt regular | Labels |
|
|
118
|
-
| `.caption2` | 11 pt regular | Small labels |
|
|
119
|
-
|
|
120
|
-
A custom typeface keeps Dynamic Type when it is tied to a text style:
|
|
121
|
-
|
|
122
|
-
```swift
|
|
123
|
-
extension Font {
|
|
124
|
-
static func brandBody(_ family: String) -> Font {
|
|
125
|
-
.custom(family, size: 17, relativeTo: .body)
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## 3. HIG: Color
|
|
131
|
-
|
|
132
|
-
Semantic colors switch between light and dark appearance on their own.
|
|
133
|
-
|
|
134
|
-
| Role | Colors |
|
|
135
|
-
|---|---|
|
|
136
|
-
| Labels | `Color.primary`, `Color.secondary`, `Color(uiColor: .tertiaryLabel)` |
|
|
137
|
-
| Backgrounds | `Color(uiColor: .systemBackground)`, `.secondarySystemBackground`, `.systemGroupedBackground` |
|
|
138
|
-
| Fill and divider | `.systemFill` and `.separator`, both wrapped in `Color(uiColor:)` |
|
|
139
|
-
|
|
140
|
-
- Set the app tint once on the root view, for example `.tint(.indigo)`.
|
|
141
|
-
- Interactive elements use the tint through `.tint(...)` or
|
|
142
|
-
`.foregroundStyle(.tint)`.
|
|
143
|
-
- Destructive actions use red (`Color.red`, or a `.destructive` button role).
|
|
144
|
-
|
|
145
|
-
## 4. HIG: Navigation Chrome
|
|
146
|
-
|
|
147
|
-
Multi-column hierarchies on iPad and Mac use `NavigationSplitView`:
|
|
148
|
-
|
|
149
|
-
```swift
|
|
150
|
-
NavigationSplitView {
|
|
151
|
-
List(folders, selection: $selectedFolder) { folder in
|
|
152
|
-
NavigationLink(folder.name, value: folder)
|
|
153
|
-
}
|
|
154
|
-
.navigationTitle("Folders")
|
|
155
|
-
} detail: {
|
|
156
|
-
if let selectedFolder {
|
|
157
|
-
FolderDetail(folder: selectedFolder)
|
|
158
|
-
} else {
|
|
159
|
-
ContentUnavailableView("Pick a folder", systemImage: "sidebar.leading")
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
Tab-based apps give each tab its own `NavigationStack` inside a `TabView`. The
|
|
165
|
-
full tab patterns are in `swiftui-navigation`.
|
|
166
|
-
|
|
167
|
-
Toolbars (`.topBarLeading` and `.topBarTrailing` come with the iOS 17 SDK and
|
|
168
|
-
are back-deployed to iOS 14):
|
|
169
|
-
|
|
170
|
-
```swift
|
|
171
|
-
.toolbar {
|
|
172
|
-
ToolbarItem(placement: .topBarLeading) {
|
|
173
|
-
EditButton()
|
|
174
|
-
}
|
|
175
|
-
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
176
|
-
Button("Mark all read", systemImage: "envelope.open", action: markAllRead)
|
|
177
|
-
Button("New", systemImage: "plus", action: createItem)
|
|
178
|
-
}
|
|
179
|
-
ToolbarItemGroup(placement: .bottomBar) {
|
|
180
|
-
Button("Archive", systemImage: "archivebox", action: archiveSelection)
|
|
181
|
-
Spacer()
|
|
182
|
-
Text("\(messages.count) messages")
|
|
183
|
-
.font(.footnote)
|
|
184
|
-
.foregroundStyle(.secondary)
|
|
185
|
-
Spacer()
|
|
186
|
-
Button("Compose", systemImage: "square.and.pencil", action: compose)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
Search with scopes (`.searchScopes` needs iOS 16.4):
|
|
192
|
-
|
|
193
|
-
```swift
|
|
194
|
-
enum LibraryScope: String, CaseIterable {
|
|
195
|
-
case titles, authors, tags
|
|
196
|
-
var label: String { rawValue.localizedCapitalized }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
|
|
200
|
-
.searchScopes($scope) {
|
|
201
|
-
ForEach(LibraryScope.allCases, id: \.self) { scope in
|
|
202
|
-
Text(scope.label).tag(scope)
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
## 5. HIG: Feedback
|
|
208
|
-
|
|
209
|
-
For haptics that follow state, use `sensoryFeedback(_:trigger:)` (iOS 17+):
|
|
210
|
-
|
|
211
|
-
```swift
|
|
212
|
-
Button("Bookmark") { isBookmarked.toggle() }
|
|
213
|
-
.sensoryFeedback(.success, trigger: isBookmarked)
|
|
214
|
-
|
|
215
|
-
Picker("Order", selection: $ordering) {
|
|
216
|
-
ForEach(Ordering.allCases) { Text($0.title).tag($0) }
|
|
217
|
-
}
|
|
218
|
-
.sensoryFeedback(.selection, trigger: ordering)
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Reach for UIKit feedback generators only when the trigger is imperative and
|
|
222
|
-
comes from UIKit or other code outside SwiftUI views.
|
|
223
|
-
|
|
224
|
-
## 6. HIG: Accessibility
|
|
225
|
-
|
|
226
|
-
Combine a row into one VoiceOver element and describe it:
|
|
227
|
-
|
|
228
|
-
```swift
|
|
229
|
-
VStack(alignment: .leading) {
|
|
230
|
-
Text(course.title).font(.headline)
|
|
231
|
-
Text("\(course.score, specifier: "%.1f") out of 5")
|
|
232
|
-
}
|
|
233
|
-
.accessibilityElement(children: .combine)
|
|
234
|
-
.accessibilityLabel(course.title)
|
|
235
|
-
.accessibilityValue("Rated \(course.score, specifier: "%.1f") out of 5")
|
|
236
|
-
.accessibilityHint("Opens the course overview")
|
|
237
|
-
.accessibilityAddTraits(.isButton)
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
Switch layout for the accessibility text sizes:
|
|
241
|
-
|
|
242
|
-
```swift
|
|
243
|
-
@Environment(\.dynamicTypeSize) private var typeSize
|
|
244
|
-
|
|
245
|
-
var body: some View {
|
|
246
|
-
if typeSize.isAccessibilitySize {
|
|
247
|
-
VStack(alignment: .leading) { avatar; details }
|
|
248
|
-
} else {
|
|
249
|
-
HStack { avatar; details; Spacer() }
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
## 7. HIG: Error and Empty States
|
|
255
|
-
|
|
256
|
-
`ContentUnavailableView` covers both. The builder form adds actions:
|
|
257
|
-
|
|
258
|
-
```swift
|
|
259
|
-
ContentUnavailableView {
|
|
260
|
-
Label("Sync failed", systemImage: "icloud.slash")
|
|
261
|
-
} description: {
|
|
262
|
-
Text(error.localizedDescription)
|
|
263
|
-
} actions: {
|
|
264
|
-
Button("Retry") { Task { await resync() } }
|
|
265
|
-
.buttonStyle(.borderedProminent)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
ContentUnavailableView {
|
|
269
|
-
Label("No receipts", systemImage: "doc.text.viewfinder")
|
|
270
|
-
} description: {
|
|
271
|
-
Text("Scan a paper receipt to add it here.")
|
|
272
|
-
} actions: {
|
|
273
|
-
Button("Scan") { isScannerPresented = true }
|
|
274
|
-
.buttonStyle(.borderedProminent)
|
|
275
|
-
}
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
## 8. Theming and Dynamic Type
|
|
279
|
-
|
|
280
|
-
Goal: theming that grows with the app while view code stays semantic.
|
|
281
|
-
|
|
282
|
-
- One `Theme` object is the only source of colors, fonts and spacing.
|
|
283
|
-
- Install it at the app root; views read it with `@Environment(Theme.self)`.
|
|
284
|
-
- Name values by role (`primaryBackground`, `secondaryBackground`, `label`,
|
|
285
|
-
`tint`) rather than by hue.
|
|
286
|
-
- Put user-facing theme choices on a dedicated settings screen.
|
|
287
|
-
- Scaling with the user's text size comes from three places: built-in text
|
|
288
|
-
styles, a custom font tied to a style via `Font.custom(_:size:relativeTo:)`,
|
|
289
|
-
and `@ScaledMetric` for numeric layout values.
|
|
290
|
-
|
|
291
|
-
```swift
|
|
292
|
-
@MainActor @Observable
|
|
293
|
-
final class Theme {
|
|
294
|
-
var accent: Color = .teal
|
|
295
|
-
var primaryBackground: Color = .white
|
|
296
|
-
var secondaryBackground: Color = .secondary.opacity(0.12)
|
|
297
|
-
var label: Color = .primary
|
|
298
|
-
var textScale: Double = 1.0
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
@main
|
|
302
|
-
struct JournalApp: App {
|
|
303
|
-
@State private var theme = Theme()
|
|
304
|
-
|
|
305
|
-
var body: some Scene {
|
|
306
|
-
WindowGroup {
|
|
307
|
-
EntriesView().environment(theme)
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
struct EntryCard: View {
|
|
313
|
-
@Environment(Theme.self) private var currentTheme
|
|
314
|
-
let entry: Entry
|
|
315
|
-
|
|
316
|
-
var body: some View {
|
|
317
|
-
Text(entry.title)
|
|
318
|
-
.foregroundStyle(currentTheme.label)
|
|
319
|
-
.background(currentTheme.primaryBackground)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
Guidelines:
|
|
325
|
-
|
|
326
|
-
- Keep the set of theme values small and semantic; do not re-create system
|
|
327
|
-
colors.
|
|
328
|
-
- Persist theme choices the user makes, if the app offers them.
|
|
329
|
-
- Check contrast between every text color and its background.
|
|
330
|
-
|
|
331
|
-
Pitfalls:
|
|
332
|
-
|
|
333
|
-
- Raw `Color` literals spread through views.
|
|
334
|
-
- A theme stored in one view's local state.
|
|
335
|
-
- Treating `@Environment(\.colorScheme)` as the whole theming story. Light or
|
|
336
|
-
dark mode is one input to the theme, not a substitute for it.
|
|
337
|
-
|
|
338
|
-
## 9. Haptics
|
|
339
|
-
|
|
340
|
-
Use haptics for moments that matter (tab switch, refresh, success, failure) and
|
|
341
|
-
respect the user's settings.
|
|
342
|
-
|
|
343
|
-
- In SwiftUI views, prefer `sensoryFeedback(_:trigger:)`.
|
|
344
|
-
- Centralise imperative feedback in a manager only for UIKit interop or code
|
|
345
|
-
that is not a view.
|
|
346
|
-
- Check both the user preference and hardware support.
|
|
347
|
-
- Use different feedback types for different moments: selection, notification,
|
|
348
|
-
refresh.
|
|
349
|
-
- Core Haptics is the step up when a pattern needs shaping that the built-in
|
|
350
|
-
feedback types do not offer.
|
|
351
|
-
|
|
352
|
-
SwiftUI first:
|
|
353
|
-
|
|
354
|
-
```swift
|
|
355
|
-
@State private var uploadCount = 0
|
|
356
|
-
|
|
357
|
-
Button("Upload") {
|
|
358
|
-
uploadCount += 1
|
|
359
|
-
startUpload()
|
|
360
|
-
}
|
|
361
|
-
.sensoryFeedback(.success, trigger: uploadCount)
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
UIKit interop:
|
|
365
|
-
|
|
366
|
-
```swift
|
|
367
|
-
import UIKit
|
|
368
|
-
|
|
369
|
-
@MainActor
|
|
370
|
-
final class FeedbackPlayer {
|
|
371
|
-
static let shared = FeedbackPlayer()
|
|
372
|
-
|
|
373
|
-
enum Kind {
|
|
374
|
-
case tap
|
|
375
|
-
case tabChange
|
|
376
|
-
case refresh(strength: CGFloat)
|
|
377
|
-
case result(UINotificationFeedbackGenerator.FeedbackType)
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
private let selection = UISelectionFeedbackGenerator()
|
|
381
|
-
private let impact = UIImpactFeedbackGenerator(style: .heavy)
|
|
382
|
-
private let notification = UINotificationFeedbackGenerator()
|
|
383
|
-
|
|
384
|
-
private init() { selection.prepare() }
|
|
385
|
-
|
|
386
|
-
func play(_ kind: Kind, allowed: Bool) {
|
|
387
|
-
guard allowed else { return }
|
|
388
|
-
switch kind {
|
|
389
|
-
case .tap: impact.impactOccurred()
|
|
390
|
-
case .tabChange: selection.selectionChanged()
|
|
391
|
-
case .refresh(let strength): impact.impactOccurred(intensity: strength)
|
|
392
|
-
case .result(let type): notification.notificationOccurred(type)
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
```swift
|
|
399
|
-
@AppStorage("hapticsOn") private var hapticsOn = true
|
|
400
|
-
|
|
401
|
-
Button("Pay") {
|
|
402
|
-
FeedbackPlayer.shared.play(.result(.success), allowed: hapticsOn)
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
TabView(selection: $tab) { tabs }
|
|
406
|
-
.onChange(of: tab) {
|
|
407
|
-
FeedbackPlayer.shared.play(.tabChange, allowed: hapticsOn)
|
|
408
|
-
}
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
Guidelines:
|
|
412
|
-
|
|
413
|
-
- Keep feedback subtle and skip it for minor interactions.
|
|
414
|
-
- Offer a setting that turns haptics off.
|
|
415
|
-
- Trigger feedback where the user acts, not from the data layer.
|
|
416
|
-
|
|
417
|
-
Pitfalls:
|
|
418
|
-
|
|
419
|
-
- Several haptics fired back to back.
|
|
420
|
-
- Assuming the device has a haptic engine.
|
|
421
|
-
|
|
422
|
-
## 10. Core Haptics
|
|
423
|
-
|
|
424
|
-
Core Haptics controls intensity, sharpness, timing and audio sync precisely,
|
|
425
|
-
for patterns the simple generators cannot produce. Apple docs:
|
|
426
|
-
[CHHapticEngine](https://developer.apple.com/documentation/corehaptics/chhapticengine),
|
|
427
|
-
and the [Core Haptics](https://developer.apple.com/documentation/corehaptics)
|
|
428
|
-
topic page, whose "Essentials" group holds the article on preparing an app to
|
|
429
|
-
play haptics.
|
|
430
|
-
|
|
431
|
-
### Engine lifecycle
|
|
432
|
-
|
|
433
|
-
Check the hardware before creating an engine.
|
|
434
|
-
|
|
435
|
-
```swift
|
|
436
|
-
import CoreHaptics
|
|
437
|
-
import OSLog
|
|
438
|
-
|
|
439
|
-
extension Logger {
|
|
440
|
-
static let haptics = Logger(subsystem: "app.haptics", category: "engine")
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
@MainActor
|
|
444
|
-
final class PatternPlayer {
|
|
445
|
-
private var engine: CHHapticEngine?
|
|
446
|
-
|
|
447
|
-
var canPlayHaptics: Bool {
|
|
448
|
-
CHHapticEngine.capabilitiesForHardware().supportsHaptics
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
var canPlayHapticAudio: Bool {
|
|
452
|
-
CHHapticEngine.capabilitiesForHardware().supportsAudio
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
func startEngine() throws {
|
|
456
|
-
guard canPlayHaptics else { return }
|
|
457
|
-
let engine = try CHHapticEngine()
|
|
458
|
-
engine.stoppedHandler = { @Sendable reason in
|
|
459
|
-
Logger.haptics.info("Engine stopped, reason \(reason.rawValue)")
|
|
460
|
-
}
|
|
461
|
-
engine.resetHandler = { @Sendable [weak self] in
|
|
462
|
-
Task { @MainActor in try? self?.engine?.start() }
|
|
463
|
-
}
|
|
464
|
-
try engine.start()
|
|
465
|
-
self.engine = engine
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
func stopEngine() {
|
|
469
|
-
engine?.stop()
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
- `stoppedHandler` runs when something outside the app stops the engine, such
|
|
475
|
-
as an audio session interruption or the app moving to the background.
|
|
476
|
-
- Both handlers are called on a queue owned by the engine, so they are
|
|
477
|
-
`@Sendable`; any state change is scheduled onto the main actor first.
|
|
478
|
-
- `resetHandler` runs after the system resets the engine, for instance once an
|
|
479
|
-
audio interruption is over. Start the engine again there.
|
|
480
|
-
- Call `start()` before playing any pattern.
|
|
481
|
-
- Once the screen no longer plays patterns, `stop()` the engine to save power.
|
|
482
|
-
|
|
483
|
-
### A single tap
|
|
484
|
-
|
|
485
|
-
```swift
|
|
486
|
-
func playTap() throws {
|
|
487
|
-
let crisp = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.75)
|
|
488
|
-
let strong = CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.95)
|
|
489
|
-
let tap = CHHapticEvent(eventType: .hapticTransient,
|
|
490
|
-
parameters: [crisp, strong],
|
|
491
|
-
relativeTime: 0)
|
|
492
|
-
let pattern = try CHHapticPattern(events: [tap], parameters: [])
|
|
493
|
-
try engine?.makePlayer(with: pattern).start(atTime: CHHapticTimeImmediate)
|
|
494
|
-
}
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
### Event types and parameters
|
|
498
|
-
|
|
499
|
-
| Event type | Effect |
|
|
500
|
-
|---|---|
|
|
501
|
-
| `.hapticTransient` | Short impulse, like a tap |
|
|
502
|
-
| `.hapticContinuous` | Sustained vibration for a `duration` |
|
|
503
|
-
| `.audioContinuous` | Sustained audio tone |
|
|
504
|
-
| `.audioCustom` | Plays an audio resource you register |
|
|
505
|
-
|
|
506
|
-
Common parameters: `.hapticIntensity` (0 to 1), `.hapticSharpness` (0 to 1),
|
|
507
|
-
`.attackTime`, `.decayTime`, `.releaseTime`.
|
|
508
|
-
|
|
509
|
-
```swift
|
|
510
|
-
func playHum() throws {
|
|
511
|
-
let hum = CHHapticEvent(
|
|
512
|
-
eventType: .hapticContinuous,
|
|
513
|
-
parameters: [
|
|
514
|
-
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.55),
|
|
515
|
-
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.35)
|
|
516
|
-
],
|
|
517
|
-
relativeTime: 0,
|
|
518
|
-
duration: 0.5
|
|
519
|
-
)
|
|
520
|
-
let humPattern = try CHHapticPattern(events: [hum], parameters: [])
|
|
521
|
-
try engine?.makePlayer(with: humPattern).start(atTime: CHHapticTimeImmediate)
|
|
522
|
-
}
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
Looping, seeking and pausing need a `CHHapticAdvancedPatternPlayer` from
|
|
526
|
-
`engine.makeAdvancedPlayer(with:)`.
|
|
527
|
-
|
|
528
|
-
### Parameter curves
|
|
529
|
-
|
|
530
|
-
A `CHHapticParameterCurve` changes a parameter smoothly while the pattern
|
|
531
|
-
plays:
|
|
532
|
-
|
|
533
|
-
```swift
|
|
534
|
-
func playSwell() throws {
|
|
535
|
-
let base = CHHapticEvent(
|
|
536
|
-
eventType: .hapticContinuous,
|
|
537
|
-
parameters: [
|
|
538
|
-
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.25),
|
|
539
|
-
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.15)
|
|
540
|
-
],
|
|
541
|
-
relativeTime: 0,
|
|
542
|
-
duration: 1.0
|
|
543
|
-
)
|
|
544
|
-
let swell = CHHapticParameterCurve(
|
|
545
|
-
parameterID: .hapticIntensityControl,
|
|
546
|
-
controlPoints: [
|
|
547
|
-
CHHapticParameterCurve.ControlPoint(relativeTime: 0, value: 0.25),
|
|
548
|
-
CHHapticParameterCurve.ControlPoint(relativeTime: 0.4, value: 0.6),
|
|
549
|
-
CHHapticParameterCurve.ControlPoint(relativeTime: 0.9, value: 1)
|
|
550
|
-
],
|
|
551
|
-
relativeTime: 0
|
|
552
|
-
)
|
|
553
|
-
let pattern = try CHHapticPattern(events: [base], parameterCurves: [swell])
|
|
554
|
-
try engine?.makePlayer(with: pattern).start(atTime: CHHapticTimeImmediate)
|
|
555
|
-
}
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
### AHAP files
|
|
559
|
-
|
|
560
|
-
Patterns can also live outside code, in an AHAP file: a JSON document you
|
|
561
|
-
tune without recompiling.
|
|
562
|
-
|
|
563
|
-
```swift
|
|
564
|
-
func playFromFile() throws {
|
|
565
|
-
guard let patternURL = Bundle.main.url(forResource: "checkout", withExtension: "ahap") else {
|
|
566
|
-
return
|
|
567
|
-
}
|
|
568
|
-
try engine?.playPattern(from: patternURL)
|
|
569
|
-
}
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
Everything the code API expresses (events, parameters, curves) can be written
|
|
573
|
-
in AHAP too, and Xcode can play the file back for you while you iterate. File
|
|
574
|
-
format reference: the AHAP article listed on the
|
|
575
|
-
[Core Haptics](https://developer.apple.com/documentation/corehaptics) topic page.
|
|
576
|
-
|
|
577
|
-
## 11. Matched Transitions
|
|
578
|
-
|
|
579
|
-
Goal: carry the eye from a source view (thumbnail, avatar) to its destination
|
|
580
|
-
(sheet, detail, full-screen viewer).
|
|
581
|
-
|
|
582
|
-
- Share a `Namespace` between source and destination, and give the source a
|
|
583
|
-
stable id.
|
|
584
|
-
- Across screens, use `matchedTransitionSource(id:in:)` with
|
|
585
|
-
`navigationTransition(.zoom(sourceID:in:))`. Both need iOS 18.
|
|
586
|
-
- Inside one hierarchy, use `matchedGeometryEffect(id:in:)`.
|
|
587
|
-
- Ids must stay the same across updates; never generate random UUIDs for them.
|
|
588
|
-
|
|
589
|
-
```swift
|
|
590
|
-
struct GalleryGrid: View {
|
|
591
|
-
@Namespace private var zoom
|
|
592
|
-
@State private var opened: Photo?
|
|
593
|
-
let photos: [Photo]
|
|
594
|
-
|
|
595
|
-
var body: some View {
|
|
596
|
-
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))]) {
|
|
597
|
-
ForEach(photos) { photo in
|
|
598
|
-
Button { opened = photo } label: {
|
|
599
|
-
PhotoThumbnail(photo: photo)
|
|
600
|
-
}
|
|
601
|
-
.matchedTransitionSource(id: photo.id, in: zoom)
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
.sheet(item: $opened) { photo in
|
|
605
|
-
PhotoViewer(photo: photo)
|
|
606
|
-
.navigationTransition(.zoom(sourceID: photo.id, in: zoom))
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
In-place change within one view:
|
|
613
|
-
|
|
614
|
-
```swift
|
|
615
|
-
struct LockToggle: View {
|
|
616
|
-
@Namespace private var space
|
|
617
|
-
@State private var isLocked = false
|
|
618
|
-
|
|
619
|
-
var body: some View {
|
|
620
|
-
Button {
|
|
621
|
-
withAnimation(.spring) { isLocked.toggle() }
|
|
622
|
-
} label: {
|
|
623
|
-
Image(systemName: isLocked ? "lock" : "lock.open")
|
|
624
|
-
.matchedGeometryEffect(id: "lockGlyph", in: space)
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
Guidelines:
|
|
631
|
-
|
|
632
|
-
- Prefer `matchedTransitionSource` for transitions between screens.
|
|
633
|
-
- Keep source and destination sizes close so the scale change is not jarring.
|
|
634
|
-
- Drive state-based transitions with `withAnimation`.
|
|
635
|
-
|
|
636
|
-
Pitfalls:
|
|
637
|
-
|
|
638
|
-
- Ids that change between updates break the transition.
|
|
639
|
-
- Shapes that do not match (a square into a circle) look wrong unless the
|
|
640
|
-
design asks for it.
|
|
641
|
-
|
|
642
|
-
## 12. Loading and Placeholders
|
|
643
|
-
|
|
644
|
-
Use this for loading states (skeletons, redaction, empty state) that stay
|
|
645
|
-
consistent and never block interaction.
|
|
646
|
-
|
|
647
|
-
- For lists and detail content, prefer redacted placeholders; the layout stays
|
|
648
|
-
in place while data arrives.
|
|
649
|
-
- Once loading ends with nothing to show, or with an error, show
|
|
650
|
-
`ContentUnavailableView`.
|
|
651
|
-
- Keep `ProgressView` for short, global operations, and use it sparingly on
|
|
652
|
-
content-heavy screens.
|
|
653
|
-
|
|
654
|
-
Steps:
|
|
655
|
-
|
|
656
|
-
1. Render the real layout with placeholder data and apply
|
|
657
|
-
`.redacted(reason: .placeholder)`.
|
|
658
|
-
2. A list gets a small, fixed set of skeleton rows, never a spinner that runs
|
|
659
|
-
with no end in sight.
|
|
660
|
-
3. When loading finishes with no data, switch to `ContentUnavailableView`.
|
|
661
|
-
|
|
662
|
-
```swift
|
|
663
|
-
List {
|
|
664
|
-
if isFetching {
|
|
665
|
-
ForEach(0..<4, id: \.self) { _ in
|
|
666
|
-
InvoiceRow(invoice: .sample)
|
|
667
|
-
.redacted(reason: .placeholder)
|
|
668
|
-
}
|
|
669
|
-
} else if invoices.isEmpty {
|
|
670
|
-
ContentUnavailableView("No invoices", systemImage: "tray")
|
|
671
|
-
} else {
|
|
672
|
-
ForEach(invoices) { InvoiceRow(invoice: $0) }
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
Pitfalls and limits:
|
|
678
|
-
|
|
679
|
-
- Layout that shifts or animates while redacted. Keep frames stable.
|
|
680
|
-
- Spinners nested inside spinners. One indicator per section.
|
|
681
|
-
- More than three to six placeholder rows, which costs frames on older
|
|
682
|
-
devices.
|
|
683
|
-
|
|
684
|
-
## 13. Form Focus
|
|
685
|
-
|
|
686
|
-
This section covers keyboard focus in forms. Directional focus, focus sections
|
|
687
|
-
and focus on tvOS, macOS and iPadOS keyboards are covered in `hig-inputs`;
|
|
688
|
-
VoiceOver and other assistive focus is covered in `ios-accessibility`.
|
|
689
|
-
|
|
690
|
-
`@FocusState` sets keyboard focus, moves it from field to field, and
|
|
691
|
-
coordinates it across a complex form.
|
|
692
|
-
|
|
693
|
-
- Describe the focusable fields with an enum.
|
|
694
|
-
- Set the first focus in `onAppear`.
|
|
695
|
-
- Move to the following field in `.onSubmit`.
|
|
696
|
-
- For a variable number of fields, use an enum case with an associated value,
|
|
697
|
-
such as `.answer(Int)`.
|
|
698
|
-
|
|
699
|
-
One field:
|
|
700
|
-
|
|
701
|
-
```swift
|
|
702
|
-
struct HostEntryForm: View {
|
|
703
|
-
@State private var host = ""
|
|
704
|
-
@FocusState private var hostFocused: Bool
|
|
705
|
-
|
|
706
|
-
var body: some View {
|
|
707
|
-
Form {
|
|
708
|
-
TextField("Host", text: $host)
|
|
709
|
-
.focused($hostFocused)
|
|
710
|
-
}
|
|
711
|
-
.onAppear { hostFocused = true }
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
```
|
|
715
|
-
|
|
716
|
-
Chained fields:
|
|
717
|
-
|
|
718
|
-
```swift
|
|
719
|
-
struct ContactForm: View {
|
|
720
|
-
enum Field { case name, phone, note }
|
|
721
|
-
|
|
722
|
-
@State private var name = ""
|
|
723
|
-
@State private var phone = ""
|
|
724
|
-
@State private var note = ""
|
|
725
|
-
@FocusState private var focus: Field?
|
|
726
|
-
|
|
727
|
-
var body: some View {
|
|
728
|
-
Form {
|
|
729
|
-
TextField("Name", text: $name)
|
|
730
|
-
.focused($focus, equals: .name)
|
|
731
|
-
.onSubmit { focus = .phone }
|
|
732
|
-
TextField("Phone", text: $phone)
|
|
733
|
-
.focused($focus, equals: .phone)
|
|
734
|
-
.onSubmit { focus = .note }
|
|
735
|
-
TextField("Note", text: $note)
|
|
736
|
-
.focused($focus, equals: .note)
|
|
737
|
-
}
|
|
738
|
-
.onAppear { focus = .name }
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
A growing list of fields:
|
|
744
|
-
|
|
745
|
-
```swift
|
|
746
|
-
struct SurveyAnswers: View {
|
|
747
|
-
enum Field: Hashable { case answer(Int) }
|
|
748
|
-
|
|
749
|
-
@State private var answers = [""]
|
|
750
|
-
@FocusState private var focus: Field?
|
|
751
|
-
|
|
752
|
-
var body: some View {
|
|
753
|
-
Form {
|
|
754
|
-
ForEach(answers.indices, id: \.self) { index in
|
|
755
|
-
TextField("Answer \(index + 1)", text: $answers[index])
|
|
756
|
-
.focused($focus, equals: .answer(index))
|
|
757
|
-
.onSubmit { addAnswer(after: index) }
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
private func addAnswer(after index: Int) {
|
|
763
|
-
answers.append("")
|
|
764
|
-
let next = answers.count - 1
|
|
765
|
-
Task { @MainActor in
|
|
766
|
-
try? await Task.sleep(for: Duration.milliseconds(15))
|
|
767
|
-
focus = .answer(next)
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
The short sleep lets SwiftUI insert the new field before focus moves to it.
|
|
774
|
-
|
|
775
|
-
Guidelines:
|
|
776
|
-
|
|
777
|
-
- The view holding the text fields also holds their `@FocusState`.
|
|
778
|
-
- React to focus moving: show a validation message or a hint for the field
|
|
779
|
-
that just gained or lost focus.
|
|
780
|
-
- Inside a `ScrollView` or `Form`, pair focus with `.scrollDismissesKeyboard(_:)`.
|
|
781
|
-
|
|
782
|
-
Pitfalls:
|
|
783
|
-
|
|
784
|
-
- Focus state stored in shared objects; it is local to the view.
|
|
785
|
-
- Focus changed aggressively in the middle of an animation. Delay it when
|
|
786
|
-
needed.
|