swift-code-reviewer-skill 1.2.0 → 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 +35 -169
- package/README.md +43 -2
- package/SKILL.md +107 -742
- package/bin/install.js +1 -1
- package/package.json +2 -1
- 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
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# SwiftUI Performance Patterns Reference
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
|
|
5
|
+
- [Performance Optimization](#performance-optimization)
|
|
6
|
+
- [Anti-Patterns](#anti-patterns)
|
|
7
|
+
- [Summary Checklist](#summary-checklist)
|
|
8
|
+
|
|
9
|
+
## Performance Optimization
|
|
10
|
+
|
|
11
|
+
### 1. Avoid Redundant State Updates
|
|
12
|
+
|
|
13
|
+
SwiftUI doesn't compare values before triggering updates:
|
|
14
|
+
|
|
15
|
+
```swift
|
|
16
|
+
// BAD - triggers update even if value unchanged
|
|
17
|
+
.onReceive(publisher) { value in
|
|
18
|
+
self.currentValue = value // Always triggers body re-evaluation
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// GOOD - only update when different
|
|
22
|
+
.onReceive(publisher) { value in
|
|
23
|
+
if self.currentValue != value {
|
|
24
|
+
self.currentValue = value
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. Optimize Hot Paths
|
|
30
|
+
|
|
31
|
+
Hot paths are frequently executed code (scroll handlers, animations, gestures):
|
|
32
|
+
|
|
33
|
+
```swift
|
|
34
|
+
// BAD - updates state on every scroll position change
|
|
35
|
+
.onPreferenceChange(ScrollOffsetKey.self) { offset in
|
|
36
|
+
shouldShowTitle = offset.y <= -32 // Fires constantly during scroll!
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// GOOD - only update when threshold crossed
|
|
40
|
+
.onPreferenceChange(ScrollOffsetKey.self) { offset in
|
|
41
|
+
let shouldShow = offset.y <= -32
|
|
42
|
+
if shouldShow != shouldShowTitle {
|
|
43
|
+
shouldShowTitle = shouldShow // Fires only when crossing threshold
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 3. Pass Only What Views Need
|
|
49
|
+
|
|
50
|
+
**Avoid passing large "config" or "context" objects.** Pass only the specific values each view needs.
|
|
51
|
+
|
|
52
|
+
```swift
|
|
53
|
+
// Good - pass specific values
|
|
54
|
+
ThemeSelector(theme: config.theme)
|
|
55
|
+
FontSizeSlider(fontSize: config.fontSize)
|
|
56
|
+
|
|
57
|
+
// Avoid - passing entire config (creates broad dependency)
|
|
58
|
+
ThemeSelector(config: config) // Notified of ALL config changes
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
With `ObservableObject`, any `@Published` change triggers all observers. With `@Observable`, views update only when accessed properties change, but passing entire objects still creates broader dependencies than necessary.
|
|
62
|
+
|
|
63
|
+
### 4. Use Equatable Views
|
|
64
|
+
|
|
65
|
+
For views with expensive bodies, conform to `Equatable`:
|
|
66
|
+
|
|
67
|
+
```swift
|
|
68
|
+
struct ExpensiveView: View, Equatable {
|
|
69
|
+
let data: SomeData
|
|
70
|
+
|
|
71
|
+
static func == (lhs: Self, rhs: Self) -> Bool {
|
|
72
|
+
lhs.data.id == rhs.data.id // Custom equality check
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var body: some View {
|
|
76
|
+
// Expensive computation
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Usage
|
|
81
|
+
ExpensiveView(data: data)
|
|
82
|
+
.equatable() // Use custom equality
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Caution**: If you add new state or dependencies to your view, remember to update your `==` function!
|
|
86
|
+
|
|
87
|
+
### 5. POD Views for Fast Diffing
|
|
88
|
+
|
|
89
|
+
**POD (Plain Old Data) views use `memcmp` for fastest diffing.** A view is POD if it only contains simple value types and no property wrappers.
|
|
90
|
+
|
|
91
|
+
```swift
|
|
92
|
+
// POD view - fastest diffing
|
|
93
|
+
struct FastView: View {
|
|
94
|
+
let title: String
|
|
95
|
+
let count: Int
|
|
96
|
+
|
|
97
|
+
var body: some View {
|
|
98
|
+
Text("\(title): \(count)")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Non-POD view - uses reflection or custom equality
|
|
103
|
+
struct SlowerView: View {
|
|
104
|
+
let title: String
|
|
105
|
+
@State private var isExpanded = false // Property wrapper makes it non-POD
|
|
106
|
+
|
|
107
|
+
var body: some View {
|
|
108
|
+
Text(title)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Advanced Pattern**: Wrap expensive non-POD views in POD parent views:
|
|
114
|
+
|
|
115
|
+
```swift
|
|
116
|
+
// POD wrapper for fast diffing
|
|
117
|
+
struct ExpensiveView: View {
|
|
118
|
+
let value: Int
|
|
119
|
+
|
|
120
|
+
var body: some View {
|
|
121
|
+
ExpensiveViewInternal(value: value)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Internal view with state
|
|
126
|
+
private struct ExpensiveViewInternal: View {
|
|
127
|
+
let value: Int
|
|
128
|
+
@State private var item: Item?
|
|
129
|
+
|
|
130
|
+
var body: some View {
|
|
131
|
+
// Expensive rendering
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Why**: The POD parent uses fast `memcmp` comparison. Only when `value` changes does the internal view get diffed.
|
|
137
|
+
|
|
138
|
+
### 6. Lazy Loading
|
|
139
|
+
|
|
140
|
+
Use lazy containers for large collections:
|
|
141
|
+
|
|
142
|
+
```swift
|
|
143
|
+
// BAD - creates all views immediately
|
|
144
|
+
ScrollView {
|
|
145
|
+
VStack {
|
|
146
|
+
ForEach(items) { item in
|
|
147
|
+
ExpensiveRow(item: item)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// GOOD - creates views on demand
|
|
153
|
+
ScrollView {
|
|
154
|
+
LazyVStack {
|
|
155
|
+
ForEach(items) { item in
|
|
156
|
+
ExpensiveRow(item: item)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**iOS 26+ note**: Nested scroll views containing lazy stacks now automatically defer loading their children until they are about to appear, matching the behavior of top-level lazy stacks. This benefits patterns like horizontal photo carousels inside a vertical scroll view.
|
|
163
|
+
|
|
164
|
+
> Source: "What's new in SwiftUI" (WWDC25, session 256)
|
|
165
|
+
|
|
166
|
+
### 7. Task Cancellation
|
|
167
|
+
|
|
168
|
+
Cancel async work when view disappears:
|
|
169
|
+
|
|
170
|
+
```swift
|
|
171
|
+
struct DataView: View {
|
|
172
|
+
@State private var data: [Item] = []
|
|
173
|
+
|
|
174
|
+
var body: some View {
|
|
175
|
+
List(data) { item in
|
|
176
|
+
Text(item.name)
|
|
177
|
+
}
|
|
178
|
+
.task {
|
|
179
|
+
// Automatically cancelled when view disappears
|
|
180
|
+
data = await fetchData()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### 8. Debug View Updates
|
|
187
|
+
|
|
188
|
+
**Use `Self._printChanges()` or `Self._logChanges()` to debug unexpected view updates.**
|
|
189
|
+
|
|
190
|
+
```swift
|
|
191
|
+
struct DebugView: View {
|
|
192
|
+
@State private var count = 0
|
|
193
|
+
@State private var name = ""
|
|
194
|
+
|
|
195
|
+
var body: some View {
|
|
196
|
+
#if DEBUG
|
|
197
|
+
let _ = Self._logChanges() // Xcode 15.1+: logs to com.apple.SwiftUI subsystem
|
|
198
|
+
#endif
|
|
199
|
+
|
|
200
|
+
VStack {
|
|
201
|
+
Text("Count: \(count)")
|
|
202
|
+
Text("Name: \(name)")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- `Self._printChanges()`: Prints which properties changed to standard output.
|
|
209
|
+
- `Self._logChanges()` (iOS 17+): Logs to the `com.apple.SwiftUI` subsystem with category "Changed Body Properties", using `os_log` for structured output.
|
|
210
|
+
|
|
211
|
+
Both print `@self` when the view value itself changed and `@identity` when the view's persistent data was recycled.
|
|
212
|
+
|
|
213
|
+
**Why**: This helps identify which state changes are causing view updates. Isolating redraw triggers into single-responsibility subviews is often the fix -- extracting a subview means SwiftUI can skip its body when its inputs haven't changed.
|
|
214
|
+
|
|
215
|
+
### 9. Eliminate Unnecessary Dependencies
|
|
216
|
+
|
|
217
|
+
**Narrow state scope to reduce update fan-out.** Instead of passing an entire `@Observable` model to a row view (which creates a dependency on all accessed properties), pass only the specific values the view needs as `let` properties.
|
|
218
|
+
|
|
219
|
+
```swift
|
|
220
|
+
// Bad - broad dependency on entire model
|
|
221
|
+
struct ItemRow: View {
|
|
222
|
+
@Environment(AppModel.self) private var model
|
|
223
|
+
let item: Item
|
|
224
|
+
var body: some View { Text(item.name).foregroundStyle(model.theme.primaryColor) }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Good - narrow dependency
|
|
228
|
+
struct ItemRow: View {
|
|
229
|
+
let item: Item
|
|
230
|
+
let themeColor: Color
|
|
231
|
+
var body: some View { Text(item.name).foregroundStyle(themeColor) }
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Avoid storing frequently-changing values in the environment.** Even when a view doesn't read the changed key, SwiftUI still checks all environment readers. This cost adds up with many views and frequent updates (geometry values, timers).
|
|
236
|
+
|
|
237
|
+
> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)
|
|
238
|
+
|
|
239
|
+
### 10. @Observable Dependency Granularity
|
|
240
|
+
|
|
241
|
+
**Consider per-item `@Observable` state holders (one per row/item) to narrow update scope.** When multiple list items share a dependency on the same `@Observable` array, changing one element causes all items to re-evaluate their bodies.
|
|
242
|
+
|
|
243
|
+
```swift
|
|
244
|
+
// BAD - all item views depend on the full favorites array
|
|
245
|
+
@Observable
|
|
246
|
+
class ModelData {
|
|
247
|
+
var favorites: [Landmark] = []
|
|
248
|
+
|
|
249
|
+
func isFavorite(_ landmark: Landmark) -> Bool {
|
|
250
|
+
favorites.contains(landmark)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
struct LandmarkRow: View {
|
|
255
|
+
let landmark: Landmark
|
|
256
|
+
@Environment(ModelData.self) private var model
|
|
257
|
+
|
|
258
|
+
var body: some View {
|
|
259
|
+
HStack {
|
|
260
|
+
Text(landmark.name)
|
|
261
|
+
if model.isFavorite(landmark) {
|
|
262
|
+
Image(systemName: "heart.fill")
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// GOOD - each item has its own observable view model
|
|
269
|
+
@Observable
|
|
270
|
+
class LandmarkViewModel {
|
|
271
|
+
var isFavorite: Bool = false
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
struct LandmarkRow: View {
|
|
275
|
+
let landmark: Landmark
|
|
276
|
+
let viewModel: LandmarkViewModel
|
|
277
|
+
|
|
278
|
+
var body: some View {
|
|
279
|
+
HStack {
|
|
280
|
+
Text(landmark.name)
|
|
281
|
+
if viewModel.isFavorite {
|
|
282
|
+
Image(systemName: "heart.fill")
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Why**: With the bad pattern, toggling one favorite marks the entire array as changed, causing every `LandmarkRow` to re-run its body. With per-item view models, only the toggled item's body runs.
|
|
290
|
+
|
|
291
|
+
> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)
|
|
292
|
+
|
|
293
|
+
### 11. Off-Main-Thread Closures
|
|
294
|
+
|
|
295
|
+
**SwiftUI may call certain closures on a background thread for performance.** These closures must be `Sendable` and should avoid accessing `@MainActor`-isolated state directly. Instead, capture needed values in the closure's capture list.
|
|
296
|
+
|
|
297
|
+
Closures that may run off the main thread:
|
|
298
|
+
- `Shape.path(in:)`
|
|
299
|
+
- `visualEffect` closure
|
|
300
|
+
- `Layout` protocol methods
|
|
301
|
+
- `onGeometryChange` transform closure
|
|
302
|
+
|
|
303
|
+
```swift
|
|
304
|
+
// BAD - accessing @MainActor state directly
|
|
305
|
+
.visualEffect { content, geometry in
|
|
306
|
+
content.blur(radius: self.pulse ? 5 : 0) // Compiler error: @MainActor isolated
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// GOOD - capture the value
|
|
310
|
+
.visualEffect { [pulse] content, geometry in
|
|
311
|
+
content.blur(radius: pulse ? 5 : 0)
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
> Source: "Explore concurrency in SwiftUI" (WWDC25, session 266)
|
|
316
|
+
|
|
317
|
+
### 12. Common Performance Issues
|
|
318
|
+
|
|
319
|
+
**Be aware of common performance bottlenecks in SwiftUI:**
|
|
320
|
+
|
|
321
|
+
- View invalidation storms from broad state changes
|
|
322
|
+
- Unstable identity in lists causing excessive diffing
|
|
323
|
+
- Heavy work in `body` (formatting, sorting, image decoding)
|
|
324
|
+
- Layout thrash from deep stacks or preference chains
|
|
325
|
+
|
|
326
|
+
**When performance issues arise**, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks.
|
|
327
|
+
|
|
328
|
+
## Anti-Patterns
|
|
329
|
+
|
|
330
|
+
### 1. Creating Objects in Body
|
|
331
|
+
|
|
332
|
+
```swift
|
|
333
|
+
// BAD - creates new formatter every body call
|
|
334
|
+
var body: some View {
|
|
335
|
+
let formatter = DateFormatter()
|
|
336
|
+
formatter.dateStyle = .long
|
|
337
|
+
return Text(formatter.string(from: date))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// GOOD - static or stored formatter
|
|
341
|
+
private static let dateFormatter: DateFormatter = {
|
|
342
|
+
let f = DateFormatter()
|
|
343
|
+
f.dateStyle = .long
|
|
344
|
+
return f
|
|
345
|
+
}()
|
|
346
|
+
|
|
347
|
+
var body: some View {
|
|
348
|
+
Text(Self.dateFormatter.string(from: date))
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### 2. Heavy Computation in Body
|
|
353
|
+
|
|
354
|
+
**Keep view body simple and pure.** Avoid side effects, dispatching, or complex logic.
|
|
355
|
+
|
|
356
|
+
```swift
|
|
357
|
+
// BAD - sorts array every body call
|
|
358
|
+
var body: some View {
|
|
359
|
+
List(items.sorted { $0.name < $1.name }) { item in Text(item.name) }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// GOOD - compute once, update via onChange or a computed property in the model
|
|
363
|
+
@State private var sortedItems: [Item] = []
|
|
364
|
+
|
|
365
|
+
var body: some View {
|
|
366
|
+
List(sortedItems) { item in Text(item.name) }
|
|
367
|
+
.onChange(of: items) { _, newItems in
|
|
368
|
+
sortedItems = newItems.sorted { $0.name < $1.name }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Move sorting, filtering, and formatting into models or computed properties. The `body` should be a pure structural representation of state.
|
|
374
|
+
|
|
375
|
+
### 3. Unnecessary State
|
|
376
|
+
|
|
377
|
+
```swift
|
|
378
|
+
// BAD - derived state stored separately
|
|
379
|
+
@State private var items: [Item] = []
|
|
380
|
+
@State private var itemCount: Int = 0 // Unnecessary!
|
|
381
|
+
|
|
382
|
+
// GOOD - compute derived values
|
|
383
|
+
@State private var items: [Item] = []
|
|
384
|
+
|
|
385
|
+
var itemCount: Int { items.count } // Computed property
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Summary Checklist
|
|
389
|
+
|
|
390
|
+
- [ ] State updates check for value changes before assigning
|
|
391
|
+
- [ ] Hot paths minimize state updates
|
|
392
|
+
- [ ] Pass only needed values to views (avoid large config objects)
|
|
393
|
+
- [ ] Large lists use `LazyVStack`/`LazyHStack`
|
|
394
|
+
- [ ] No object creation in `body`
|
|
395
|
+
- [ ] Heavy computation moved out of `body`
|
|
396
|
+
- [ ] Body kept simple and pure (no side effects)
|
|
397
|
+
- [ ] Derived state computed, not stored
|
|
398
|
+
- [ ] Use `Self._logChanges()` or `Self._printChanges()` to debug unexpected updates
|
|
399
|
+
- [ ] Equatable conformance for expensive views (when appropriate)
|
|
400
|
+
- [ ] Consider POD view wrappers for advanced optimization
|
|
401
|
+
- [ ] Consider using granular @Observable dependencies for list items (smaller observable units per row when it measurably reduces updates)
|
|
402
|
+
- [ ] Frequently-changing values not stored in the environment
|
|
403
|
+
- [ ] Sendable closures (Shape, visualEffect, Layout) capture values instead of accessing @MainActor state
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# SwiftUI ScrollView Patterns Reference
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
|
|
5
|
+
- [ScrollViewReader for Programmatic Scrolling](#scrollviewreader-for-programmatic-scrolling)
|
|
6
|
+
- [Scroll Position Tracking](#scroll-position-tracking)
|
|
7
|
+
- [Scroll Transitions and Effects](#scroll-transitions-and-effects)
|
|
8
|
+
- [Scroll Target Behavior](#scroll-target-behavior)
|
|
9
|
+
- [Summary Checklist](#summary-checklist)
|
|
10
|
+
|
|
11
|
+
## ScrollViewReader for Programmatic Scrolling
|
|
12
|
+
|
|
13
|
+
**Use `ScrollViewReader` for scroll-to-top, scroll-to-bottom, and anchor-based jumps.**
|
|
14
|
+
|
|
15
|
+
```swift
|
|
16
|
+
struct ChatView: View {
|
|
17
|
+
@State private var messages: [Message] = []
|
|
18
|
+
private let bottomID = "bottom"
|
|
19
|
+
|
|
20
|
+
var body: some View {
|
|
21
|
+
ScrollViewReader { proxy in
|
|
22
|
+
ScrollView {
|
|
23
|
+
LazyVStack {
|
|
24
|
+
ForEach(messages) { message in
|
|
25
|
+
MessageRow(message: message)
|
|
26
|
+
.id(message.id)
|
|
27
|
+
}
|
|
28
|
+
Color.clear
|
|
29
|
+
.frame(height: 1)
|
|
30
|
+
.id(bottomID)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
.onChange(of: messages.count) { _, _ in
|
|
34
|
+
withAnimation {
|
|
35
|
+
proxy.scrollTo(bottomID, anchor: .bottom)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
.onAppear {
|
|
39
|
+
proxy.scrollTo(bottomID, anchor: .bottom)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Scroll-to-Top Pattern
|
|
47
|
+
|
|
48
|
+
```swift
|
|
49
|
+
struct FeedView: View {
|
|
50
|
+
@State private var items: [Item] = []
|
|
51
|
+
@State private var scrollToTop = false
|
|
52
|
+
private let topID = "top"
|
|
53
|
+
|
|
54
|
+
var body: some View {
|
|
55
|
+
ScrollViewReader { proxy in
|
|
56
|
+
ScrollView {
|
|
57
|
+
LazyVStack {
|
|
58
|
+
Color.clear
|
|
59
|
+
.frame(height: 1)
|
|
60
|
+
.id(topID)
|
|
61
|
+
|
|
62
|
+
ForEach(items) { item in
|
|
63
|
+
ItemRow(item: item)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
.onChange(of: scrollToTop) { _, shouldScroll in
|
|
68
|
+
if shouldScroll {
|
|
69
|
+
withAnimation {
|
|
70
|
+
proxy.scrollTo(topID, anchor: .top)
|
|
71
|
+
}
|
|
72
|
+
scrollToTop = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Why**: `ScrollViewReader` provides programmatic scroll control with stable anchors. Always use stable IDs and explicit animations.
|
|
81
|
+
|
|
82
|
+
## Scroll Position Tracking
|
|
83
|
+
|
|
84
|
+
### Basic Scroll Position
|
|
85
|
+
|
|
86
|
+
**Avoid** - Storing scroll position directly triggers view updates on every scroll frame:
|
|
87
|
+
|
|
88
|
+
```swift
|
|
89
|
+
// ❌ Bad Practice - causes unnecessary re-renders
|
|
90
|
+
struct ContentView: View {
|
|
91
|
+
@State private var scrollPosition: CGFloat = 0
|
|
92
|
+
|
|
93
|
+
var body: some View {
|
|
94
|
+
ScrollView {
|
|
95
|
+
content
|
|
96
|
+
.background(
|
|
97
|
+
GeometryReader { geometry in
|
|
98
|
+
Color.clear
|
|
99
|
+
.preference(
|
|
100
|
+
key: ScrollOffsetPreferenceKey.self,
|
|
101
|
+
value: geometry.frame(in: .named("scroll")).minY
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
.coordinateSpace(name: "scroll")
|
|
107
|
+
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
|
|
108
|
+
scrollPosition = value
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Preferred** - Check scroll position and update a flag based on thresholds for smoother, more efficient scrolling:
|
|
115
|
+
|
|
116
|
+
```swift
|
|
117
|
+
// ✅ Good Practice - only updates state when crossing threshold
|
|
118
|
+
struct ContentView: View {
|
|
119
|
+
@State private var startAnimation: Bool = false
|
|
120
|
+
|
|
121
|
+
var body: some View {
|
|
122
|
+
ScrollView {
|
|
123
|
+
content
|
|
124
|
+
.background(
|
|
125
|
+
GeometryReader { geometry in
|
|
126
|
+
Color.clear
|
|
127
|
+
.preference(
|
|
128
|
+
key: ScrollOffsetPreferenceKey.self,
|
|
129
|
+
value: geometry.frame(in: .named("scroll")).minY
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
.coordinateSpace(name: "scroll")
|
|
135
|
+
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
|
|
136
|
+
if value < -100 {
|
|
137
|
+
startAnimation = true
|
|
138
|
+
} else {
|
|
139
|
+
startAnimation = false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
|
146
|
+
static var defaultValue: CGFloat = 0
|
|
147
|
+
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
148
|
+
value = nextValue()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Scroll-Based Header Visibility
|
|
154
|
+
|
|
155
|
+
```swift
|
|
156
|
+
struct ContentView: View {
|
|
157
|
+
@State private var showHeader = true
|
|
158
|
+
|
|
159
|
+
var body: some View {
|
|
160
|
+
VStack(spacing: 0) {
|
|
161
|
+
if showHeader {
|
|
162
|
+
HeaderView()
|
|
163
|
+
.transition(.move(edge: .top))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
ScrollView {
|
|
167
|
+
content
|
|
168
|
+
.background(
|
|
169
|
+
GeometryReader { geometry in
|
|
170
|
+
Color.clear
|
|
171
|
+
.preference(
|
|
172
|
+
key: ScrollOffsetPreferenceKey.self,
|
|
173
|
+
value: geometry.frame(in: .named("scroll")).minY
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
.coordinateSpace(name: "scroll")
|
|
179
|
+
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
|
180
|
+
if offset < -50 { // Scrolling down
|
|
181
|
+
withAnimation { showHeader = false }
|
|
182
|
+
} else if offset > 50 { // Scrolling up
|
|
183
|
+
withAnimation { showHeader = true }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Scroll Transitions and Effects
|
|
192
|
+
|
|
193
|
+
> **iOS 17+**: All APIs in this section require iOS 17 or later.
|
|
194
|
+
|
|
195
|
+
### Scroll-Based Opacity
|
|
196
|
+
|
|
197
|
+
```swift
|
|
198
|
+
struct ParallaxView: View {
|
|
199
|
+
var body: some View {
|
|
200
|
+
ScrollView {
|
|
201
|
+
LazyVStack(spacing: 20) {
|
|
202
|
+
ForEach(items) { item in
|
|
203
|
+
ItemCard(item: item)
|
|
204
|
+
.visualEffect { content, geometry in
|
|
205
|
+
let frame = geometry.frame(in: .scrollView)
|
|
206
|
+
let distance = min(0, frame.minY)
|
|
207
|
+
return content
|
|
208
|
+
.opacity(1 + distance / 200)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Parallax Effect
|
|
218
|
+
|
|
219
|
+
```swift
|
|
220
|
+
struct ParallaxHeader: View {
|
|
221
|
+
var body: some View {
|
|
222
|
+
ScrollView {
|
|
223
|
+
VStack(spacing: 0) {
|
|
224
|
+
Image("hero")
|
|
225
|
+
.resizable()
|
|
226
|
+
.aspectRatio(contentMode: .fill)
|
|
227
|
+
.frame(height: 300)
|
|
228
|
+
.visualEffect { content, geometry in
|
|
229
|
+
let offset = geometry.frame(in: .scrollView).minY
|
|
230
|
+
return content
|
|
231
|
+
.offset(y: offset > 0 ? -offset * 0.5 : 0)
|
|
232
|
+
}
|
|
233
|
+
.clipped()
|
|
234
|
+
|
|
235
|
+
ContentView()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Scroll Target Behavior
|
|
243
|
+
|
|
244
|
+
> **iOS 17+**: All APIs in this section require iOS 17 or later.
|
|
245
|
+
|
|
246
|
+
### Paging ScrollView
|
|
247
|
+
|
|
248
|
+
```swift
|
|
249
|
+
struct PagingView: View {
|
|
250
|
+
var body: some View {
|
|
251
|
+
ScrollView(.horizontal) {
|
|
252
|
+
LazyHStack(spacing: 0) {
|
|
253
|
+
ForEach(pages) { page in
|
|
254
|
+
PageView(page: page)
|
|
255
|
+
.containerRelativeFrame(.horizontal)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
.scrollTargetLayout()
|
|
259
|
+
}
|
|
260
|
+
.scrollTargetBehavior(.paging)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Snap to Items
|
|
266
|
+
|
|
267
|
+
```swift
|
|
268
|
+
struct SnapScrollView: View {
|
|
269
|
+
var body: some View {
|
|
270
|
+
ScrollView(.horizontal) {
|
|
271
|
+
LazyHStack(spacing: 16) {
|
|
272
|
+
ForEach(items) { item in
|
|
273
|
+
ItemCard(item: item)
|
|
274
|
+
.frame(width: 280)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
.scrollTargetLayout()
|
|
278
|
+
}
|
|
279
|
+
.scrollTargetBehavior(.viewAligned)
|
|
280
|
+
.contentMargins(.horizontal, 20)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Summary Checklist
|
|
286
|
+
|
|
287
|
+
- [ ] Use `ScrollViewReader` with stable IDs for programmatic scrolling
|
|
288
|
+
- [ ] Always use explicit animations with `scrollTo()`
|
|
289
|
+
- [ ] Use `.visualEffect` for scroll-based visual changes
|
|
290
|
+
- [ ] Use `.scrollTargetBehavior(.paging)` for paging behavior
|
|
291
|
+
- [ ] Use `.scrollTargetBehavior(.viewAligned)` for snap-to-item behavior
|
|
292
|
+
- [ ] Gate frequent scroll position updates by thresholds
|
|
293
|
+
- [ ] Use preference keys for custom scroll position tracking
|