swift-code-reviewer-skill 1.0.0
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 +214 -0
- package/CONTRIBUTING.md +271 -0
- package/LICENSE +21 -0
- package/README.md +536 -0
- package/SKILL.md +690 -0
- package/bin/install.js +173 -0
- package/package.json +41 -0
- package/references/architecture-patterns.md +862 -0
- package/references/custom-guidelines.md +852 -0
- package/references/feedback-templates.md +666 -0
- package/references/performance-review.md +914 -0
- package/references/review-workflow.md +1131 -0
- package/references/security-checklist.md +781 -0
- package/references/swift-quality-checklist.md +928 -0
- package/references/swiftui-review-checklist.md +909 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
# SwiftUI Review Checklist
|
|
2
|
+
|
|
3
|
+
This checklist covers SwiftUI-specific patterns including state management, property wrappers, modern API usage, view composition, and accessibility. Use this to ensure SwiftUI code follows best practices and leverages modern APIs effectively.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. State Management
|
|
8
|
+
|
|
9
|
+
### 1.1 @Observable (iOS 17+, macOS 14+)
|
|
10
|
+
|
|
11
|
+
**Check for:**
|
|
12
|
+
- [ ] @Observable used for view models and observable objects
|
|
13
|
+
- [ ] No mixing @Observable with @StateObject/@ObservedObject
|
|
14
|
+
- [ ] Proper state isolation
|
|
15
|
+
|
|
16
|
+
**Examples:**
|
|
17
|
+
|
|
18
|
+
❌ **Bad: Using old ObservableObject pattern**
|
|
19
|
+
```swift
|
|
20
|
+
class LoginViewModel: ObservableObject { // ❌ Old pattern (iOS 17+)
|
|
21
|
+
@Published var email: String = ""
|
|
22
|
+
@Published var isLoading: Bool = false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
struct LoginView: View {
|
|
26
|
+
@StateObject private var viewModel = LoginViewModel() // ❌ Old pattern
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
✅ **Good: Modern @Observable pattern**
|
|
31
|
+
```swift
|
|
32
|
+
@Observable
|
|
33
|
+
final class LoginViewModel { // ✅ Modern pattern (iOS 17+)
|
|
34
|
+
var email: String = ""
|
|
35
|
+
var isLoading: Bool = false
|
|
36
|
+
|
|
37
|
+
func login() async {
|
|
38
|
+
isLoading = true
|
|
39
|
+
// Login logic
|
|
40
|
+
isLoading = false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
struct LoginView: View {
|
|
45
|
+
let viewModel: LoginViewModel // ✅ No property wrapper needed
|
|
46
|
+
|
|
47
|
+
var body: some View {
|
|
48
|
+
// Automatically observes viewModel changes
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
✅ **Good: @Observable with MainActor**
|
|
54
|
+
```swift
|
|
55
|
+
@MainActor
|
|
56
|
+
@Observable
|
|
57
|
+
final class UserListViewModel { // ✅ MainActor + Observable
|
|
58
|
+
var users: [User] = []
|
|
59
|
+
var isLoading: Bool = false
|
|
60
|
+
|
|
61
|
+
func fetchUsers() async {
|
|
62
|
+
// Always runs on main actor
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 1.2 @State for View-Local State
|
|
68
|
+
|
|
69
|
+
**Check for:**
|
|
70
|
+
- [ ] @State used only for view-owned state
|
|
71
|
+
- [ ] Private @State properties
|
|
72
|
+
- [ ] No @State for passed data
|
|
73
|
+
|
|
74
|
+
**Examples:**
|
|
75
|
+
|
|
76
|
+
❌ **Bad: @State for passed data**
|
|
77
|
+
```swift
|
|
78
|
+
struct UserDetailView: View {
|
|
79
|
+
@State var user: User // ❌ User should be passed as let
|
|
80
|
+
|
|
81
|
+
var body: some View {
|
|
82
|
+
// ...
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
✅ **Good: @State for view-local state**
|
|
88
|
+
```swift
|
|
89
|
+
struct UserDetailView: View {
|
|
90
|
+
let user: User // ✅ Passed data as let
|
|
91
|
+
|
|
92
|
+
@State private var isExpanded: Bool = false // ✅ View-local state
|
|
93
|
+
@State private var selectedTab: Tab = .profile
|
|
94
|
+
|
|
95
|
+
var body: some View {
|
|
96
|
+
VStack {
|
|
97
|
+
Button(isExpanded ? "Collapse" : "Expand") {
|
|
98
|
+
isExpanded.toggle() // ✅ Modifying view-local state
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 1.3 @Binding for Two-Way Communication
|
|
106
|
+
|
|
107
|
+
**Check for:**
|
|
108
|
+
- [ ] @Binding used for child-to-parent communication
|
|
109
|
+
- [ ] Parent owns the state, child has @Binding
|
|
110
|
+
- [ ] No @Binding for read-only data
|
|
111
|
+
|
|
112
|
+
**Examples:**
|
|
113
|
+
|
|
114
|
+
❌ **Bad: Passing @State directly**
|
|
115
|
+
```swift
|
|
116
|
+
struct ParentView: View {
|
|
117
|
+
@State private var text: String = ""
|
|
118
|
+
|
|
119
|
+
var body: some View {
|
|
120
|
+
ChildView(text: text) // ❌ Child can't modify
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
struct ChildView: View {
|
|
125
|
+
let text: String
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
✅ **Good: Using @Binding for two-way communication**
|
|
130
|
+
```swift
|
|
131
|
+
struct ParentView: View {
|
|
132
|
+
@State private var text: String = "" // ✅ Parent owns state
|
|
133
|
+
|
|
134
|
+
var body: some View {
|
|
135
|
+
ChildView(text: $text) // ✅ Pass binding with $
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
struct ChildView: View {
|
|
140
|
+
@Binding var text: String // ✅ Child can read and write
|
|
141
|
+
|
|
142
|
+
var body: some View {
|
|
143
|
+
TextField("Enter text", text: $text)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
✅ **Good: Read-only without @Binding**
|
|
149
|
+
```swift
|
|
150
|
+
struct DisplayView: View {
|
|
151
|
+
let text: String // ✅ Read-only, no @Binding
|
|
152
|
+
|
|
153
|
+
var body: some View {
|
|
154
|
+
Text(text)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 1.4 @Environment for Dependency Injection
|
|
160
|
+
|
|
161
|
+
**Check for:**
|
|
162
|
+
- [ ] @Environment for cross-cutting concerns
|
|
163
|
+
- [ ] Custom environment values for dependencies
|
|
164
|
+
- [ ] No direct service access in views
|
|
165
|
+
|
|
166
|
+
**Examples:**
|
|
167
|
+
|
|
168
|
+
✅ **Good: Custom environment value**
|
|
169
|
+
```swift
|
|
170
|
+
// Define environment key
|
|
171
|
+
private struct AuthServiceKey: EnvironmentKey {
|
|
172
|
+
static let defaultValue: AuthService = DefaultAuthService()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
extension EnvironmentValues {
|
|
176
|
+
var authService: AuthService {
|
|
177
|
+
get { self[AuthServiceKey.self] }
|
|
178
|
+
set { self[AuthServiceKey.self] = newValue }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Usage in view
|
|
183
|
+
struct LoginView: View {
|
|
184
|
+
@Environment(\.authService) private var authService // ✅ Injected dependency
|
|
185
|
+
|
|
186
|
+
var body: some View {
|
|
187
|
+
Button("Login") {
|
|
188
|
+
Task {
|
|
189
|
+
await authService.login(email: email, password: password)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Provide in app
|
|
196
|
+
@main
|
|
197
|
+
struct MyApp: App {
|
|
198
|
+
var body: some Scene {
|
|
199
|
+
WindowGroup {
|
|
200
|
+
ContentView()
|
|
201
|
+
.environment(\.authService, productionAuthService) // ✅ Provide
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
✅ **Good: Built-in environment values**
|
|
208
|
+
```swift
|
|
209
|
+
struct ContentView: View {
|
|
210
|
+
@Environment(\.dismiss) private var dismiss // ✅ Dismissal
|
|
211
|
+
@Environment(\.colorScheme) private var colorScheme // ✅ Color scheme
|
|
212
|
+
@Environment(\.horizontalSizeClass) private var sizeClass // ✅ Size class
|
|
213
|
+
|
|
214
|
+
var body: some View {
|
|
215
|
+
Button("Close") {
|
|
216
|
+
dismiss() // ✅ Use environment value
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### 1.5 State Ownership Rules
|
|
223
|
+
|
|
224
|
+
**Check for:**
|
|
225
|
+
- [ ] Single source of truth
|
|
226
|
+
- [ ] Clear state ownership
|
|
227
|
+
- [ ] No duplicate state
|
|
228
|
+
- [ ] Derived state computed, not stored
|
|
229
|
+
|
|
230
|
+
**Examples:**
|
|
231
|
+
|
|
232
|
+
❌ **Bad: Duplicate state**
|
|
233
|
+
```swift
|
|
234
|
+
@Observable
|
|
235
|
+
final class UserViewModel {
|
|
236
|
+
var users: [User] = []
|
|
237
|
+
var userCount: Int = 0 // ❌ Duplicate - derived from users
|
|
238
|
+
|
|
239
|
+
func addUser(_ user: User) {
|
|
240
|
+
users.append(user)
|
|
241
|
+
userCount = users.count // ❌ Manual sync
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
✅ **Good: Computed property**
|
|
247
|
+
```swift
|
|
248
|
+
@Observable
|
|
249
|
+
final class UserViewModel {
|
|
250
|
+
var users: [User] = []
|
|
251
|
+
|
|
252
|
+
var userCount: Int { // ✅ Computed from users
|
|
253
|
+
users.count
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func addUser(_ user: User) {
|
|
257
|
+
users.append(user) // ✅ Single source of truth
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 2. Property Wrapper Selection
|
|
265
|
+
|
|
266
|
+
### 2.1 Property Wrapper Decision Tree
|
|
267
|
+
|
|
268
|
+
**Use this decision tree:**
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
Is this UI-related mutable state?
|
|
272
|
+
├─ Yes → Is it owned by this view?
|
|
273
|
+
│ ├─ Yes → Use @State
|
|
274
|
+
│ └─ No → Is it a two-way binding from parent?
|
|
275
|
+
│ ├─ Yes → Use @Binding
|
|
276
|
+
│ └─ No → Is it an observable object?
|
|
277
|
+
│ ├─ Yes (iOS 17+) → Use @Observable class (no wrapper in view)
|
|
278
|
+
│ └─ Yes (iOS 16-) → Use @StateObject or @ObservedObject
|
|
279
|
+
├─ No → Is it environment data?
|
|
280
|
+
├─ Yes → Use @Environment
|
|
281
|
+
└─ No → Use let (immutable property)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### 2.2 Property Wrapper Reference Table
|
|
285
|
+
|
|
286
|
+
| Wrapper | iOS Version | Use Case | Example |
|
|
287
|
+
|---------|-------------|----------|---------|
|
|
288
|
+
| `@State` | iOS 13+ | View-local mutable state | `@State private var isExpanded = false` |
|
|
289
|
+
| `@Binding` | iOS 13+ | Two-way binding from parent | `@Binding var text: String` |
|
|
290
|
+
| `@Observable` | iOS 17+ | Observable view model (class) | `@Observable final class ViewModel { }` |
|
|
291
|
+
| `@StateObject` | iOS 14+ | View owns observable object (legacy) | `@StateObject private var vm = VM()` |
|
|
292
|
+
| `@ObservedObject` | iOS 13+ | Parent owns observable object (legacy) | `@ObservedObject var vm: VM` |
|
|
293
|
+
| `@Environment` | iOS 13+ | Environment dependency injection | `@Environment(\.dismiss) var dismiss` |
|
|
294
|
+
| `@EnvironmentObject` | iOS 13+ | Shared observable across views | `@EnvironmentObject var settings: Settings` |
|
|
295
|
+
| `@AppStorage` | iOS 14+ | UserDefaults-backed property | `@AppStorage("theme") var theme = "light"` |
|
|
296
|
+
| `@SceneStorage` | iOS 14+ | Scene-specific state restoration | `@SceneStorage("selectedTab") var tab = 0` |
|
|
297
|
+
| `@FocusState` | iOS 15+ | Focus state for text fields | `@FocusState private var isFocused: Bool` |
|
|
298
|
+
|
|
299
|
+
### 2.3 Common Mistakes
|
|
300
|
+
|
|
301
|
+
**Check for:**
|
|
302
|
+
- [ ] No @StateObject with @Observable classes
|
|
303
|
+
- [ ] No @Published with @Observable classes
|
|
304
|
+
- [ ] No @State for objects (use @Observable instead)
|
|
305
|
+
- [ ] No @Binding for read-only data
|
|
306
|
+
|
|
307
|
+
**Examples:**
|
|
308
|
+
|
|
309
|
+
❌ **Bad: @StateObject with @Observable**
|
|
310
|
+
```swift
|
|
311
|
+
@Observable
|
|
312
|
+
final class ViewModel { }
|
|
313
|
+
|
|
314
|
+
struct MyView: View {
|
|
315
|
+
@StateObject private var viewModel = ViewModel() // ❌ Don't mix
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
✅ **Good: No wrapper with @Observable**
|
|
320
|
+
```swift
|
|
321
|
+
@Observable
|
|
322
|
+
final class ViewModel { }
|
|
323
|
+
|
|
324
|
+
struct MyView: View {
|
|
325
|
+
let viewModel = ViewModel() // ✅ No wrapper needed (iOS 17+)
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
❌ **Bad: @Published with @Observable**
|
|
330
|
+
```swift
|
|
331
|
+
@Observable
|
|
332
|
+
final class ViewModel {
|
|
333
|
+
@Published var text: String = "" // ❌ Don't mix
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
✅ **Good: Regular property with @Observable**
|
|
338
|
+
```swift
|
|
339
|
+
@Observable
|
|
340
|
+
final class ViewModel {
|
|
341
|
+
var text: String = "" // ✅ Automatically observable
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## 3. Modern API Usage
|
|
348
|
+
|
|
349
|
+
### 3.1 NavigationStack vs NavigationView
|
|
350
|
+
|
|
351
|
+
**Check for:**
|
|
352
|
+
- [ ] NavigationStack used instead of NavigationView (iOS 16+)
|
|
353
|
+
- [ ] Proper navigation path management
|
|
354
|
+
- [ ] Type-safe navigation destinations
|
|
355
|
+
|
|
356
|
+
**Examples:**
|
|
357
|
+
|
|
358
|
+
❌ **Bad: Deprecated NavigationView**
|
|
359
|
+
```swift
|
|
360
|
+
NavigationView { // ❌ Deprecated in iOS 16
|
|
361
|
+
List(items) { item in
|
|
362
|
+
NavigationLink(destination: DetailView(item: item)) {
|
|
363
|
+
Text(item.name)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
✅ **Good: NavigationStack**
|
|
370
|
+
```swift
|
|
371
|
+
NavigationStack { // ✅ Modern (iOS 16+)
|
|
372
|
+
List(items) { item in
|
|
373
|
+
NavigationLink(value: item) { // ✅ Value-based
|
|
374
|
+
Text(item.name)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
.navigationDestination(for: Item.self) { item in
|
|
378
|
+
DetailView(item: item)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
✅ **Good: NavigationStack with path**
|
|
384
|
+
```swift
|
|
385
|
+
@Observable
|
|
386
|
+
final class NavigationModel {
|
|
387
|
+
var path = NavigationPath()
|
|
388
|
+
|
|
389
|
+
func navigateToDetail(_ item: Item) {
|
|
390
|
+
path.append(item)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
struct ContentView: View {
|
|
395
|
+
@State private var navModel = NavigationModel()
|
|
396
|
+
|
|
397
|
+
var body: some View {
|
|
398
|
+
NavigationStack(path: $navModel.path) { // ✅ Programmatic navigation
|
|
399
|
+
// Content
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### 3.2 .task vs .onAppear for Async Work
|
|
406
|
+
|
|
407
|
+
**Check for:**
|
|
408
|
+
- [ ] .task modifier for async work instead of .onAppear
|
|
409
|
+
- [ ] Automatic cancellation handling with .task
|
|
410
|
+
- [ ] No manual Task creation in .onAppear
|
|
411
|
+
|
|
412
|
+
**Examples:**
|
|
413
|
+
|
|
414
|
+
❌ **Bad: .onAppear with manual Task**
|
|
415
|
+
```swift
|
|
416
|
+
.onAppear {
|
|
417
|
+
Task { // ❌ Manual task, no automatic cancellation
|
|
418
|
+
await viewModel.load()
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
✅ **Good: .task modifier**
|
|
424
|
+
```swift
|
|
425
|
+
.task { // ✅ Automatically cancelled when view disappears
|
|
426
|
+
await viewModel.load()
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
✅ **Good: .task with id for refresh**
|
|
431
|
+
```swift
|
|
432
|
+
.task(id: selectedCategory) { // ✅ Runs again when id changes
|
|
433
|
+
await viewModel.load(category: selectedCategory)
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### 3.3 .onChange with Modern Syntax (iOS 17+)
|
|
438
|
+
|
|
439
|
+
**Check for:**
|
|
440
|
+
- [ ] Modern .onChange syntax (iOS 17+)
|
|
441
|
+
- [ ] Access to both old and new values
|
|
442
|
+
- [ ] No deprecated .onChange(of:perform:)
|
|
443
|
+
|
|
444
|
+
**Examples:**
|
|
445
|
+
|
|
446
|
+
❌ **Bad: Old .onChange syntax**
|
|
447
|
+
```swift
|
|
448
|
+
.onChange(of: searchText) { newValue in // ❌ Old syntax
|
|
449
|
+
performSearch(newValue)
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
✅ **Good: Modern .onChange syntax**
|
|
454
|
+
```swift
|
|
455
|
+
.onChange(of: searchText) { oldValue, newValue in // ✅ New syntax (iOS 17+)
|
|
456
|
+
performSearch(newValue)
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
✅ **Good: Modern .onChange with initial value**
|
|
461
|
+
```swift
|
|
462
|
+
.onChange(of: searchText, initial: true) { oldValue, newValue in // ✅ Runs on appear
|
|
463
|
+
performSearch(newValue)
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### 3.4 Deprecated APIs to Replace
|
|
468
|
+
|
|
469
|
+
**Check for and replace:**
|
|
470
|
+
|
|
471
|
+
| Deprecated API | Modern Replacement | iOS Version |
|
|
472
|
+
|----------------|-------------------|-------------|
|
|
473
|
+
| `NavigationView` | `NavigationStack` | iOS 16+ |
|
|
474
|
+
| `.onAppear { Task { } }` | `.task { }` | iOS 15+ |
|
|
475
|
+
| `.onChange(of:perform:)` | `.onChange(of:) { old, new in }` | iOS 17+ |
|
|
476
|
+
| `@StateObject` with `ObservableObject` | `@Observable` class | iOS 17+ |
|
|
477
|
+
| `@Published` | Regular property with `@Observable` | iOS 17+ |
|
|
478
|
+
| `GeometryReader` (simple cases) | `.frame(maxWidth: .infinity)` | iOS 13+ |
|
|
479
|
+
| `List { ... }` with explicit ForEach | `List(items) { }` | iOS 13+ |
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## 4. View Composition
|
|
484
|
+
|
|
485
|
+
### 4.1 View Extraction Guidelines
|
|
486
|
+
|
|
487
|
+
**Check for:**
|
|
488
|
+
- [ ] View body < 50 lines (guideline)
|
|
489
|
+
- [ ] Logical subviews extracted
|
|
490
|
+
- [ ] Reusable components identified
|
|
491
|
+
- [ ] Proper view hierarchy depth (< 5 levels)
|
|
492
|
+
|
|
493
|
+
**Examples:**
|
|
494
|
+
|
|
495
|
+
❌ **Bad: Monolithic view**
|
|
496
|
+
```swift
|
|
497
|
+
struct LoginView: View {
|
|
498
|
+
var body: some View {
|
|
499
|
+
VStack(spacing: 20) {
|
|
500
|
+
Image("logo")
|
|
501
|
+
.resizable()
|
|
502
|
+
.frame(width: 100, height: 100)
|
|
503
|
+
Text("Welcome")
|
|
504
|
+
.font(.title)
|
|
505
|
+
TextField("Email", text: $email)
|
|
506
|
+
.textFieldStyle(.roundedBorder)
|
|
507
|
+
SecureField("Password", text: $password)
|
|
508
|
+
.textFieldStyle(.roundedBorder)
|
|
509
|
+
Button("Login") {
|
|
510
|
+
login()
|
|
511
|
+
}
|
|
512
|
+
.buttonStyle(.borderedProminent)
|
|
513
|
+
// ... 50 more lines
|
|
514
|
+
} // ❌ Too long, no extraction
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
✅ **Good: Extracted subviews**
|
|
520
|
+
```swift
|
|
521
|
+
struct LoginView: View {
|
|
522
|
+
var body: some View {
|
|
523
|
+
VStack(spacing: 20) {
|
|
524
|
+
LoginHeaderView() // ✅ Extracted
|
|
525
|
+
LoginFormView(
|
|
526
|
+
email: $email,
|
|
527
|
+
password: $password
|
|
528
|
+
) // ✅ Extracted
|
|
529
|
+
LoginActionsView(
|
|
530
|
+
onLogin: login
|
|
531
|
+
) // ✅ Extracted
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// MARK: - Subviews
|
|
537
|
+
private struct LoginHeaderView: View {
|
|
538
|
+
var body: some View {
|
|
539
|
+
VStack {
|
|
540
|
+
Image("logo")
|
|
541
|
+
.resizable()
|
|
542
|
+
.frame(width: 100, height: 100)
|
|
543
|
+
Text("Welcome")
|
|
544
|
+
.font(.title)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private struct LoginFormView: View {
|
|
550
|
+
@Binding var email: String
|
|
551
|
+
@Binding var password: String
|
|
552
|
+
|
|
553
|
+
var body: some View {
|
|
554
|
+
VStack(spacing: 12) {
|
|
555
|
+
TextField("Email", text: $email)
|
|
556
|
+
.textFieldStyle(.roundedBorder)
|
|
557
|
+
SecureField("Password", text: $password)
|
|
558
|
+
.textFieldStyle(.roundedBorder)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### 4.2 When to Extract
|
|
565
|
+
|
|
566
|
+
**Extract when:**
|
|
567
|
+
- View body > 50 lines
|
|
568
|
+
- Logic is reused in multiple places
|
|
569
|
+
- Clear semantic boundary (header, form, footer)
|
|
570
|
+
- Testing would benefit from isolation
|
|
571
|
+
- View hierarchy becomes too deep
|
|
572
|
+
|
|
573
|
+
**Don't extract when:**
|
|
574
|
+
- View is small and simple (< 20 lines)
|
|
575
|
+
- Only used once and tightly coupled
|
|
576
|
+
- Extraction adds unnecessary complexity
|
|
577
|
+
|
|
578
|
+
### 4.3 ViewBuilder Patterns
|
|
579
|
+
|
|
580
|
+
**Check for:**
|
|
581
|
+
- [ ] @ViewBuilder for conditional view logic
|
|
582
|
+
- [ ] @ViewBuilder for custom container views
|
|
583
|
+
- [ ] Proper use of view builder syntax
|
|
584
|
+
|
|
585
|
+
**Examples:**
|
|
586
|
+
|
|
587
|
+
✅ **Good: @ViewBuilder for conditional content**
|
|
588
|
+
```swift
|
|
589
|
+
struct ConditionalView<Content: View>: View {
|
|
590
|
+
let showHeader: Bool
|
|
591
|
+
@ViewBuilder let content: () -> Content
|
|
592
|
+
|
|
593
|
+
var body: some View {
|
|
594
|
+
VStack {
|
|
595
|
+
if showHeader {
|
|
596
|
+
HeaderView()
|
|
597
|
+
}
|
|
598
|
+
content()
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Usage
|
|
604
|
+
ConditionalView(showHeader: true) {
|
|
605
|
+
Text("Content")
|
|
606
|
+
Button("Action") { }
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
✅ **Good: @ViewBuilder for custom container**
|
|
611
|
+
```swift
|
|
612
|
+
struct Card<Content: View>: View {
|
|
613
|
+
@ViewBuilder let content: () -> Content
|
|
614
|
+
|
|
615
|
+
var body: some View {
|
|
616
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
617
|
+
content()
|
|
618
|
+
}
|
|
619
|
+
.padding()
|
|
620
|
+
.background(Color.white)
|
|
621
|
+
.cornerRadius(8)
|
|
622
|
+
.shadow(radius: 2)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Usage
|
|
627
|
+
Card {
|
|
628
|
+
Text("Title")
|
|
629
|
+
Text("Subtitle")
|
|
630
|
+
Button("Action") { }
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## 5. Accessibility
|
|
637
|
+
|
|
638
|
+
### 5.1 Accessibility Labels
|
|
639
|
+
|
|
640
|
+
**Check for:**
|
|
641
|
+
- [ ] Accessibility labels for non-text elements
|
|
642
|
+
- [ ] Descriptive labels (not just button text)
|
|
643
|
+
- [ ] Labels for images and icons
|
|
644
|
+
|
|
645
|
+
**Examples:**
|
|
646
|
+
|
|
647
|
+
❌ **Bad: No accessibility labels**
|
|
648
|
+
```swift
|
|
649
|
+
Image(systemName: "trash") // ❌ No label
|
|
650
|
+
.onTapGesture {
|
|
651
|
+
deleteItem()
|
|
652
|
+
}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
✅ **Good: Accessibility labels**
|
|
656
|
+
```swift
|
|
657
|
+
Image(systemName: "trash")
|
|
658
|
+
.onTapGesture {
|
|
659
|
+
deleteItem()
|
|
660
|
+
}
|
|
661
|
+
.accessibilityLabel("Delete item") // ✅ Clear label
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
✅ **Good: Accessibility for complex views**
|
|
665
|
+
```swift
|
|
666
|
+
HStack {
|
|
667
|
+
Image(systemName: "star.fill")
|
|
668
|
+
Text("\(rating)")
|
|
669
|
+
}
|
|
670
|
+
.accessibilityElement(children: .combine) // ✅ Combine children
|
|
671
|
+
.accessibilityLabel("Rating: \(rating) stars") // ✅ Clear description
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### 5.2 Accessibility Hints
|
|
675
|
+
|
|
676
|
+
**Check for:**
|
|
677
|
+
- [ ] Hints for non-obvious interactions
|
|
678
|
+
- [ ] Clear, concise hints
|
|
679
|
+
- [ ] No redundant hints
|
|
680
|
+
|
|
681
|
+
**Examples:**
|
|
682
|
+
|
|
683
|
+
✅ **Good: Accessibility hints**
|
|
684
|
+
```swift
|
|
685
|
+
Button("Share") {
|
|
686
|
+
shareContent()
|
|
687
|
+
}
|
|
688
|
+
.accessibilityLabel("Share")
|
|
689
|
+
.accessibilityHint("Opens the share sheet") // ✅ Describes action
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### 5.3 Accessibility Traits
|
|
693
|
+
|
|
694
|
+
**Check for:**
|
|
695
|
+
- [ ] Appropriate traits for elements
|
|
696
|
+
- [ ] Button trait for tappable elements
|
|
697
|
+
- [ ] Header trait for section headers
|
|
698
|
+
|
|
699
|
+
**Examples:**
|
|
700
|
+
|
|
701
|
+
✅ **Good: Accessibility traits**
|
|
702
|
+
```swift
|
|
703
|
+
Text("Settings")
|
|
704
|
+
.font(.title)
|
|
705
|
+
.accessibilityAddTraits(.isHeader) // ✅ Mark as header
|
|
706
|
+
|
|
707
|
+
Image(systemName: "gear")
|
|
708
|
+
.onTapGesture {
|
|
709
|
+
openSettings()
|
|
710
|
+
}
|
|
711
|
+
.accessibilityAddTraits(.isButton) // ✅ Mark as button
|
|
712
|
+
.accessibilityLabel("Settings")
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### 5.4 Dynamic Type Support
|
|
716
|
+
|
|
717
|
+
**Check for:**
|
|
718
|
+
- [ ] System fonts used (automatically scale)
|
|
719
|
+
- [ ] Custom fonts with .dynamicTypeSize
|
|
720
|
+
- [ ] Layout adapts to large text sizes
|
|
721
|
+
|
|
722
|
+
**Examples:**
|
|
723
|
+
|
|
724
|
+
✅ **Good: System fonts (automatic scaling)**
|
|
725
|
+
```swift
|
|
726
|
+
Text("Title")
|
|
727
|
+
.font(.title) // ✅ Automatically scales
|
|
728
|
+
|
|
729
|
+
Text("Body")
|
|
730
|
+
.font(.body) // ✅ Automatically scales
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
✅ **Good: Custom font with scaling**
|
|
734
|
+
```swift
|
|
735
|
+
Text("Custom")
|
|
736
|
+
.font(.custom("CustomFont", size: 16, relativeTo: .body)) // ✅ Scales
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
✅ **Good: Layout adaptation**
|
|
740
|
+
```swift
|
|
741
|
+
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
742
|
+
|
|
743
|
+
var body: some View {
|
|
744
|
+
if dynamicTypeSize.isAccessibilitySize {
|
|
745
|
+
VStack { // ✅ Vertical for large text
|
|
746
|
+
labelView
|
|
747
|
+
valueView
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
HStack { // ✅ Horizontal for normal text
|
|
751
|
+
labelView
|
|
752
|
+
valueView
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
---
|
|
759
|
+
|
|
760
|
+
## 6. Performance Patterns
|
|
761
|
+
|
|
762
|
+
### 6.1 Equatable Conformance
|
|
763
|
+
|
|
764
|
+
**Check for:**
|
|
765
|
+
- [ ] View models conform to Equatable
|
|
766
|
+
- [ ] Proper equality implementation
|
|
767
|
+
- [ ] Reduced view updates
|
|
768
|
+
|
|
769
|
+
**Examples:**
|
|
770
|
+
|
|
771
|
+
✅ **Good: Equatable view model**
|
|
772
|
+
```swift
|
|
773
|
+
@Observable
|
|
774
|
+
final class UserViewModel: Equatable { // ✅ Equatable
|
|
775
|
+
let id: UUID
|
|
776
|
+
var name: String
|
|
777
|
+
var email: String
|
|
778
|
+
|
|
779
|
+
static func == (lhs: UserViewModel, rhs: UserViewModel) -> Bool {
|
|
780
|
+
lhs.id == rhs.id &&
|
|
781
|
+
lhs.name == rhs.name &&
|
|
782
|
+
lhs.email == rhs.email
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
struct UserRow: View {
|
|
787
|
+
let viewModel: UserViewModel
|
|
788
|
+
|
|
789
|
+
var body: some View {
|
|
790
|
+
HStack {
|
|
791
|
+
Text(viewModel.name)
|
|
792
|
+
Text(viewModel.email)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
.equatable() // ✅ Only updates when viewModel changes
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
### 6.2 Avoid Heavy Work in Body
|
|
800
|
+
|
|
801
|
+
**Check for:**
|
|
802
|
+
- [ ] No computation in body property
|
|
803
|
+
- [ ] Computed properties for derived values
|
|
804
|
+
- [ ] View model handles complex logic
|
|
805
|
+
|
|
806
|
+
**Examples:**
|
|
807
|
+
|
|
808
|
+
❌ **Bad: Computation in body**
|
|
809
|
+
```swift
|
|
810
|
+
var body: some View {
|
|
811
|
+
let sortedItems = items.sorted { $0.date > $1.date } // ❌ Every render
|
|
812
|
+
List(sortedItems) { item in
|
|
813
|
+
ItemRow(item: item)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
✅ **Good: Computed property or view model**
|
|
819
|
+
```swift
|
|
820
|
+
@Observable
|
|
821
|
+
final class ItemListViewModel {
|
|
822
|
+
var items: [Item] = []
|
|
823
|
+
|
|
824
|
+
var sortedItems: [Item] { // ✅ Computed property
|
|
825
|
+
items.sorted { $0.date > $1.date }
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
var body: some View {
|
|
830
|
+
List(viewModel.sortedItems) { item in // ✅ Uses cached result
|
|
831
|
+
ItemRow(item: item)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
---
|
|
837
|
+
|
|
838
|
+
## 7. Preview Configurations
|
|
839
|
+
|
|
840
|
+
### 7.1 Preview Macros (iOS 17+)
|
|
841
|
+
|
|
842
|
+
**Check for:**
|
|
843
|
+
- [ ] #Preview macro used instead of PreviewProvider
|
|
844
|
+
- [ ] Multiple preview configurations
|
|
845
|
+
- [ ] Sample data for previews
|
|
846
|
+
|
|
847
|
+
**Examples:**
|
|
848
|
+
|
|
849
|
+
❌ **Bad: Old PreviewProvider**
|
|
850
|
+
```swift
|
|
851
|
+
struct LoginView_Previews: PreviewProvider { // ❌ Old pattern
|
|
852
|
+
static var previews: some View {
|
|
853
|
+
LoginView()
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
✅ **Good: Modern #Preview macro**
|
|
859
|
+
```swift
|
|
860
|
+
#Preview { // ✅ Modern preview (iOS 17+)
|
|
861
|
+
LoginView()
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
#Preview("Dark Mode") { // ✅ Named preview
|
|
865
|
+
LoginView()
|
|
866
|
+
.preferredColorScheme(.dark)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
#Preview("Large Text") { // ✅ Accessibility preview
|
|
870
|
+
LoginView()
|
|
871
|
+
.environment(\.dynamicTypeSize, .xxxLarge)
|
|
872
|
+
}
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
## Quick Reference Checklist
|
|
878
|
+
|
|
879
|
+
### Critical Issues
|
|
880
|
+
- [ ] No @StateObject with @Observable classes (iOS 17+)
|
|
881
|
+
- [ ] No @Published with @Observable classes
|
|
882
|
+
- [ ] No heavy computation in view body
|
|
883
|
+
- [ ] Proper state ownership (single source of truth)
|
|
884
|
+
|
|
885
|
+
### High Priority
|
|
886
|
+
- [ ] @Observable used for view models (iOS 17+)
|
|
887
|
+
- [ ] NavigationStack instead of NavigationView (iOS 16+)
|
|
888
|
+
- [ ] .task instead of .onAppear for async work (iOS 15+)
|
|
889
|
+
- [ ] Proper property wrapper selection
|
|
890
|
+
- [ ] View extraction for complex views
|
|
891
|
+
|
|
892
|
+
### Medium Priority
|
|
893
|
+
- [ ] Modern .onChange syntax (iOS 17+)
|
|
894
|
+
- [ ] Accessibility labels and hints
|
|
895
|
+
- [ ] Dynamic Type support
|
|
896
|
+
- [ ] Equatable conformance for view models
|
|
897
|
+
- [ ] #Preview macro (iOS 17+)
|
|
898
|
+
|
|
899
|
+
### Low Priority
|
|
900
|
+
- [ ] View body < 50 lines
|
|
901
|
+
- [ ] MARK comments for subviews
|
|
902
|
+
- [ ] Preview configurations for testing
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
## Version
|
|
907
|
+
**Last Updated**: 2026-02-10
|
|
908
|
+
**Version**: 1.0.0
|
|
909
|
+
**iOS Version**: iOS 17+, macOS 14+, watchOS 10+, tvOS 17+, visionOS 1+
|