@tuan_son.dinh/gsd 2.6.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/LICENSE +21 -0
- package/README.md +453 -0
- package/dist/app-paths.d.ts +4 -0
- package/dist/app-paths.js +6 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +269 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +70 -0
- package/dist/logo.d.ts +16 -0
- package/dist/logo.js +25 -0
- package/dist/onboarding.d.ts +43 -0
- package/dist/onboarding.js +418 -0
- package/dist/pi-migration.d.ts +14 -0
- package/dist/pi-migration.js +57 -0
- package/dist/resource-loader.d.ts +22 -0
- package/dist/resource-loader.js +60 -0
- package/dist/tool-bootstrap.d.ts +4 -0
- package/dist/tool-bootstrap.js +74 -0
- package/dist/wizard.d.ts +7 -0
- package/dist/wizard.js +25 -0
- package/package.json +60 -0
- package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
- package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
- package/pkg/dist/modes/interactive/theme/dark.json +85 -0
- package/pkg/dist/modes/interactive/theme/light.json +84 -0
- package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
- package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
- package/pkg/dist/modes/interactive/theme/theme.js +949 -0
- package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
- package/pkg/package.json +8 -0
- package/scripts/postinstall.js +127 -0
- package/src/resources/GSD-WORKFLOW.md +661 -0
- package/src/resources/agents/researcher.md +29 -0
- package/src/resources/agents/scout.md +56 -0
- package/src/resources/agents/worker.md +31 -0
- package/src/resources/extensions/ask-user-questions.ts +249 -0
- package/src/resources/extensions/bg-shell/index.ts +2808 -0
- package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
- package/src/resources/extensions/browser-tools/core.js +1057 -0
- package/src/resources/extensions/browser-tools/index.ts +4989 -0
- package/src/resources/extensions/browser-tools/package.json +20 -0
- package/src/resources/extensions/context7/index.ts +428 -0
- package/src/resources/extensions/context7/package.json +11 -0
- package/src/resources/extensions/get-secrets-from-user.ts +352 -0
- package/src/resources/extensions/google-search/index.ts +323 -0
- package/src/resources/extensions/google-search/package.json +9 -0
- package/src/resources/extensions/gsd/activity-log.ts +69 -0
- package/src/resources/extensions/gsd/auto.ts +2744 -0
- package/src/resources/extensions/gsd/commands.ts +313 -0
- package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
- package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
- package/src/resources/extensions/gsd/doctor.ts +690 -0
- package/src/resources/extensions/gsd/files.ts +732 -0
- package/src/resources/extensions/gsd/git-service.ts +597 -0
- package/src/resources/extensions/gsd/gitignore.ts +168 -0
- package/src/resources/extensions/gsd/guided-flow.ts +817 -0
- package/src/resources/extensions/gsd/index.ts +558 -0
- package/src/resources/extensions/gsd/metrics.ts +374 -0
- package/src/resources/extensions/gsd/migrate/command.ts +218 -0
- package/src/resources/extensions/gsd/migrate/index.ts +42 -0
- package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
- package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
- package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
- package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
- package/src/resources/extensions/gsd/migrate/types.ts +370 -0
- package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
- package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
- package/src/resources/extensions/gsd/observability-validator.ts +408 -0
- package/src/resources/extensions/gsd/package.json +11 -0
- package/src/resources/extensions/gsd/paths.ts +308 -0
- package/src/resources/extensions/gsd/preferences.ts +757 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
- package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
- package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
- package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
- package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
- package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
- package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
- package/src/resources/extensions/gsd/prompts/queue.md +85 -0
- package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
- package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
- package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
- package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
- package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
- package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
- package/src/resources/extensions/gsd/prompts/system.md +187 -0
- package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
- package/src/resources/extensions/gsd/session-forensics.ts +487 -0
- package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
- package/src/resources/extensions/gsd/state.ts +460 -0
- package/src/resources/extensions/gsd/templates/context.md +76 -0
- package/src/resources/extensions/gsd/templates/decisions.md +8 -0
- package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
- package/src/resources/extensions/gsd/templates/plan.md +131 -0
- package/src/resources/extensions/gsd/templates/preferences.md +24 -0
- package/src/resources/extensions/gsd/templates/project.md +31 -0
- package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
- package/src/resources/extensions/gsd/templates/requirements.md +81 -0
- package/src/resources/extensions/gsd/templates/research.md +46 -0
- package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
- package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
- package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
- package/src/resources/extensions/gsd/templates/state.md +19 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
- package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
- package/src/resources/extensions/gsd/templates/uat.md +54 -0
- package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
- package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
- package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
- package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
- package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
- package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
- package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
- package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
- package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
- package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
- package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
- package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
- package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
- package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
- package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
- package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
- package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
- package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
- package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
- package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
- package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
- package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
- package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
- package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
- package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
- package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
- package/src/resources/extensions/gsd/types.ts +159 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
- package/src/resources/extensions/gsd/workspace-index.ts +203 -0
- package/src/resources/extensions/gsd/worktree-command.ts +845 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
- package/src/resources/extensions/gsd/worktree.ts +183 -0
- package/src/resources/extensions/mac-tools/index.ts +852 -0
- package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
- package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
- package/src/resources/extensions/mcporter/index.ts +429 -0
- package/src/resources/extensions/remote-questions/config.ts +81 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
- package/src/resources/extensions/remote-questions/format.ts +163 -0
- package/src/resources/extensions/remote-questions/manager.ts +192 -0
- package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
- package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
- package/src/resources/extensions/remote-questions/status.ts +31 -0
- package/src/resources/extensions/remote-questions/store.ts +77 -0
- package/src/resources/extensions/remote-questions/types.ts +75 -0
- package/src/resources/extensions/search-the-web/cache.ts +78 -0
- package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
- package/src/resources/extensions/search-the-web/format.ts +258 -0
- package/src/resources/extensions/search-the-web/http.ts +238 -0
- package/src/resources/extensions/search-the-web/index.ts +65 -0
- package/src/resources/extensions/search-the-web/native-search.ts +157 -0
- package/src/resources/extensions/search-the-web/provider.ts +118 -0
- package/src/resources/extensions/search-the-web/tavily.ts +116 -0
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
- package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
- package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
- package/src/resources/extensions/shared/confirm-ui.ts +126 -0
- package/src/resources/extensions/shared/interview-ui.ts +613 -0
- package/src/resources/extensions/shared/next-action-ui.ts +197 -0
- package/src/resources/extensions/shared/progress-widget.ts +282 -0
- package/src/resources/extensions/shared/terminal.ts +23 -0
- package/src/resources/extensions/shared/thinking-widget.ts +107 -0
- package/src/resources/extensions/shared/ui.ts +400 -0
- package/src/resources/extensions/shared/wizard-ui.ts +551 -0
- package/src/resources/extensions/slash-commands/audit.ts +88 -0
- package/src/resources/extensions/slash-commands/clear.ts +10 -0
- package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
- package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
- package/src/resources/extensions/slash-commands/index.ts +12 -0
- package/src/resources/extensions/subagent/agents.ts +126 -0
- package/src/resources/extensions/subagent/index.ts +1020 -0
- package/src/resources/extensions/voice/index.ts +195 -0
- package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
- package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
- package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
- package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
- package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
- package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
- package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
- package/src/resources/skills/frontend-design/SKILL.md +45 -0
- package/src/resources/skills/swiftui/SKILL.md +208 -0
- package/src/resources/skills/swiftui/references/animations.md +921 -0
- package/src/resources/skills/swiftui/references/architecture.md +1561 -0
- package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
- package/src/resources/skills/swiftui/references/navigation.md +1492 -0
- package/src/resources/skills/swiftui/references/networking-async.md +214 -0
- package/src/resources/skills/swiftui/references/performance.md +1706 -0
- package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
- package/src/resources/skills/swiftui/references/state-management.md +1443 -0
- package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
- package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
- package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
- package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
- package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
- package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
- package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
- package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
- package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
<overview>
|
|
2
|
+
SwiftUI animations are declarative and state-driven. When state changes, SwiftUI automatically animates views from old to new values. Your role is to control timing curves, duration, and which state changes trigger animations.
|
|
3
|
+
|
|
4
|
+
Key insight: Animations are automatic when state changes - you control timing/curve, not the mechanics.
|
|
5
|
+
|
|
6
|
+
This file covers:
|
|
7
|
+
- Implicit vs explicit animations
|
|
8
|
+
- Spring animations (iOS 17+ duration/bounce API)
|
|
9
|
+
- Transitions for appearing/disappearing views
|
|
10
|
+
- matchedGeometryEffect for hero animations
|
|
11
|
+
- PhaseAnimator and KeyframeAnimator (iOS 17+)
|
|
12
|
+
- Gesture-driven animations
|
|
13
|
+
|
|
14
|
+
See also:
|
|
15
|
+
- navigation.md for NavigationStack transitions
|
|
16
|
+
- performance.md for animation optimization strategies
|
|
17
|
+
</overview>
|
|
18
|
+
|
|
19
|
+
<implicit_animations>
|
|
20
|
+
## Implicit Animations (.animation modifier)
|
|
21
|
+
|
|
22
|
+
Implicit animations apply whenever an animatable property changes on a view. Always specify which value triggers the animation using the `value:` parameter to prevent unexpected animations.
|
|
23
|
+
|
|
24
|
+
**Basic usage:**
|
|
25
|
+
```swift
|
|
26
|
+
struct ContentView: View {
|
|
27
|
+
@State private var scale: CGFloat = 1.0
|
|
28
|
+
|
|
29
|
+
var body: some View {
|
|
30
|
+
Circle()
|
|
31
|
+
.fill(.blue)
|
|
32
|
+
.scaleEffect(scale)
|
|
33
|
+
.animation(.spring(), value: scale)
|
|
34
|
+
.onTapGesture {
|
|
35
|
+
scale = scale == 1.0 ? 1.5 : 1.0
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Animation types:**
|
|
42
|
+
- `.default` - System default spring animation
|
|
43
|
+
- `.linear(duration:)` - Constant speed from start to finish
|
|
44
|
+
- `.easeIn(duration:)` - Starts slow, accelerates
|
|
45
|
+
- `.easeOut(duration:)` - Starts fast, decelerates
|
|
46
|
+
- `.easeInOut(duration:)` - Slow start and end, fast middle
|
|
47
|
+
- `.spring()` - iOS 17+ spring with default parameters
|
|
48
|
+
- `.bouncy` - Preset spring with high bounce
|
|
49
|
+
- `.snappy` - Preset spring with quick, slight bounce
|
|
50
|
+
- `.smooth` - Preset spring with no bounce
|
|
51
|
+
|
|
52
|
+
**Value-specific animation:**
|
|
53
|
+
```swift
|
|
54
|
+
struct MultiPropertyView: View {
|
|
55
|
+
@State private var rotation: Double = 0
|
|
56
|
+
@State private var scale: CGFloat = 1.0
|
|
57
|
+
|
|
58
|
+
var body: some View {
|
|
59
|
+
Rectangle()
|
|
60
|
+
.fill(.red)
|
|
61
|
+
.scaleEffect(scale)
|
|
62
|
+
.rotationEffect(.degrees(rotation))
|
|
63
|
+
.animation(.spring(), value: rotation) // Only animate rotation
|
|
64
|
+
.animation(.easeInOut, value: scale) // Different animation for scale
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Why always use value: parameter:**
|
|
70
|
+
- Prevents unexpected animations on unrelated state changes
|
|
71
|
+
- Device rotation won't trigger animations
|
|
72
|
+
- More predictable behavior
|
|
73
|
+
- Better performance (only tracks specific value)
|
|
74
|
+
</implicit_animations>
|
|
75
|
+
|
|
76
|
+
<explicit_animations>
|
|
77
|
+
## Explicit Animations (withAnimation)
|
|
78
|
+
|
|
79
|
+
Explicit animations only affect properties that depend on values changed inside the `withAnimation` closure. Preferred for user-triggered actions.
|
|
80
|
+
|
|
81
|
+
**Basic usage:**
|
|
82
|
+
```swift
|
|
83
|
+
struct ContentView: View {
|
|
84
|
+
@State private var isExpanded = false
|
|
85
|
+
|
|
86
|
+
var body: some View {
|
|
87
|
+
VStack {
|
|
88
|
+
if isExpanded {
|
|
89
|
+
Text("Details")
|
|
90
|
+
.transition(.opacity)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
Button("Toggle") {
|
|
94
|
+
withAnimation(.spring()) {
|
|
95
|
+
isExpanded.toggle()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Completion handlers (iOS 17+):**
|
|
104
|
+
```swift
|
|
105
|
+
Button("Animate") {
|
|
106
|
+
withAnimation(.easeInOut(duration: 1.0)) {
|
|
107
|
+
offset.y = 200
|
|
108
|
+
} completion: {
|
|
109
|
+
// Animation finished - safe to perform next action
|
|
110
|
+
showNextStep = true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Transaction-based:**
|
|
116
|
+
```swift
|
|
117
|
+
var transaction = Transaction(animation: .spring())
|
|
118
|
+
transaction.disablesAnimations = true // Temporarily disable animations
|
|
119
|
+
|
|
120
|
+
withTransaction(transaction) {
|
|
121
|
+
someState.toggle()
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Removing animations temporarily:**
|
|
126
|
+
```swift
|
|
127
|
+
withAnimation(nil) {
|
|
128
|
+
// Changes happen immediately without animation
|
|
129
|
+
resetState()
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
</explicit_animations>
|
|
133
|
+
|
|
134
|
+
<spring_animations>
|
|
135
|
+
## Spring Animations
|
|
136
|
+
|
|
137
|
+
Springs are the default animation in SwiftUI. They feel natural because they mimic real-world physics.
|
|
138
|
+
|
|
139
|
+
**Modern spring parameters (iOS 17+):**
|
|
140
|
+
```swift
|
|
141
|
+
// Duration and bounce control
|
|
142
|
+
.spring(duration: 0.5, bounce: 0.3)
|
|
143
|
+
|
|
144
|
+
// No bounce with blend duration for smooth transitions
|
|
145
|
+
.spring(duration: 0.5, bounce: 0, blendDuration: 0.2)
|
|
146
|
+
|
|
147
|
+
// With initial velocity for gesture-driven animations
|
|
148
|
+
.spring(duration: 0.6, bounce: 0.4)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Bounce parameter:**
|
|
152
|
+
- `-1.0` to `1.0` range
|
|
153
|
+
- `0` = no bounce (critically damped)
|
|
154
|
+
- `0.3` to `0.5` = natural bounce
|
|
155
|
+
- `0.7` to `1.0` = exaggerated bounce
|
|
156
|
+
- Negative values create "anticipation" (overshoots in opposite direction first)
|
|
157
|
+
|
|
158
|
+
**Presets (iOS 17+):**
|
|
159
|
+
```swift
|
|
160
|
+
.bouncy // High bounce - playful, attention-grabbing
|
|
161
|
+
.snappy // Quick with slight bounce - feels responsive
|
|
162
|
+
.smooth // No bounce - elegant, sophisticated
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Tuning workflow:**
|
|
166
|
+
1. Start with duration that feels right
|
|
167
|
+
2. Adjust bounce to set character/feeling
|
|
168
|
+
3. Use presets first, then customize if needed
|
|
169
|
+
|
|
170
|
+
**Legacy spring (still works):**
|
|
171
|
+
```swift
|
|
172
|
+
// For backward compatibility or precise control
|
|
173
|
+
.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**When to use springs:**
|
|
177
|
+
- User interactions (button presses, drags)
|
|
178
|
+
- Most UI state changes
|
|
179
|
+
- Default choice unless you need precise timing
|
|
180
|
+
</spring_animations>
|
|
181
|
+
|
|
182
|
+
<transitions>
|
|
183
|
+
## Transitions
|
|
184
|
+
|
|
185
|
+
Transitions control how views appear and disappear. Applied with `.transition()` modifier, animated by wrapping insertion/removal in `withAnimation`.
|
|
186
|
+
|
|
187
|
+
**Built-in transitions:**
|
|
188
|
+
```swift
|
|
189
|
+
struct TransitionsDemo: View {
|
|
190
|
+
@State private var showDetail = false
|
|
191
|
+
|
|
192
|
+
var body: some View {
|
|
193
|
+
VStack {
|
|
194
|
+
if showDetail {
|
|
195
|
+
Text("Detail")
|
|
196
|
+
.transition(.opacity) // Fade in/out
|
|
197
|
+
// .transition(.slide) // Slide from leading edge
|
|
198
|
+
// .transition(.scale) // Grow/shrink from center
|
|
199
|
+
// .transition(.move(edge: .bottom)) // Slide from bottom
|
|
200
|
+
// .transition(.push(from: .leading)) // Push from leading (iOS 16+)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
Button("Toggle") {
|
|
204
|
+
withAnimation {
|
|
205
|
+
showDetail.toggle()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Combining transitions:**
|
|
214
|
+
```swift
|
|
215
|
+
// Both opacity and scale together
|
|
216
|
+
.transition(.opacity.combined(with: .scale))
|
|
217
|
+
|
|
218
|
+
// Different insertion and removal
|
|
219
|
+
.transition(.asymmetric(
|
|
220
|
+
insertion: .move(edge: .leading).combined(with: .opacity),
|
|
221
|
+
removal: .move(edge: .trailing).combined(with: .opacity)
|
|
222
|
+
))
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Custom transitions:**
|
|
226
|
+
```swift
|
|
227
|
+
struct RotateModifier: ViewModifier {
|
|
228
|
+
let rotation: Double
|
|
229
|
+
|
|
230
|
+
func body(content: Content) -> some View {
|
|
231
|
+
content
|
|
232
|
+
.rotationEffect(.degrees(rotation))
|
|
233
|
+
.opacity(rotation == 0 ? 1 : 0)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
extension AnyTransition {
|
|
238
|
+
static var pivot: AnyTransition {
|
|
239
|
+
.modifier(
|
|
240
|
+
active: RotateModifier(rotation: -90),
|
|
241
|
+
identity: RotateModifier(rotation: 0)
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Usage
|
|
247
|
+
Text("Pivoting in")
|
|
248
|
+
.transition(.pivot)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Identity vs insertion/removal:**
|
|
252
|
+
- `identity` = final state when view is visible
|
|
253
|
+
- `active` = state during transition (appearing/disappearing)
|
|
254
|
+
</transitions>
|
|
255
|
+
|
|
256
|
+
<matched_geometry>
|
|
257
|
+
## matchedGeometryEffect
|
|
258
|
+
|
|
259
|
+
Synchronizes geometry between two views with the same ID, creating hero animations. Views don't need to be in the same container.
|
|
260
|
+
|
|
261
|
+
**Basic hero animation:**
|
|
262
|
+
```swift
|
|
263
|
+
struct HeroDemo: View {
|
|
264
|
+
@State private var isExpanded = false
|
|
265
|
+
@Namespace private var animation
|
|
266
|
+
|
|
267
|
+
var body: some View {
|
|
268
|
+
VStack {
|
|
269
|
+
if !isExpanded {
|
|
270
|
+
// Thumbnail state
|
|
271
|
+
Circle()
|
|
272
|
+
.fill(.blue)
|
|
273
|
+
.frame(width: 60, height: 60)
|
|
274
|
+
.matchedGeometryEffect(id: "circle", in: animation)
|
|
275
|
+
.onTapGesture {
|
|
276
|
+
withAnimation(.spring()) {
|
|
277
|
+
isExpanded = true
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
// Expanded state
|
|
282
|
+
VStack {
|
|
283
|
+
Circle()
|
|
284
|
+
.fill(.blue)
|
|
285
|
+
.frame(width: 200, height: 200)
|
|
286
|
+
.matchedGeometryEffect(id: "circle", in: animation)
|
|
287
|
+
|
|
288
|
+
Button("Close") {
|
|
289
|
+
withAnimation(.spring()) {
|
|
290
|
+
isExpanded = false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Creating namespace:**
|
|
301
|
+
```swift
|
|
302
|
+
@Namespace private var animation // Property wrapper creates unique namespace
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**isSource parameter:**
|
|
306
|
+
Controls which view provides geometry during transition.
|
|
307
|
+
|
|
308
|
+
```swift
|
|
309
|
+
// Example: Grid to detail view
|
|
310
|
+
struct ContentView: View {
|
|
311
|
+
@State private var selectedItem: Item?
|
|
312
|
+
@Namespace private var namespace
|
|
313
|
+
|
|
314
|
+
var body: some View {
|
|
315
|
+
ZStack {
|
|
316
|
+
// Grid view
|
|
317
|
+
LazyVGrid(columns: columns) {
|
|
318
|
+
ForEach(items) { item in
|
|
319
|
+
ItemCard(item: item)
|
|
320
|
+
.matchedGeometryEffect(
|
|
321
|
+
id: item.id,
|
|
322
|
+
in: namespace,
|
|
323
|
+
isSource: selectedItem == nil // Source when detail not shown
|
|
324
|
+
)
|
|
325
|
+
.onTapGesture {
|
|
326
|
+
selectedItem = item
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Detail view
|
|
332
|
+
if let item = selectedItem {
|
|
333
|
+
DetailView(item: item)
|
|
334
|
+
.matchedGeometryEffect(
|
|
335
|
+
id: item.id,
|
|
336
|
+
in: namespace,
|
|
337
|
+
isSource: selectedItem != nil // Source when detail shown
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
.animation(.spring(), value: selectedItem)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Properties parameter:**
|
|
347
|
+
Control what gets matched.
|
|
348
|
+
|
|
349
|
+
```swift
|
|
350
|
+
.matchedGeometryEffect(
|
|
351
|
+
id: "shape",
|
|
352
|
+
in: namespace,
|
|
353
|
+
properties: .frame // Only match frame, not position
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
// Options: .frame, .position, .size
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Common pitfalls:**
|
|
360
|
+
- **Both views must exist simultaneously** during animation - use conditional rendering carefully
|
|
361
|
+
- **Same ID required** - use stable identifiers (UUIDs, database IDs)
|
|
362
|
+
- **Need explicit animation** - wrap state changes in `withAnimation`
|
|
363
|
+
- **ZStack coordination** - often need ZStack to ensure both views render during transition
|
|
364
|
+
</matched_geometry>
|
|
365
|
+
|
|
366
|
+
<phased_animations>
|
|
367
|
+
## Phased Animations (iOS 17+)
|
|
368
|
+
|
|
369
|
+
PhaseAnimator automatically cycles through animation phases. Ideal for loading indicators, attention-grabbing effects, or multi-step sequences.
|
|
370
|
+
|
|
371
|
+
**PhaseAnimator with continuous cycling:**
|
|
372
|
+
```swift
|
|
373
|
+
struct PulsingCircle: View {
|
|
374
|
+
var body: some View {
|
|
375
|
+
PhaseAnimator([false, true]) { isLarge in
|
|
376
|
+
Circle()
|
|
377
|
+
.fill(.red)
|
|
378
|
+
.scaleEffect(isLarge ? 1.5 : 1.0)
|
|
379
|
+
.opacity(isLarge ? 0.5 : 1.0)
|
|
380
|
+
} animation: { phase in
|
|
381
|
+
.easeInOut(duration: 1.0)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**PhaseAnimator with enum phases:**
|
|
388
|
+
```swift
|
|
389
|
+
enum LoadingPhase: CaseIterable {
|
|
390
|
+
case initial, loading, success
|
|
391
|
+
|
|
392
|
+
var scale: CGFloat {
|
|
393
|
+
switch self {
|
|
394
|
+
case .initial: 1.0
|
|
395
|
+
case .loading: 1.2
|
|
396
|
+
case .success: 1.5
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
var color: Color {
|
|
401
|
+
switch self {
|
|
402
|
+
case .initial: .gray
|
|
403
|
+
case .loading: .blue
|
|
404
|
+
case .success: .green
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
struct LoadingButton: View {
|
|
410
|
+
var body: some View {
|
|
411
|
+
PhaseAnimator(LoadingPhase.allCases) { phase in
|
|
412
|
+
Circle()
|
|
413
|
+
.fill(phase.color)
|
|
414
|
+
.scaleEffect(phase.scale)
|
|
415
|
+
} animation: { phase in
|
|
416
|
+
switch phase {
|
|
417
|
+
case .initial: .easeIn(duration: 0.3)
|
|
418
|
+
case .loading: .easeInOut(duration: 0.5)
|
|
419
|
+
case .success: .spring(duration: 0.6, bounce: 0.4)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
**Trigger-based PhaseAnimator:**
|
|
427
|
+
```swift
|
|
428
|
+
struct TriggerDemo: View {
|
|
429
|
+
@State private var triggerValue = 0
|
|
430
|
+
|
|
431
|
+
var body: some View {
|
|
432
|
+
VStack {
|
|
433
|
+
PhaseAnimator([0, 1, 2], trigger: triggerValue) { phase in
|
|
434
|
+
RoundedRectangle(cornerRadius: 12)
|
|
435
|
+
.fill(.blue)
|
|
436
|
+
.frame(width: 100 + CGFloat(phase * 50), height: 100)
|
|
437
|
+
.offset(x: CGFloat(phase * 20))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
Button("Animate") {
|
|
441
|
+
triggerValue += 1
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Use cases:**
|
|
449
|
+
- Loading spinners and progress indicators
|
|
450
|
+
- Attention-grabbing call-to-action buttons
|
|
451
|
+
- Celebratory success animations
|
|
452
|
+
- Idle state animations
|
|
453
|
+
- Tutorial highlights
|
|
454
|
+
</phased_animations>
|
|
455
|
+
|
|
456
|
+
<keyframe_animations>
|
|
457
|
+
## Keyframe Animations (iOS 17+)
|
|
458
|
+
|
|
459
|
+
KeyframeAnimator provides frame-by-frame control over complex animations. More powerful than PhaseAnimator when you need precise timing and multiple simultaneous property changes.
|
|
460
|
+
|
|
461
|
+
**Basic KeyframeAnimator:**
|
|
462
|
+
```swift
|
|
463
|
+
struct AnimationValues {
|
|
464
|
+
var scale = 1.0
|
|
465
|
+
var rotation = 0.0
|
|
466
|
+
var opacity = 1.0
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
struct KeyframeDemo: View {
|
|
470
|
+
@State private var trigger = false
|
|
471
|
+
|
|
472
|
+
var body: some View {
|
|
473
|
+
KeyframeAnimator(
|
|
474
|
+
initialValue: AnimationValues(),
|
|
475
|
+
trigger: trigger
|
|
476
|
+
) { values in
|
|
477
|
+
Rectangle()
|
|
478
|
+
.fill(.purple)
|
|
479
|
+
.scaleEffect(values.scale)
|
|
480
|
+
.rotationEffect(.degrees(values.rotation))
|
|
481
|
+
.opacity(values.opacity)
|
|
482
|
+
.frame(width: 100, height: 100)
|
|
483
|
+
} keyframes: { _ in
|
|
484
|
+
KeyframeTrack(\.scale) {
|
|
485
|
+
SpringKeyframe(1.5, duration: 0.3)
|
|
486
|
+
CubicKeyframe(0.8, duration: 0.2)
|
|
487
|
+
CubicKeyframe(1.0, duration: 0.2)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
KeyframeTrack(\.rotation) {
|
|
491
|
+
LinearKeyframe(180, duration: 0.4)
|
|
492
|
+
CubicKeyframe(360, duration: 0.3)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
KeyframeTrack(\.opacity) {
|
|
496
|
+
CubicKeyframe(0.5, duration: 0.3)
|
|
497
|
+
CubicKeyframe(1.0, duration: 0.4)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
.onTapGesture {
|
|
501
|
+
trigger.toggle()
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
**Keyframe types:**
|
|
508
|
+
|
|
509
|
+
```swift
|
|
510
|
+
// Linear - constant speed interpolation
|
|
511
|
+
LinearKeyframe(targetValue, duration: 0.5)
|
|
512
|
+
|
|
513
|
+
// Cubic - smooth Bezier curve
|
|
514
|
+
CubicKeyframe(targetValue, duration: 0.5)
|
|
515
|
+
|
|
516
|
+
// Spring - physics-based bounce
|
|
517
|
+
SpringKeyframe(targetValue, duration: 0.5, spring: .bouncy)
|
|
518
|
+
|
|
519
|
+
// Move - jump immediately to value
|
|
520
|
+
MoveKeyframe(targetValue)
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
**Complex multi-property animation:**
|
|
524
|
+
```swift
|
|
525
|
+
struct AnimationState {
|
|
526
|
+
var position: CGPoint = .zero
|
|
527
|
+
var color: Color = .blue
|
|
528
|
+
var size: CGFloat = 50
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
KeyframeAnimator(initialValue: AnimationState(), trigger: animate) { state in
|
|
532
|
+
Circle()
|
|
533
|
+
.fill(state.color)
|
|
534
|
+
.frame(width: state.size, height: state.size)
|
|
535
|
+
.position(state.position)
|
|
536
|
+
} keyframes: { _ in
|
|
537
|
+
KeyframeTrack(\.position) {
|
|
538
|
+
CubicKeyframe(CGPoint(x: 200, y: 100), duration: 0.4)
|
|
539
|
+
SpringKeyframe(CGPoint(x: 200, y: 300), duration: 0.6)
|
|
540
|
+
CubicKeyframe(CGPoint(x: 0, y: 0), duration: 0.5)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
KeyframeTrack(\.color) {
|
|
544
|
+
CubicKeyframe(.red, duration: 0.5)
|
|
545
|
+
CubicKeyframe(.green, duration: 0.5)
|
|
546
|
+
CubicKeyframe(.blue, duration: 0.5)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
KeyframeTrack(\.size) {
|
|
550
|
+
SpringKeyframe(100, duration: 0.6, spring: .bouncy)
|
|
551
|
+
CubicKeyframe(50, duration: 0.4)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
**When to use KeyframeAnimator:**
|
|
557
|
+
- Complex choreographed animations
|
|
558
|
+
- Precise timing control needed
|
|
559
|
+
- Multiple properties animating with different curves
|
|
560
|
+
- Path-based animations
|
|
561
|
+
- Recreating motion design prototypes
|
|
562
|
+
</keyframe_animations>
|
|
563
|
+
|
|
564
|
+
<gesture_animations>
|
|
565
|
+
## Gesture-Driven Animations
|
|
566
|
+
|
|
567
|
+
Interactive animations that respond to user input in real-time.
|
|
568
|
+
|
|
569
|
+
**DragGesture with spring animation:**
|
|
570
|
+
```swift
|
|
571
|
+
struct DraggableCard: View {
|
|
572
|
+
@State private var offset: CGSize = .zero
|
|
573
|
+
|
|
574
|
+
var body: some View {
|
|
575
|
+
RoundedRectangle(cornerRadius: 20)
|
|
576
|
+
.fill(.blue)
|
|
577
|
+
.frame(width: 200, height: 300)
|
|
578
|
+
.offset(offset)
|
|
579
|
+
.gesture(
|
|
580
|
+
DragGesture()
|
|
581
|
+
.onChanged { value in
|
|
582
|
+
offset = value.translation
|
|
583
|
+
}
|
|
584
|
+
.onEnded { _ in
|
|
585
|
+
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
|
|
586
|
+
offset = .zero
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
**Interruptible animations:**
|
|
595
|
+
```swift
|
|
596
|
+
struct InterruptibleView: View {
|
|
597
|
+
@State private var position: CGFloat = 0
|
|
598
|
+
|
|
599
|
+
var body: some View {
|
|
600
|
+
Circle()
|
|
601
|
+
.fill(.red)
|
|
602
|
+
.frame(width: 60, height: 60)
|
|
603
|
+
.offset(y: position)
|
|
604
|
+
.animation(.spring(), value: position)
|
|
605
|
+
.gesture(
|
|
606
|
+
DragGesture()
|
|
607
|
+
.onChanged { value in
|
|
608
|
+
// Interrupts ongoing animation immediately
|
|
609
|
+
position = value.translation.height
|
|
610
|
+
}
|
|
611
|
+
.onEnded { value in
|
|
612
|
+
// Determine snap point based on velocity
|
|
613
|
+
let velocity = value.predictedEndLocation.y - value.location.y
|
|
614
|
+
|
|
615
|
+
if abs(velocity) > 500 {
|
|
616
|
+
position = velocity > 0 ? 300 : -300
|
|
617
|
+
} else {
|
|
618
|
+
position = 0
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**GestureState for automatic reset:**
|
|
627
|
+
```swift
|
|
628
|
+
struct GestureStateExample: View {
|
|
629
|
+
@GestureState private var dragOffset: CGSize = .zero
|
|
630
|
+
@State private var permanentOffset: CGSize = .zero
|
|
631
|
+
|
|
632
|
+
var body: some View {
|
|
633
|
+
Rectangle()
|
|
634
|
+
.fill(.purple)
|
|
635
|
+
.frame(width: 100, height: 100)
|
|
636
|
+
.offset(x: permanentOffset.width + dragOffset.width,
|
|
637
|
+
y: permanentOffset.height + dragOffset.height)
|
|
638
|
+
.gesture(
|
|
639
|
+
DragGesture()
|
|
640
|
+
.updating($dragOffset) { value, state, _ in
|
|
641
|
+
state = value.translation
|
|
642
|
+
}
|
|
643
|
+
.onEnded { value in
|
|
644
|
+
withAnimation(.spring()) {
|
|
645
|
+
permanentOffset.width += value.translation.width
|
|
646
|
+
permanentOffset.height += value.translation.height
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
**Combining gestures with animations:**
|
|
655
|
+
```swift
|
|
656
|
+
struct SwipeToDelete: View {
|
|
657
|
+
@State private var offset: CGFloat = 0
|
|
658
|
+
@State private var isDeleted = false
|
|
659
|
+
|
|
660
|
+
var body: some View {
|
|
661
|
+
if !isDeleted {
|
|
662
|
+
HStack {
|
|
663
|
+
Text("Swipe to delete")
|
|
664
|
+
Spacer()
|
|
665
|
+
}
|
|
666
|
+
.padding()
|
|
667
|
+
.background(.white)
|
|
668
|
+
.offset(x: offset)
|
|
669
|
+
.gesture(
|
|
670
|
+
DragGesture()
|
|
671
|
+
.onChanged { value in
|
|
672
|
+
if value.translation.width < 0 {
|
|
673
|
+
offset = value.translation.width
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
.onEnded { value in
|
|
677
|
+
if offset < -100 {
|
|
678
|
+
withAnimation(.easeOut(duration: 0.3)) {
|
|
679
|
+
offset = -500
|
|
680
|
+
} completion: {
|
|
681
|
+
isDeleted = true
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
withAnimation(.spring()) {
|
|
685
|
+
offset = 0
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
**Velocity-based animations:**
|
|
696
|
+
```swift
|
|
697
|
+
struct VelocityDrag: View {
|
|
698
|
+
@State private var offset: CGSize = .zero
|
|
699
|
+
|
|
700
|
+
var body: some View {
|
|
701
|
+
Circle()
|
|
702
|
+
.fill(.green)
|
|
703
|
+
.frame(width: 80, height: 80)
|
|
704
|
+
.offset(offset)
|
|
705
|
+
.gesture(
|
|
706
|
+
DragGesture()
|
|
707
|
+
.onChanged { value in
|
|
708
|
+
offset = value.translation
|
|
709
|
+
}
|
|
710
|
+
.onEnded { value in
|
|
711
|
+
let velocity = value.velocity
|
|
712
|
+
|
|
713
|
+
// Use velocity magnitude to determine spring response
|
|
714
|
+
let speed = sqrt(velocity.width * velocity.width +
|
|
715
|
+
velocity.height * velocity.height)
|
|
716
|
+
|
|
717
|
+
let animation: Animation = speed > 1000
|
|
718
|
+
? .spring(duration: 0.4, bounce: 0.5)
|
|
719
|
+
: .spring(duration: 0.6, bounce: 0.3)
|
|
720
|
+
|
|
721
|
+
withAnimation(animation) {
|
|
722
|
+
offset = .zero
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
</gesture_animations>
|
|
730
|
+
|
|
731
|
+
<decision_tree>
|
|
732
|
+
## Choosing the Right Animation
|
|
733
|
+
|
|
734
|
+
**Simple state change:**
|
|
735
|
+
- Use `.animation(.default, value: state)` for single property changes
|
|
736
|
+
- Implicit animation is simplest approach
|
|
737
|
+
|
|
738
|
+
**User-triggered change:**
|
|
739
|
+
- Use `withAnimation { }` for button taps, user actions
|
|
740
|
+
- Explicit animation provides better control
|
|
741
|
+
- Use completion handlers (iOS 17+) for sequential actions
|
|
742
|
+
|
|
743
|
+
**View appearing/disappearing:**
|
|
744
|
+
- Use `.transition()` for conditional views
|
|
745
|
+
- Combine with `withAnimation` to trigger
|
|
746
|
+
- Consider `.asymmetric()` for different in/out animations
|
|
747
|
+
|
|
748
|
+
**Shared element between screens:**
|
|
749
|
+
- Use `matchedGeometryEffect` for hero animations
|
|
750
|
+
- Requires both views to exist during transition
|
|
751
|
+
- Best with `@Namespace` and explicit animations
|
|
752
|
+
|
|
753
|
+
**Multi-step sequence:**
|
|
754
|
+
- Use `PhaseAnimator` (iOS 17+) for simple phase-based sequences
|
|
755
|
+
- Great for loading states, idle animations
|
|
756
|
+
- Trigger-based for user-initiated sequences
|
|
757
|
+
|
|
758
|
+
**Complex keyframed motion:**
|
|
759
|
+
- Use `KeyframeAnimator` (iOS 17+) for precise timing
|
|
760
|
+
- Multiple properties with independent curves
|
|
761
|
+
- Recreating motion design specs
|
|
762
|
+
|
|
763
|
+
**User-controlled motion:**
|
|
764
|
+
- Use `DragGesture` + animation for interactive elements
|
|
765
|
+
- `@GestureState` for automatic state reset
|
|
766
|
+
- Consider velocity for natural physics
|
|
767
|
+
|
|
768
|
+
**Performance tips:**
|
|
769
|
+
- Animate opacity, scale, offset (cheap)
|
|
770
|
+
- Avoid animating frame size, padding (expensive)
|
|
771
|
+
- Use `.drawingGroup()` for complex hierarchies being animated
|
|
772
|
+
- Avoid animating during scroll (competes with scroll performance)
|
|
773
|
+
- Profile with Instruments if animations drop frames
|
|
774
|
+
</decision_tree>
|
|
775
|
+
|
|
776
|
+
<anti_patterns>
|
|
777
|
+
## What NOT to Do
|
|
778
|
+
|
|
779
|
+
<anti_pattern name="Animation without value parameter">
|
|
780
|
+
**Problem:**
|
|
781
|
+
```swift
|
|
782
|
+
.animation(.spring()) // No value parameter
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
**Why it's bad:**
|
|
786
|
+
Animates every property change, including device rotation, parent view updates, and unrelated state changes. Creates unexpected animations and performance issues.
|
|
787
|
+
|
|
788
|
+
**Instead:**
|
|
789
|
+
```swift
|
|
790
|
+
.animation(.spring(), value: specificState)
|
|
791
|
+
```
|
|
792
|
+
</anti_pattern>
|
|
793
|
+
|
|
794
|
+
<anti_pattern name="Animating layout-heavy properties">
|
|
795
|
+
**Problem:**
|
|
796
|
+
```swift
|
|
797
|
+
withAnimation {
|
|
798
|
+
frameWidth = 300 // Triggers layout recalculation
|
|
799
|
+
padding = 20 // Triggers layout recalculation
|
|
800
|
+
}
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**Why it's bad:**
|
|
804
|
+
Frame size and padding changes force SwiftUI to recalculate layout, which is expensive. Can cause stuttering on complex views.
|
|
805
|
+
|
|
806
|
+
**Instead:**
|
|
807
|
+
```swift
|
|
808
|
+
withAnimation {
|
|
809
|
+
scale = 1.5 // Cheap transform
|
|
810
|
+
opacity = 0.5 // Cheap property
|
|
811
|
+
offset = CGSize(width: 20, height: 0) // Cheap transform
|
|
812
|
+
}
|
|
813
|
+
```
|
|
814
|
+
</anti_pattern>
|
|
815
|
+
|
|
816
|
+
<anti_pattern name="matchedGeometryEffect without namespace">
|
|
817
|
+
**Problem:**
|
|
818
|
+
```swift
|
|
819
|
+
Circle()
|
|
820
|
+
.matchedGeometryEffect(id: "circle", in: ???) // Forgot @Namespace
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
**Why it's bad:**
|
|
824
|
+
Won't compile. Namespace is required to coordinate geometry matching.
|
|
825
|
+
|
|
826
|
+
**Instead:**
|
|
827
|
+
```swift
|
|
828
|
+
@Namespace private var animation
|
|
829
|
+
|
|
830
|
+
Circle()
|
|
831
|
+
.matchedGeometryEffect(id: "circle", in: animation)
|
|
832
|
+
```
|
|
833
|
+
</anti_pattern>
|
|
834
|
+
|
|
835
|
+
<anti_pattern name="Nested withAnimation blocks">
|
|
836
|
+
**Problem:**
|
|
837
|
+
```swift
|
|
838
|
+
withAnimation(.easeIn) {
|
|
839
|
+
withAnimation(.spring()) {
|
|
840
|
+
state = newValue
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
**Why it's bad:**
|
|
846
|
+
Inner animation is ignored. Only outer animation applies. Creates confusion about which animation runs.
|
|
847
|
+
|
|
848
|
+
**Instead:**
|
|
849
|
+
```swift
|
|
850
|
+
withAnimation(.spring()) {
|
|
851
|
+
state = newValue
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
</anti_pattern>
|
|
855
|
+
|
|
856
|
+
<anti_pattern name="Transition without withAnimation">
|
|
857
|
+
**Problem:**
|
|
858
|
+
```swift
|
|
859
|
+
if showDetail {
|
|
860
|
+
DetailView()
|
|
861
|
+
.transition(.slide) // Transition defined but not triggered
|
|
862
|
+
}
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
**Why it's bad:**
|
|
866
|
+
View appears/disappears instantly. Transition is never applied without animation context.
|
|
867
|
+
|
|
868
|
+
**Instead:**
|
|
869
|
+
```swift
|
|
870
|
+
Button("Toggle") {
|
|
871
|
+
withAnimation {
|
|
872
|
+
showDetail.toggle()
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
</anti_pattern>
|
|
877
|
+
|
|
878
|
+
<anti_pattern name="Animating computed properties">
|
|
879
|
+
**Problem:**
|
|
880
|
+
```swift
|
|
881
|
+
var computedValue: Double {
|
|
882
|
+
return stateA * stateB
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
.animation(.spring(), value: computedValue)
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
**Why it's bad:**
|
|
889
|
+
Computed properties can change for many reasons. Animation triggers on any dependency change, not just intentional updates.
|
|
890
|
+
|
|
891
|
+
**Instead:**
|
|
892
|
+
```swift
|
|
893
|
+
.animation(.spring(), value: stateA)
|
|
894
|
+
.animation(.spring(), value: stateB)
|
|
895
|
+
```
|
|
896
|
+
</anti_pattern>
|
|
897
|
+
|
|
898
|
+
<anti_pattern name="matchedGeometryEffect with overlapping views">
|
|
899
|
+
**Problem:**
|
|
900
|
+
```swift
|
|
901
|
+
// Both views exist at same time with same ID
|
|
902
|
+
GridItem()
|
|
903
|
+
.matchedGeometryEffect(id: item.id, in: namespace)
|
|
904
|
+
|
|
905
|
+
DetailItem()
|
|
906
|
+
.matchedGeometryEffect(id: item.id, in: namespace)
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
**Why it's bad:**
|
|
910
|
+
Without proper `isSource` configuration, SwiftUI doesn't know which view's geometry to use. Creates unpredictable animations.
|
|
911
|
+
|
|
912
|
+
**Instead:**
|
|
913
|
+
```swift
|
|
914
|
+
GridItem()
|
|
915
|
+
.matchedGeometryEffect(id: item.id, in: namespace, isSource: selectedItem == nil)
|
|
916
|
+
|
|
917
|
+
DetailItem()
|
|
918
|
+
.matchedGeometryEffect(id: item.id, in: namespace, isSource: selectedItem != nil)
|
|
919
|
+
```
|
|
920
|
+
</anti_pattern>
|
|
921
|
+
</anti_patterns>
|