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,394 @@
|
|
|
1
|
+
# SwiftUI List Patterns Reference
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
|
|
5
|
+
- [ForEach Identity and Stability](#foreach-identity-and-stability)
|
|
6
|
+
- [Enumerated Sequences](#enumerated-sequences)
|
|
7
|
+
- [List with Custom Styling](#list-with-custom-styling)
|
|
8
|
+
- [List with Pull-to-Refresh](#list-with-pull-to-refresh)
|
|
9
|
+
- [Empty States with ContentUnavailableView (iOS 17+)](#empty-states-with-contentunavailableview-ios-17)
|
|
10
|
+
- [Custom List Backgrounds](#custom-list-backgrounds)
|
|
11
|
+
- [Table](#table)
|
|
12
|
+
- [Summary Checklist](#summary-checklist)
|
|
13
|
+
|
|
14
|
+
## ForEach Identity and Stability
|
|
15
|
+
|
|
16
|
+
**Always provide stable identity for `ForEach`.** Never use `.indices` for dynamic content.
|
|
17
|
+
|
|
18
|
+
```swift
|
|
19
|
+
// Good - stable identity via Identifiable
|
|
20
|
+
extension User: Identifiable {
|
|
21
|
+
var id: String { userId }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ForEach(users) { user in
|
|
25
|
+
UserRow(user: user)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Good - stable identity via keypath
|
|
29
|
+
ForEach(users, id: \.userId) { user in
|
|
30
|
+
UserRow(user: user)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Wrong - indices create static content
|
|
34
|
+
ForEach(users.indices, id: \.self) { index in
|
|
35
|
+
UserRow(user: users[index]) // Can crash on removal!
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Wrong - unstable identity
|
|
39
|
+
ForEach(users, id: \.self) { user in
|
|
40
|
+
UserRow(user: user) // Only works if User is Hashable and stable
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Critical**: Ensure **constant number of views per element** in `ForEach`:
|
|
45
|
+
|
|
46
|
+
```swift
|
|
47
|
+
// Good - consistent view count
|
|
48
|
+
ForEach(items) { item in
|
|
49
|
+
ItemRow(item: item)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Bad - variable view count breaks identity
|
|
53
|
+
ForEach(items) { item in
|
|
54
|
+
if item.isSpecial {
|
|
55
|
+
SpecialRow(item: item)
|
|
56
|
+
DetailRow(item: item)
|
|
57
|
+
} else {
|
|
58
|
+
RegularRow(item: item)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Avoid inline filtering:**
|
|
64
|
+
|
|
65
|
+
```swift
|
|
66
|
+
// Bad - unstable identity, changes on every update
|
|
67
|
+
ForEach(items.filter { $0.isEnabled }) { item in
|
|
68
|
+
ItemRow(item: item)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Good - prefilter and cache
|
|
72
|
+
@State private var enabledItems: [Item] = []
|
|
73
|
+
|
|
74
|
+
var body: some View {
|
|
75
|
+
ForEach(enabledItems) { item in
|
|
76
|
+
ItemRow(item: item)
|
|
77
|
+
}
|
|
78
|
+
.onChange(of: items) { _, newItems in
|
|
79
|
+
enabledItems = newItems.filter { $0.isEnabled }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Avoid `AnyView` in list rows:**
|
|
85
|
+
|
|
86
|
+
```swift
|
|
87
|
+
// Bad - hides identity, increases cost
|
|
88
|
+
ForEach(items) { item in
|
|
89
|
+
AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Good - Create a unified row view
|
|
93
|
+
ForEach(items) { item in
|
|
94
|
+
ItemRow(item: item)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
struct ItemRow: View {
|
|
98
|
+
let item: Item
|
|
99
|
+
|
|
100
|
+
var body: some View {
|
|
101
|
+
if item.isSpecial {
|
|
102
|
+
SpecialRow(item: item)
|
|
103
|
+
} else {
|
|
104
|
+
RegularRow(item: item)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Why**: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes.
|
|
111
|
+
|
|
112
|
+
### Identifiable ID Must Be Truly Unique
|
|
113
|
+
|
|
114
|
+
Non-unique IDs cause SwiftUI to treat different items as identical, leading to duplicate rendering or missing views:
|
|
115
|
+
|
|
116
|
+
```swift
|
|
117
|
+
// Bug -- two articles with the same URL show identical content
|
|
118
|
+
struct Article: Identifiable {
|
|
119
|
+
let title: String
|
|
120
|
+
let url: URL
|
|
121
|
+
var id: String { url.absoluteString } // Not unique if URLs repeat!
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fix -- use a genuinely unique identifier
|
|
125
|
+
struct Article: Identifiable {
|
|
126
|
+
let id: UUID
|
|
127
|
+
let title: String
|
|
128
|
+
let url: URL
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Classes get a default `ObjectIdentifier`-based `id`** when conforming to `Identifiable` without providing one. This is only unique for the object's lifetime and can be recycled after deallocation.
|
|
133
|
+
|
|
134
|
+
## Enumerated Sequences
|
|
135
|
+
|
|
136
|
+
**Always convert enumerated sequences to arrays. To be able to use them in a ForEach.**
|
|
137
|
+
|
|
138
|
+
```swift
|
|
139
|
+
let items = ["A", "B", "C"]
|
|
140
|
+
|
|
141
|
+
// Correct
|
|
142
|
+
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
|
|
143
|
+
Text("\(index): \(item)")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Wrong - Doesn't compile, enumerated() isn't an array
|
|
147
|
+
ForEach(items.enumerated(), id: \.offset) { index, item in
|
|
148
|
+
Text("\(index): \(item)")
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## List with Custom Styling
|
|
153
|
+
|
|
154
|
+
```swift
|
|
155
|
+
// Remove default background and separators
|
|
156
|
+
List(items) { item in
|
|
157
|
+
ItemRow(item: item)
|
|
158
|
+
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
|
159
|
+
.listRowSeparator(.hidden)
|
|
160
|
+
}
|
|
161
|
+
.listStyle(.plain)
|
|
162
|
+
.scrollContentBackground(.hidden)
|
|
163
|
+
.background(Color.customBackground)
|
|
164
|
+
.environment(\.defaultMinListRowHeight, 1) // Allows custom row heights
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## List with Pull-to-Refresh
|
|
168
|
+
|
|
169
|
+
```swift
|
|
170
|
+
List(items) { item in
|
|
171
|
+
ItemRow(item: item)
|
|
172
|
+
}
|
|
173
|
+
.refreshable {
|
|
174
|
+
await loadItems()
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Empty States with ContentUnavailableView (iOS 17+)
|
|
179
|
+
|
|
180
|
+
Use `ContentUnavailableView` for empty list/search states. The built-in `.search` variant is auto-localized:
|
|
181
|
+
|
|
182
|
+
```swift
|
|
183
|
+
List {
|
|
184
|
+
ForEach(searchResults) { item in
|
|
185
|
+
ItemRow(item: item)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
.overlay {
|
|
189
|
+
if searchResults.isEmpty, !searchText.isEmpty {
|
|
190
|
+
ContentUnavailableView.search(text: searchText)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
For non-search empty states, use a custom instance:
|
|
196
|
+
|
|
197
|
+
```swift
|
|
198
|
+
ContentUnavailableView(
|
|
199
|
+
"No Articles",
|
|
200
|
+
systemImage: "doc.richtext.fill",
|
|
201
|
+
description: Text("Articles you save will appear here.")
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Custom List Backgrounds
|
|
206
|
+
|
|
207
|
+
Use `.scrollContentBackground(.hidden)` to replace the default list background:
|
|
208
|
+
|
|
209
|
+
```swift
|
|
210
|
+
List(items) { item in
|
|
211
|
+
ItemRow(item: item)
|
|
212
|
+
}
|
|
213
|
+
.scrollContentBackground(.hidden)
|
|
214
|
+
.background(Color.customBackground)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Without `.scrollContentBackground(.hidden)`, a custom `.background()` has no visible effect on `List`.
|
|
218
|
+
|
|
219
|
+
## Table
|
|
220
|
+
|
|
221
|
+
> **Availability:** iOS 16.0+, iPadOS 16.0+, visionOS 1.0+
|
|
222
|
+
|
|
223
|
+
A multi-column data container that presents rows of `Identifiable` data with sortable, selectable columns. On compact size classes (iPhone, iPad Slide Over), columns after the first are automatically hidden.
|
|
224
|
+
|
|
225
|
+
### Basic Table
|
|
226
|
+
|
|
227
|
+
```swift
|
|
228
|
+
struct Person: Identifiable {
|
|
229
|
+
let givenName: String
|
|
230
|
+
let familyName: String
|
|
231
|
+
let emailAddress: String
|
|
232
|
+
let id = UUID()
|
|
233
|
+
var fullName: String { givenName + " " + familyName }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
struct PeopleTable: View {
|
|
237
|
+
@State private var people: [Person] = [ /* ... */ ]
|
|
238
|
+
|
|
239
|
+
var body: some View {
|
|
240
|
+
Table(people) {
|
|
241
|
+
TableColumn("Given Name", value: \.givenName)
|
|
242
|
+
TableColumn("Family Name", value: \.familyName)
|
|
243
|
+
TableColumn("E-Mail Address", value: \.emailAddress)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Table with Selection
|
|
250
|
+
|
|
251
|
+
Bind to a single `ID` for single-selection, or a `Set<ID>` for multi-selection:
|
|
252
|
+
|
|
253
|
+
```swift
|
|
254
|
+
struct SelectableTable: View {
|
|
255
|
+
@State private var people: [Person] = [ /* ... */ ]
|
|
256
|
+
@State private var selectedPeople = Set<Person.ID>()
|
|
257
|
+
|
|
258
|
+
var body: some View {
|
|
259
|
+
Table(people, selection: $selectedPeople) {
|
|
260
|
+
TableColumn("Given Name", value: \.givenName)
|
|
261
|
+
TableColumn("Family Name", value: \.familyName)
|
|
262
|
+
TableColumn("E-Mail Address", value: \.emailAddress)
|
|
263
|
+
}
|
|
264
|
+
Text("\(selectedPeople.count) people selected")
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Sortable Table
|
|
270
|
+
|
|
271
|
+
Provide a binding to `[KeyPathComparator]` and re-sort the data in `.onChange(of:)`:
|
|
272
|
+
|
|
273
|
+
```swift
|
|
274
|
+
struct SortableTable: View {
|
|
275
|
+
@State private var people: [Person] = [ /* ... */ ]
|
|
276
|
+
@State private var sortOrder = [KeyPathComparator(\Person.givenName)]
|
|
277
|
+
|
|
278
|
+
var body: some View {
|
|
279
|
+
Table(people, sortOrder: $sortOrder) {
|
|
280
|
+
TableColumn("Given Name", value: \.givenName)
|
|
281
|
+
TableColumn("Family Name", value: \.familyName)
|
|
282
|
+
TableColumn("E-Mail Address", value: \.emailAddress)
|
|
283
|
+
}
|
|
284
|
+
.onChange(of: sortOrder) { _, newOrder in
|
|
285
|
+
people.sort(using: newOrder)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Important:** The table does **not** sort data itself — you must re-sort the collection when `sortOrder` changes.
|
|
292
|
+
|
|
293
|
+
### Adaptive Table for Compact Size Classes
|
|
294
|
+
|
|
295
|
+
On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information:
|
|
296
|
+
|
|
297
|
+
```swift
|
|
298
|
+
struct AdaptiveTable: View {
|
|
299
|
+
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
300
|
+
private var isCompact: Bool { horizontalSizeClass == .compact }
|
|
301
|
+
|
|
302
|
+
@State private var people: [Person] = [ /* ... */ ]
|
|
303
|
+
@State private var sortOrder = [KeyPathComparator(\Person.givenName)]
|
|
304
|
+
|
|
305
|
+
var body: some View {
|
|
306
|
+
Table(people, sortOrder: $sortOrder) {
|
|
307
|
+
TableColumn("Given Name", value: \.givenName) { person in
|
|
308
|
+
VStack(alignment: .leading) {
|
|
309
|
+
Text(isCompact ? person.fullName : person.givenName)
|
|
310
|
+
if isCompact {
|
|
311
|
+
Text(person.emailAddress)
|
|
312
|
+
.foregroundStyle(.secondary)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
TableColumn("Family Name", value: \.familyName)
|
|
317
|
+
TableColumn("E-Mail Address", value: \.emailAddress)
|
|
318
|
+
}
|
|
319
|
+
.onChange(of: sortOrder) { _, newOrder in
|
|
320
|
+
people.sort(using: newOrder)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Table with Static Rows
|
|
327
|
+
|
|
328
|
+
Use `init(of:columns:rows:)` when rows are known at compile time:
|
|
329
|
+
|
|
330
|
+
```swift
|
|
331
|
+
struct Purchase: Identifiable {
|
|
332
|
+
let price: Decimal
|
|
333
|
+
let id = UUID()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
struct TipTable: View {
|
|
337
|
+
let currencyStyle = Decimal.FormatStyle.Currency(code: "USD")
|
|
338
|
+
|
|
339
|
+
var body: some View {
|
|
340
|
+
Table(of: Purchase.self) {
|
|
341
|
+
TableColumn("Base price") { purchase in
|
|
342
|
+
Text(purchase.price, format: currencyStyle)
|
|
343
|
+
}
|
|
344
|
+
TableColumn("With 15% tip") { purchase in
|
|
345
|
+
Text(purchase.price * 1.15, format: currencyStyle)
|
|
346
|
+
}
|
|
347
|
+
TableColumn("With 20% tip") { purchase in
|
|
348
|
+
Text(purchase.price * 1.2, format: currencyStyle)
|
|
349
|
+
}
|
|
350
|
+
} rows: {
|
|
351
|
+
TableRow(Purchase(price: 20))
|
|
352
|
+
TableRow(Purchase(price: 50))
|
|
353
|
+
TableRow(Purchase(price: 75))
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Table Styles
|
|
360
|
+
|
|
361
|
+
```swift
|
|
362
|
+
// Inset (no borders)
|
|
363
|
+
Table(people) { /* columns */ }
|
|
364
|
+
.tableStyle(.inset)
|
|
365
|
+
|
|
366
|
+
// Hide column headers
|
|
367
|
+
Table(people) { /* columns */ }
|
|
368
|
+
.tableColumnHeaders(.hidden)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Platform Behavior
|
|
372
|
+
|
|
373
|
+
| Platform | Behavior |
|
|
374
|
+
|----------|----------|
|
|
375
|
+
| **iPadOS (regular)** | Full multi-column layout; headers and all columns visible |
|
|
376
|
+
| **iPadOS (compact)** | Only the first column shown; headers hidden |
|
|
377
|
+
| **iPhone (all sizes)** | Only the first column shown; headers hidden; list-like appearance |
|
|
378
|
+
|
|
379
|
+
> **Best Practice:** Prefer handling the compact size class by showing combined info in the first column. This provides a seamless transition when the size class changes (e.g., entering/exiting Slide Over on iPad).
|
|
380
|
+
|
|
381
|
+
## Summary Checklist
|
|
382
|
+
|
|
383
|
+
- [ ] ForEach uses stable identity (never `.indices` for dynamic content)
|
|
384
|
+
- [ ] Identifiable IDs are truly unique across all items
|
|
385
|
+
- [ ] Constant number of views per ForEach element
|
|
386
|
+
- [ ] No inline filtering in ForEach (prefilter and cache instead)
|
|
387
|
+
- [ ] No `AnyView` in list rows
|
|
388
|
+
- [ ] Don't convert enumerated sequences to arrays
|
|
389
|
+
- [ ] Use `.refreshable` for pull-to-refresh
|
|
390
|
+
- [ ] Use `ContentUnavailableView` for empty states (iOS 17+)
|
|
391
|
+
- [ ] Use `.scrollContentBackground(.hidden)` for custom list backgrounds
|
|
392
|
+
- [ ] `Table` adapts for compact size classes (first column shows combined info)
|
|
393
|
+
- [ ] `Table` sorting re-sorts data in `.onChange(of: sortOrder)` (table doesn't sort itself)
|
|
394
|
+
- [ ] `Table` data conforms to `Identifiable`
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# macOS Scenes Reference
|
|
2
|
+
|
|
3
|
+
> SwiftUI scene types for macOS apps — `Settings`, `MenuBarExtra`, `WindowGroup`, `Window`, `UtilityWindow`, and `DocumentGroup`. Covers macOS-only scenes and cross-platform scenes with macOS-specific behavior.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Quick Lookup Table](#quick-lookup-table)
|
|
8
|
+
- [Settings (macOS-only)](#settings-macos-only)
|
|
9
|
+
- [MenuBarExtra (macOS-only)](#menubarextra-macos-only)
|
|
10
|
+
- [WindowGroup (macOS behavior)](#windowgroup-macos-behavior)
|
|
11
|
+
- [Window](#window)
|
|
12
|
+
- [UtilityWindow (macOS-only)](#utilitywindow-macos-only)
|
|
13
|
+
- [DocumentGroup](#documentgroup)
|
|
14
|
+
- [Platform Conditionals](#platform-conditionals)
|
|
15
|
+
- [Best Practices](#best-practices)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quick Lookup Table
|
|
20
|
+
|
|
21
|
+
| API | Availability | macOS-Only? | macOS-Specific Behavior |
|
|
22
|
+
|-----|-------------|:-----------:|------------------------|
|
|
23
|
+
| `WindowGroup` | macOS 11.0+ | No | Multiple window instances, tabbed interface, automatic Window menu commands |
|
|
24
|
+
| `Window` | macOS 13.0+ | No | App quits when sole window closes; adds itself to Windows menu |
|
|
25
|
+
| `UtilityWindow` | macOS 15.0+ | Yes | Floating tool palette; receives `FocusedValues` from active main window |
|
|
26
|
+
| `Settings` | macOS 11.0+ | Yes | Presents preferences window (Cmd+,) |
|
|
27
|
+
| `MenuBarExtra` | macOS 13.0+ | Yes | Persistent icon/menu in the system menu bar |
|
|
28
|
+
| `DocumentGroup` | macOS 11.0+ | No | Document-based menu bar commands (File > New/Open/Save); multiple document windows |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Settings (macOS-only)
|
|
33
|
+
|
|
34
|
+
Presents the app's preferences window, accessible via **Cmd+,** or the app menu. SwiftUI automatically enables the Settings menu item and manages the window lifecycle.
|
|
35
|
+
|
|
36
|
+
```swift
|
|
37
|
+
Settings {
|
|
38
|
+
TabView {
|
|
39
|
+
Tab("General", systemImage: "gear") { GeneralSettingsView() }
|
|
40
|
+
Tab("Advanced", systemImage: "star") { AdvancedSettingsView() }
|
|
41
|
+
}
|
|
42
|
+
.scenePadding()
|
|
43
|
+
.frame(maxWidth: 350, minHeight: 100)
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Use `TabView` with `Tab` items for multi-pane preferences. Each tab's content is typically a `Form` with `@AppStorage`-backed controls.
|
|
48
|
+
|
|
49
|
+
### SettingsLink (macOS 14.0+)
|
|
50
|
+
|
|
51
|
+
A button that opens the Settings scene. Use for in-app navigation to preferences.
|
|
52
|
+
|
|
53
|
+
```swift
|
|
54
|
+
struct SidebarFooter: View {
|
|
55
|
+
var body: some View {
|
|
56
|
+
SettingsLink {
|
|
57
|
+
Label("Preferences", systemImage: "gear")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### openSettings environment action (macOS 14.0+)
|
|
64
|
+
|
|
65
|
+
Programmatically open (or bring to front) the Settings window.
|
|
66
|
+
|
|
67
|
+
```swift
|
|
68
|
+
struct OpenSettingsButton: View {
|
|
69
|
+
@Environment(\.openSettings) private var openSettings
|
|
70
|
+
|
|
71
|
+
var body: some View {
|
|
72
|
+
Button("Open Settings") {
|
|
73
|
+
openSettings()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## MenuBarExtra (macOS-only)
|
|
82
|
+
|
|
83
|
+
Renders a persistent control in the system menu bar. Two styles available:
|
|
84
|
+
- **`.menu`** (default) — standard dropdown menu
|
|
85
|
+
- **`.window`** — popover panel with custom SwiftUI views
|
|
86
|
+
|
|
87
|
+
### Menu-style (dropdown)
|
|
88
|
+
|
|
89
|
+
```swift
|
|
90
|
+
MenuBarExtra("My Utility", systemImage: "hammer") {
|
|
91
|
+
Button("Action One") { /* ... */ }
|
|
92
|
+
Button("Action Two") { /* ... */ }
|
|
93
|
+
Divider()
|
|
94
|
+
Button("Quit") { NSApplication.shared.terminate(nil) }
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Window-style (popover panel)
|
|
99
|
+
|
|
100
|
+
```swift
|
|
101
|
+
MenuBarExtra("Status", systemImage: "chart.bar") {
|
|
102
|
+
DashboardView()
|
|
103
|
+
.frame(width: 240)
|
|
104
|
+
}
|
|
105
|
+
.menuBarExtraStyle(.window)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Variations:**
|
|
109
|
+
- **Toggleable** — pass `isInserted:` with an `@AppStorage` binding to let users show/hide the extra: `MenuBarExtra("Status", systemImage: "chart.bar", isInserted: $showMenuBarExtra)`
|
|
110
|
+
- **Menu-bar-only app** — use `MenuBarExtra` as the sole scene + set `LSUIElement = true` in Info.plist to hide the Dock icon. The app auto-terminates if the user removes the extra from the menu bar.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## WindowGroup (macOS behavior)
|
|
115
|
+
|
|
116
|
+
On macOS, `WindowGroup` supports:
|
|
117
|
+
- **Multiple window instances** — users can open many windows from File > New Window
|
|
118
|
+
- **Tabbed interface** — users can merge windows into tabs
|
|
119
|
+
- **Automatic Window menu** — commands for window management appear automatically
|
|
120
|
+
|
|
121
|
+
```swift
|
|
122
|
+
@main
|
|
123
|
+
struct Mail: App {
|
|
124
|
+
var body: some Scene {
|
|
125
|
+
// Basic multi-window support
|
|
126
|
+
WindowGroup {
|
|
127
|
+
MailViewer()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Data-presenting window opened programmatically
|
|
131
|
+
WindowGroup("Message", for: Message.ID.self) { $messageID in
|
|
132
|
+
MessageDetail(messageID: messageID)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Open a specific window programmatically
|
|
138
|
+
struct NewMessageButton: View {
|
|
139
|
+
var message: Message
|
|
140
|
+
@Environment(\.openWindow) private var openWindow
|
|
141
|
+
|
|
142
|
+
var body: some View {
|
|
143
|
+
Button("Open Message") {
|
|
144
|
+
openWindow(value: message.id)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
> **Key difference from `Window`:** `WindowGroup` keeps the app running even after all windows are closed. `Window` (as sole scene) quits the app when closed.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Window
|
|
155
|
+
|
|
156
|
+
A single, unique window scene. The system ensures only one instance exists.
|
|
157
|
+
|
|
158
|
+
```swift
|
|
159
|
+
@main
|
|
160
|
+
struct Mail: App {
|
|
161
|
+
var body: some Scene {
|
|
162
|
+
WindowGroup {
|
|
163
|
+
MailViewer()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Supplementary singleton window
|
|
167
|
+
Window("Connection Doctor", id: "connection-doctor") {
|
|
168
|
+
ConnectionDoctor()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Open programmatically — brings to front if already open
|
|
174
|
+
struct OpenDoctorButton: View {
|
|
175
|
+
@Environment(\.openWindow) private var openWindow
|
|
176
|
+
|
|
177
|
+
var body: some View {
|
|
178
|
+
Button("Connection Doctor") {
|
|
179
|
+
openWindow(id: "connection-doctor")
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Window as sole scene
|
|
186
|
+
|
|
187
|
+
If `Window` is the only scene, the app quits when the window closes:
|
|
188
|
+
|
|
189
|
+
```swift
|
|
190
|
+
@main
|
|
191
|
+
struct VideoCall: App {
|
|
192
|
+
var body: some Scene {
|
|
193
|
+
Window("VideoCall", id: "main") {
|
|
194
|
+
CameraView()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
> **Recommendation:** In most cases, prefer `WindowGroup` for the primary scene. Use `Window` for supplementary singleton windows.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## UtilityWindow (macOS-only)
|
|
205
|
+
|
|
206
|
+
A specialized floating window for tool palettes and inspector panels. Available since macOS 15.0.
|
|
207
|
+
|
|
208
|
+
**Key behaviors:**
|
|
209
|
+
- Receives `FocusedValues` from the focused main scene (like menu bar commands)
|
|
210
|
+
- Floats above main windows (default level: `.floating`)
|
|
211
|
+
- Hides when the app is no longer active
|
|
212
|
+
- Only becomes focused when explicitly needed (e.g., clicking the title bar)
|
|
213
|
+
- Dismissible with the Escape key
|
|
214
|
+
- Not minimizable by default
|
|
215
|
+
- Automatically adds a show/hide item to the View menu
|
|
216
|
+
|
|
217
|
+
```swift
|
|
218
|
+
@main
|
|
219
|
+
struct PhotoBrowser: App {
|
|
220
|
+
var body: some Scene {
|
|
221
|
+
WindowGroup {
|
|
222
|
+
PhotoGallery()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
UtilityWindow("Photo Info", id: "photo-info") {
|
|
226
|
+
PhotoInfoViewer()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
struct PhotoInfoViewer: View {
|
|
232
|
+
// Automatically updates based on whichever main window is focused
|
|
233
|
+
@FocusedValue(PhotoSelection.self) private var selectedPhotos
|
|
234
|
+
|
|
235
|
+
var body: some View {
|
|
236
|
+
if let photos = selectedPhotos {
|
|
237
|
+
Text("\(photos.count) photos selected")
|
|
238
|
+
} else {
|
|
239
|
+
Text("No selection")
|
|
240
|
+
.foregroundStyle(.secondary)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
> **Tip:** Remove the automatic View menu item with `.commandsRemoved()` and place a `WindowVisibilityToggle` elsewhere in your commands.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## DocumentGroup
|
|
251
|
+
|
|
252
|
+
Document-based apps with automatic file management. On macOS, provides:
|
|
253
|
+
- **Document-based menu bar commands** (File > New, Open, Save, Revert)
|
|
254
|
+
- **Multiple document windows** simultaneously
|
|
255
|
+
- On iOS, shows a document browser instead
|
|
256
|
+
|
|
257
|
+
```swift
|
|
258
|
+
DocumentGroup(newDocument: TextFile()) { config in
|
|
259
|
+
ContentView(document: config.$document)
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The document type must conform to `FileDocument` (value type) or `ReferenceFileDocument` (reference type). Key requirements:
|
|
264
|
+
|
|
265
|
+
```swift
|
|
266
|
+
struct TextFile: FileDocument {
|
|
267
|
+
static var readableContentTypes: [UTType] { [.plainText] }
|
|
268
|
+
var text: String = ""
|
|
269
|
+
init() {}
|
|
270
|
+
init(configuration: ReadConfiguration) throws {
|
|
271
|
+
text = String(data: configuration.file.regularFileContents ?? Data(), encoding: .utf8) ?? ""
|
|
272
|
+
}
|
|
273
|
+
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
|
274
|
+
FileWrapper(regularFileWithContents: Data(text.utf8))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
For multiple document types, add additional `DocumentGroup` scenes — use `DocumentGroup(viewing:)` for read-only formats.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Platform Conditionals
|
|
284
|
+
|
|
285
|
+
Always wrap macOS-only scenes in `#if os(macOS)`:
|
|
286
|
+
|
|
287
|
+
```swift
|
|
288
|
+
@main
|
|
289
|
+
struct MyApp: App {
|
|
290
|
+
var body: some Scene {
|
|
291
|
+
WindowGroup {
|
|
292
|
+
ContentView()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#if os(macOS)
|
|
296
|
+
Settings {
|
|
297
|
+
SettingsView()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
MenuBarExtra("Status", systemImage: "bolt") {
|
|
301
|
+
StatusMenu()
|
|
302
|
+
}
|
|
303
|
+
#endif
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Best Practices
|
|
311
|
+
|
|
312
|
+
- **Use `Settings`** for preferences — prefer this over a custom preferences window
|
|
313
|
+
- **Use `MenuBarExtra`** for menu bar items — prefer this over managing AppKit's `NSStatusItem` directly
|
|
314
|
+
- **Use `WindowGroup`** as the primary scene — reserve `Window` for supplementary singletons
|
|
315
|
+
- **Use `UtilityWindow`** for inspectors/palettes — it handles floating, focus, and visibility automatically
|
|
316
|
+
- **Use `DocumentGroup`** for document-based apps — it provides the full File menu and document lifecycle
|
|
317
|
+
- **Gate macOS-only scenes** with `#if os(macOS)` for multiplatform projects
|
|
318
|
+
- **Use `openWindow(id:)`** to open windows programmatically — it brings existing windows to front
|