From 477da6c82d3d0e651988e52709c95cfe87a7c611 Mon Sep 17 00:00:00 2001 From: Omar Elsayed Date: Thu, 29 Jan 2026 12:11:58 +0200 Subject: [PATCH 1/8] added animation refrenece for the skill --- swiftui-expert-skill/SKILL.md | 25 + .../references/animation-patterns.md | 686 ++++++++++++++++++ 2 files changed, 711 insertions(+) create mode 100644 swiftui-expert-skill/references/animation-patterns.md diff --git a/swiftui-expert-skill/SKILL.md b/swiftui-expert-skill/SKILL.md index 851f875..ba23048 100644 --- a/swiftui-expert-skill/SKILL.md +++ b/swiftui-expert-skill/SKILL.md @@ -16,6 +16,7 @@ Use this skill to build, review, or improve SwiftUI features with correct state - Verify view composition follows extraction rules (see `references/view-structure.md`) - Check performance patterns are applied (see `references/performance-patterns.md`) - Verify list patterns use stable identity (see `references/list-patterns.md`) +- Check animation patterns for correctness (see `references/animation-patterns.md`) - Inspect Liquid Glass usage for correctness and consistency (see `references/liquid-glass.md`) - Validate iOS 26+ availability handling with sensible fallbacks @@ -25,6 +26,7 @@ Use this skill to build, review, or improve SwiftUI features with correct state - Extract complex views into separate subviews (see `references/view-structure.md`) - Refactor hot paths to minimize redundant state updates (see `references/performance-patterns.md`) - Ensure ForEach uses stable identity (see `references/list-patterns.md`) +- Improve animation patterns (use value parameter, proper transitions, see `references/animation-patterns.md`) - Suggest image downsampling when `UIImage(data:)` is used (as optional optimization, see `references/image-optimization.md`) - Adopt Liquid Glass only when explicitly requested by the user @@ -34,6 +36,7 @@ Use this skill to build, review, or improve SwiftUI features with correct state - Use `@Observable` for shared state (with `@MainActor` if not using default actor isolation) - Structure views for optimal diffing (extract subviews early, keep views small, see `references/view-structure.md`) - Separate business logic into testable models (see `references/layout-best-practices.md`) +- Use correct animation patterns (implicit vs explicit, transitions, see `references/animation-patterns.md`) - Apply glass effects after layout/appearance modifiers (see `references/liquid-glass.md`) - Gate iOS 26+ features with `#available` and provide fallbacks @@ -102,6 +105,17 @@ Use this skill to build, review, or improve SwiftUI features with correct state - Gate frequent geometry updates by thresholds - Use `Self._printChanges()` to debug unexpected view updates +### Animations +- Use `.animation(_:value:)` with value parameter (deprecated version without value is too broad) +- Use `withAnimation` for event-driven animations (button taps, gestures) +- Prefer transforms (`offset`, `scale`, `rotation`) over layout changes (`frame`) for performance +- Transitions require animations outside the conditional structure +- Custom `Animatable` implementations must have explicit `animatableData` +- Use `.phaseAnimator` for multi-step sequences (iOS 17+) +- Use `.keyframeAnimator` for precise timing control (iOS 17+) +- Animation completion handlers need `.transaction(value:)` for reexecution +- Implicit animations override explicit animations (later in view tree wins) + ### Liquid Glass (iOS 26+) **Only adopt when explicitly requested by the user.** - Use native `glassEffect`, `GlassEffectContainer`, and glass button styles @@ -232,6 +246,16 @@ Button("Confirm") { } - [ ] Using relative layout (not hard-coded constants) - [ ] Views work in any context (context-agnostic) +### Animations (see `references/animation-patterns.md`) +- [ ] Using `.animation(_:value:)` with value parameter +- [ ] Using `withAnimation` for event-driven animations +- [ ] Transitions paired with animations outside conditional structure +- [ ] Custom `Animatable` has explicit `animatableData` implementation +- [ ] Preferring transforms over layout changes for animation performance +- [ ] Phase animations for multi-step sequences (iOS 17+) +- [ ] Keyframe animations for precise timing (iOS 17+) +- [ ] Completion handlers use `.transaction(value:)` for reexecution + ### Liquid Glass (iOS 26+) - [ ] `#available(iOS 26, *)` with fallback for Liquid Glass - [ ] Multiple glass views wrapped in `GlassEffectContainer` @@ -246,6 +270,7 @@ Button("Confirm") { } - `references/list-patterns.md` - ForEach identity, stability, and list best practices - `references/layout-best-practices.md` - Layout patterns, context-agnostic views, and testability - `references/modern-apis.md` - Modern API usage and deprecated replacements +- `references/animation-patterns.md` - Animation patterns, transitions, and iOS 17+ APIs - `references/sheet-navigation-patterns.md` - Sheet presentation and navigation patterns - `references/scroll-patterns.md` - ScrollView patterns and programmatic scrolling - `references/text-formatting.md` - Modern text formatting and string operations diff --git a/swiftui-expert-skill/references/animation-patterns.md b/swiftui-expert-skill/references/animation-patterns.md new file mode 100644 index 0000000..e1a2ed1 --- /dev/null +++ b/swiftui-expert-skill/references/animation-patterns.md @@ -0,0 +1,686 @@ +# SwiftUI Animation Patterns Reference + +## Core Concepts + +### How SwiftUI Animations Work + +State changes are the only way to trigger view updates. By default, changes aren't animated, but SwiftUI provides mechanisms to animate them. + +```swift +struct ContentView: View { + @State private var flag = false + + var body: some View { + Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .onTapGesture { + withAnimation(.linear) { flag.toggle() } + } + } +} +``` + +**Animation Process:** +1. State change triggers view tree re-evaluation +2. SwiftUI compares new view tree to current render tree +3. Animatable properties are identified +4. Timing curve generates progress values (0 to 1) +5. Values interpolate smoothly (~60 fps) + +**Key Characteristics:** +- Animations are additive and cancelable +- Always start from current render tree state +- Blend smoothly when interrupted + +## Property Animations vs Transitions + +### Property Animations + +Property animations interpolate changed properties of views that exist before and after state change: + +```swift +struct ContentView: View { + @State private var flag = false + + var body: some View { + Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .animation(.default, value: flag) + .onTapGesture { flag.toggle() } + } +} +``` + +### Transitions + +Transitions are animations for views being inserted or removed from the render tree: + +```swift +var body: some View { + VStack { + Button("Toggle") { + withAnimation { flag.toggle() } + } + if flag { + Rectangle() + .frame(width: 100, height: 100) + .transition(.scale) + } + } +} +``` + +**Key Insight:** Different branches in conditionals have different identities, triggering transitions instead of property animations. + +## Controlling Animations + +### 1. Implicit Animations + +Use `.animation(_:value:)` to animate when a specific value changes: + +```swift +Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .animation(.default, value: flag) +``` + +**Important:** Always use the value parameter (the version without it is deprecated). + +**Placement Matters:** +```swift +// Animation applies to frame and rectangle +Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .animation(.default, value: flag) +``` + +### 2. Explicit Animations + +Use `withAnimation` to wrap state changes that should be animated: + +```swift +Button("Animate") { + withAnimation(.spring) { + flag.toggle() + } +} +``` + +**Characteristics:** +- Animates all changes from state changes in the closure +- Scope defined by closure, not view tree position +- Good for event-driven animations + +### 3. Binding Animations + +Apply animation when a binding's value is set: + +```swift +struct ContentView: View { + @State private var flag = false + + var body: some View { + ToggleRectangle(flag: $flag.animation(.default)) + } +} +``` + +### iOS 17+: Scoped Animations + +Scope animations to specific modifiers: + +```swift +Text("Hello World") + .opacity(flag ? 1 : 0) + .animation(.default) { + $0.rotationEffect(flag ? .zero : .degrees(90)) + } +``` + +This animates only the rotation, not the opacity. + +### When to Use Which + +**Implicit Animations:** +- Animations tied to specific value changes +- Precise control over view tree scope + +**Explicit Animations:** +- Event-driven animations (button taps, gestures) +- Easy to distinguish model updates from user interactions + +## Timing Curves + +### Built-in Curves + +- `.linear` - Constant speed +- `.easeIn` - Starts slow, ends fast +- `.easeOut` - Starts fast, ends slow +- `.easeInOut` - Slow-fast-slow +- `.default` - System default +- Spring curves - Physics-based +- `.bouncy` (iOS 17+) - Overshoots target + +### Animation Modifiers + +```swift +// Speed +.animation(.default.speed(2.0), value: flag) + +// Delay +.animation(.default.delay(1.0), value: flag) + +// Repeat +.animation(.default.repeatCount(3, autoreverses: true), value: flag) +``` + +### Custom Timing Curves (iOS 17+) + +```swift +struct MyCustomAnimation: CustomAnimation { + func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { + // Custom interpolation logic + // Return nil when animation is complete + } +} +``` + +## Transactions + +### Understanding Transactions + +Transactions are the underlying mechanism for all animations. Every view update is wrapped in a transaction carrying animation information. + +```swift +// Using withAnimation (convenient) +withAnimation(.default) { flag.toggle() } + +// Using withTransaction (explicit) +var transaction = Transaction(animation: .default) +withTransaction(transaction) { flag.toggle() } +``` + +### The .transaction Modifier + +```swift +Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .transaction { t in + t.animation = .default + } +``` + +### Animation Precedence + +**Implicit animations take precedence over explicit animations:** + +```swift +Button("Tap") { + withAnimation(.linear) { flag.toggle() } +} +.animation(.bouncy, value: flag) // This wins! +``` + +### Disabling Animations + +```swift +.transaction { t in + t.disablesAnimations = true +} +``` + +## Completion Handlers (iOS 17+) + +### Basic Usage + +```swift +Button("Animate") { + withAnimation(.default) { + flag.toggle() + } completion: { + print("Animation complete!") + } +} +``` + +### Common Pitfall: Completion Not Firing + +```swift +// BAD - completion only fires once +.transaction { + $0.addAnimationCompletion { print("Done!") } +} + +// GOOD - use value parameter for reexecution +.transaction(value: flag) { + $0.addAnimationCompletion { print("Done!") } +} +``` + +## The Animatable Protocol + +### Understanding Animatable + +The `Animatable` protocol enables property interpolation: + +```swift +protocol Animatable { + associatedtype AnimatableData : VectorArithmetic + var animatableData: Self.AnimatableData { get set } +} +``` + +### Custom Animatable Modifier + +```swift +struct MyOpacity: ViewModifier, Animatable { + var animatableData: Double + + init(_ opacity: Double) { + animatableData = opacity + } + + func body(content: Content) -> some View { + content.opacity(animatableData) + } +} +``` + +### Creating Custom Animations: Shake Effect + +```swift +struct Shake: ViewModifier, Animatable { + var numberOfShakes: Double + + var animatableData: Double { + get { numberOfShakes } + set { numberOfShakes = newValue } + } + + func body(content: Content) -> some View { + content + .offset(x: -sin(numberOfShakes * 2 * .pi) * 30) + } +} + +// Usage +struct ContentView: View { + @State private var shakes = 0 + + var body: some View { + Button("Shake!") { shakes += 1 } + .modifier(Shake(numberOfShakes: Double(shakes))) + .animation(.default, value: shakes) + } +} +``` + +### Multiple Animatable Properties + +Use `AnimatablePair` to compose multiple values: + +```swift +struct ComplexAnimation: ViewModifier, Animatable { + var offset: CGFloat + var rotation: Double + + var animatableData: AnimatablePair { + get { AnimatablePair(offset, rotation) } + set { + offset = newValue.first + rotation = newValue.second + } + } + + func body(content: Content) -> some View { + content + .offset(x: offset) + .rotationEffect(.degrees(rotation)) + } +} +``` + +**Pitfall:** Default `animatableData` implementation does nothing. Always explicitly implement it. + +## Transitions + +### Default Behavior + +Without specifying a transition, SwiftUI applies `.opacity`: + +```swift +if flag { + Rectangle() + .frame(width: 100, height: 100) +} +``` + +### Transition States + +- **Active State**: Appearance when inserting/removing begins +- **Identity State**: Normal, at-rest appearance + +### Built-in Transitions + +```swift +.transition(.opacity) // Fade +.transition(.scale) // Scale +.transition(.slide) // Slide from edge +.transition(.move(edge: .leading)) // Move from specific edge +.transition(.offset(x: 100, y: 0)) // Offset by amount +``` + +### Combining Transitions + +```swift +// Parallel +.transition(.slide.combined(with: .opacity)) + +// Asymmetric +.transition( + .asymmetric( + insertion: .slide, + removal: .scale + ) +) +``` + +### Custom Transitions (Pre-iOS 17) + +```swift +struct Blur: ViewModifier { + var radius: CGFloat + + func body(content: Content) -> some View { + content.blur(radius: radius) + } +} + +extension AnyTransition { + static func blur(radius: CGFloat) -> Self { + .modifier( + active: Blur(radius: radius), + identity: Blur(radius: 0) + ) + } +} + +// Usage +.transition(.blur(radius: 5)) +``` + +### Custom Transitions (iOS 17+) + +```swift +struct BlurTransition: Transition { + var radius: CGFloat + + func body(content: Content, phase: TransitionPhase) -> some View { + content + .blur(radius: phase.isIdentity ? 0 : radius) + } +} +``` + +### Critical: Transitions Require Animations + +```swift +// BAD - animation is in the subtree being removed +if flag { + Rectangle() + .transition(.blur(radius: 5)) + .animation(.default, value: flag) +} + +// GOOD - animation is outside the conditional +VStack { + if flag { + Rectangle() + .transition(.blur(radius: 5)) + } +} +.animation(.default, value: flag) + +// ALSO GOOD - explicit animation +Button("Toggle") { + withAnimation(.default) { flag.toggle() } +} +``` + +### Identity Changes Trigger Transitions + +```swift +Rectangle() + .id(flag) // Different identity when flag changes + .transition(.scale) +``` + +## Phase-Based Animations (iOS 17+) + +Phase animations cycle through discrete phases automatically: + +```swift +struct Sample: View { + @State private var shakes = 0 + + var body: some View { + Button("Shake") { + shakes += 1 + } + .phaseAnimator([0, -20, 20], trigger: shakes) { content, offset in + content.offset(x: offset) + } + } +} +``` + +**Infinite Loop (no trigger):** +```swift +.phaseAnimator([0, -20, 20]) { content, offset in + content.offset(x: offset) +} +``` + +**Custom Timing Per Phase:** +```swift +.phaseAnimator([0, -20, 20], trigger: shakes) { content, offset in + content.offset(x: offset) +} animation: { phase in + switch phase { + case -20: return .bouncy + case 20: return .linear + default: return .smooth + } +} +``` + +**Using Enum Phases:** +```swift +enum AnimationPhase: Equatable { + case initial + case expanded + case rotated +} + +.phaseAnimator([.initial, .expanded, .rotated]) { content, phase in + switch phase { + case .initial: + content + case .expanded: + content.scaleEffect(1.5) + case .rotated: + content.scaleEffect(1.5).rotationEffect(.degrees(45)) + } +} +``` + +## Keyframe-Based Animations (iOS 17+) + +Keyframe animations provide fine-grained control with exact values at specific times: + +```swift +struct ShakeSample: View { + @State private var trigger = 0 + + var body: some View { + Button("Shake") { + trigger += 1 + } + .keyframeAnimator( + initialValue: 0, + trigger: trigger + ) { content, offset in + content.offset(x: offset) + } keyframes: { value in + CubicKeyframe(-30, duration: 0.25) + CubicKeyframe(30, duration: 0.5) + CubicKeyframe(0, duration: 0.25) + } + } +} +``` + +### Keyframe Types + +- **CubicKeyframe**: Smooth interpolation +- **LinearKeyframe**: Straight-line interpolation +- **MoveKeyframe**: Instant jump (no interpolation) + +### Multiple Tracks + +```swift +struct ShakeData { + var offset: CGFloat = 0 + var rotation: Angle = .zero +} + +.keyframeAnimator( + initialValue: ShakeData(), + trigger: trigger +) { content, data in + content + .offset(x: data.offset) + .rotationEffect(data.rotation) +} keyframes: { value in + KeyframeTrack(\.offset) { + CubicKeyframe(-30, duration: 0.25) + CubicKeyframe(30, duration: 0.5) + CubicKeyframe(0, duration: 0.25) + } + + KeyframeTrack(\.rotation) { + LinearKeyframe(.degrees(20), duration: 0.1) + LinearKeyframe(.degrees(-20), duration: 0.2) + LinearKeyframe(.degrees(0), duration: 0.1) + } +} +``` + +**Tracks run in parallel**, each animating one property. + +### KeyframeTimeline + +Query animation values directly: + +```swift +let timeline = KeyframeTimeline(initialValue: ShakeData()) { + KeyframeTrack(\.offset) { + CubicKeyframe(-30, duration: 0.25) + CubicKeyframe(30, duration: 0.5) + CubicKeyframe(0, duration: 0.25) + } +} + +let valueAt50Percent = timeline.value(time: 0.5) +``` + +## Best Practices + +### Animation Guidelines + +1. **Apply Animations Locally** + - Place animations close to what's being animated + - Prevents unintended side effects + +2. **Always Use Value Parameter** + - Use `.animation(_:value:)` not deprecated `.animation(_:)` + - Prevents unexpected animation triggers + +3. **Understand View Identity** + - Property animations require stable view identity + - Changing identity triggers transitions + +4. **Choose the Right Type** + - Property animations: interpolating values on existing views + - Transitions: inserting/removing views + - Phase animations: multi-step sequences returning to start + - Keyframe animations: complex, precisely-timed animations + +### Performance Considerations + +1. **Narrow Animation Scope** + - Animate only what needs to change + - Consider breaking views into smaller pieces + +2. **Prefer Transforms Over Layout** + - Animate `offset`, `scale`, `rotation` instead of `frame` + - Layout animations are more expensive + +3. **Spring Animation Awareness** + - Can be more expensive than simple curves + - Complete later than "logical" completion + +### Debugging Animations + +```swift +// Print interpolated values +struct MyModifier: ViewModifier, Animatable { + var value: Double + var animatableData: Double { + get { value } + set { + value = newValue + print("Animation value: \(newValue)") + } + } +} + +// Slow down for inspection +.animation(.linear(duration: 3.0).speed(0.2), value: flag) +``` + +## Common Pitfalls + +1. **Animations Without State Changes** + - Animations only work with state changes + - Can't animate constants directly + +2. **Transition Without Animation** + - Always pair transitions with animations + - Place animations outside conditional structure + +3. **Forgetting animatableData** + - Default implementation does nothing + - No compiler error, but animation won't work + +4. **Completion Handlers Not Reexecuting** + - Use `.transaction(value:)` variant + - Ensure closure depends on state + +5. **Animation Precedence Confusion** + - Implicit animations override explicit ones + - Use `disablesAnimations` when needed + +## Summary Checklist + +- [ ] Using `.animation(_:value:)` with value parameter (not deprecated version) +- [ ] Transitions paired with animations outside conditional structure +- [ ] Custom `Animatable` implementations have explicit `animatableData` +- [ ] Completion handlers use `.transaction(value:)` for reexecution +- [ ] Animations placed close to animated content +- [ ] Preferring transforms over layout changes for performance +- [ ] Using phase animations for multi-step sequences (iOS 17+) +- [ ] Using keyframe animations for precise timing control (iOS 17+) +- [ ] View identity stable for property animations +- [ ] Explicit animations for event-driven changes +- [ ] Implicit animations for value-dependent changes From 31706f1f9c606d86fda4719dd1bad20dd2b91ff4 Mon Sep 17 00:00:00 2001 From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:24:35 +0100 Subject: [PATCH 2/8] Update GitHub Actions workflow to grant write permissions and configure user email for commits --- .github/workflows/sync-readme-references.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-readme-references.yml b/.github/workflows/sync-readme-references.yml index 5c27e91..75486fe 100644 --- a/.github/workflows/sync-readme-references.yml +++ b/.github/workflows/sync-readme-references.yml @@ -9,6 +9,8 @@ jobs: sync-readme: if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 @@ -31,7 +33,8 @@ jobs: fi git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} git add README.md git commit -m "chore: sync README references [skip ci]" git push From ac180d1f6947e4e1fc0f38e87998447174561a3a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:24:48 +0000 Subject: [PATCH 3/8] chore: sync README references [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 57973f9..03adc55 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ This skill gives your AI coding tool practical SwiftUI guidance. It can: swiftui-expert-skill/ SKILL.md references/ + animation-patterns.md image-optimization.md - AsyncImage usage, downsampling, caching layout-best-practices.md - Layout patterns and GeometryReader alternatives liquid-glass.md - iOS 26+ glass effects and fallback patterns From 3ea43c56db4b7350ca0e648f82e6c63fdde39e82 Mon Sep 17 00:00:00 2001 From: Omar Elsayed Date: Fri, 30 Jan 2026 17:10:13 +0200 Subject: [PATCH 4/8] updated file with good and bad patterns --- .../references/animation-patterns.md | 1268 +++++++++++------ 1 file changed, 854 insertions(+), 414 deletions(-) diff --git a/swiftui-expert-skill/references/animation-patterns.md b/swiftui-expert-skill/references/animation-patterns.md index e1a2ed1..1f5f536 100644 --- a/swiftui-expert-skill/references/animation-patterns.md +++ b/swiftui-expert-skill/references/animation-patterns.md @@ -6,20 +6,6 @@ State changes are the only way to trigger view updates. By default, changes aren't animated, but SwiftUI provides mechanisms to animate them. -```swift -struct ContentView: View { - @State private var flag = false - - var body: some View { - Rectangle() - .frame(width: flag ? 100 : 50, height: 50) - .onTapGesture { - withAnimation(.linear) { flag.toggle() } - } - } -} -``` - **Animation Process:** 1. State change triggers view tree re-evaluation 2. SwiftUI compares new view tree to current render tree @@ -32,655 +18,1109 @@ struct ContentView: View { - Always start from current render tree state - Blend smoothly when interrupted -## Property Animations vs Transitions +--- -### Property Animations +## Implicit vs Explicit Animations -Property animations interpolate changed properties of views that exist before and after state change: +### 1. Implicit Animations + +Use `.animation(_:value:)` to animate when a specific value changes. ```swift -struct ContentView: View { - @State private var flag = false +// GOOD - uses value parameter for precise control +struct GoodImplicitAnimation: View { + @State private var isExpanded = false var body: some View { Rectangle() - .frame(width: flag ? 100 : 50, height: 50) - .animation(.default, value: flag) - .onTapGesture { flag.toggle() } + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + .onTapGesture { isExpanded.toggle() } + } +} + +// BAD - deprecated animation without value (animates everything) +struct BadImplicitAnimation: View { + @State private var isExpanded = false + + var body: some View { + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring) // Deprecated! Animates all changes unexpectedly + .onTapGesture { isExpanded.toggle() } } } ``` -### Transitions +### 2. Explicit Animations -Transitions are animations for views being inserted or removed from the render tree: +Use `withAnimation` to wrap state changes that should be animated. ```swift -var body: some View { - VStack { - Button("Toggle") { - withAnimation { flag.toggle() } - } - if flag { +// GOOD - explicit animation for event-driven changes +struct GoodExplicitAnimation: View { + @State private var isExpanded = false + + var body: some View { + VStack { + Button("Toggle") { + withAnimation(.spring) { + isExpanded.toggle() + } + } + Rectangle() - .frame(width: 100, height: 100) - .transition(.scale) + .frame(width: isExpanded ? 200 : 100, height: 50) } } } -``` -**Key Insight:** Different branches in conditionals have different identities, triggering transitions instead of property animations. - -## Controlling Animations - -### 1. Implicit Animations +// BAD - state change without animation context +struct BadExplicitAnimation: View { + @State private var isExpanded = false -Use `.animation(_:value:)` to animate when a specific value changes: + var body: some View { + VStack { + Button("Toggle") { + isExpanded.toggle() // No animation - abrupt change + } -```swift -Rectangle() - .frame(width: flag ? 100 : 50, height: 50) - .animation(.default, value: flag) + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + } + } +} ``` -**Important:** Always use the value parameter (the version without it is deprecated). +### 3. Animation Placement -**Placement Matters:** ```swift -// Animation applies to frame and rectangle -Rectangle() - .frame(width: flag ? 100 : 50, height: 50) - .animation(.default, value: flag) -``` +// GOOD - animation placed after the properties it should animate +struct GoodAnimationPlacement: View { + @State private var isExpanded = false -### 2. Explicit Animations + var body: some View { + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .foregroundStyle(isExpanded ? .blue : .red) + .animation(.default, value: isExpanded) // Animates both frame and color + } +} -Use `withAnimation` to wrap state changes that should be animated: +// BAD - animation placed before properties (may not animate as expected) +struct BadAnimationPlacement: View { + @State private var isExpanded = false -```swift -Button("Animate") { - withAnimation(.spring) { - flag.toggle() + var body: some View { + Rectangle() + .animation(.default, value: isExpanded) // Too early! + .frame(width: isExpanded ? 200 : 100, height: 50) + .foregroundStyle(isExpanded ? .blue : .red) } } ``` -**Characteristics:** -- Animates all changes from state changes in the closure -- Scope defined by closure, not view tree position -- Good for event-driven animations - -### 3. Binding Animations - -Apply animation when a binding's value is set: +### 4. Selective Animation ```swift -struct ContentView: View { - @State private var flag = false +// GOOD - animate only specific properties +struct GoodSelectiveAnimation: View { + @State private var isExpanded = false var body: some View { - ToggleRectangle(flag: $flag.animation(.default)) + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) // Animate size + .foregroundStyle(isExpanded ? .blue : .red) + .animation(nil, value: isExpanded) // Don't animate color } } -``` -### iOS 17+: Scoped Animations +// iOS 17+ scoped animation +struct GoodScopedAnimation: View { + @State private var isExpanded = false -Scope animations to specific modifiers: + var body: some View { + Rectangle() + .foregroundStyle(isExpanded ? .blue : .red) // Not animated + .animation(.spring) { + $0.frame(width: isExpanded ? 200 : 100, height: 50) // Only this is animated + } + } +} -```swift -Text("Hello World") - .opacity(flag ? 1 : 0) - .animation(.default) { - $0.rotationEffect(flag ? .zero : .degrees(90)) +// BAD - animating everything when only some properties should animate +struct BadSelectiveAnimation: View { + @State private var isExpanded = false + + var body: some View { + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .foregroundStyle(isExpanded ? .blue : .red) + .animation(.spring, value: isExpanded) // Animates both - maybe unintended } +} ``` -This animates only the rotation, not the opacity. +--- -### When to Use Which +## Transitions -**Implicit Animations:** -- Animations tied to specific value changes -- Precise control over view tree scope +### 1. Basic Transitions -**Explicit Animations:** -- Event-driven animations (button taps, gestures) -- Easy to distinguish model updates from user interactions +```swift +// GOOD - transition with animation outside conditional +struct GoodTransition: View { + @State private var showDetail = false -## Timing Curves + var body: some View { + VStack { + Button("Toggle") { + showDetail.toggle() + } + + if showDetail { + DetailView() + .transition(.slide) + } + } + .animation(.spring, value: showDetail) // Animation outside conditional + } +} -### Built-in Curves +// GOOD - explicit animation for transitions +struct GoodExplicitTransition: View { + @State private var showDetail = false -- `.linear` - Constant speed -- `.easeIn` - Starts slow, ends fast -- `.easeOut` - Starts fast, ends slow -- `.easeInOut` - Slow-fast-slow -- `.default` - System default -- Spring curves - Physics-based -- `.bouncy` (iOS 17+) - Overshoots target + var body: some View { + VStack { + Button("Toggle") { + withAnimation(.spring) { + showDetail.toggle() + } + } -### Animation Modifiers + if showDetail { + DetailView() + .transition(.scale.combined(with: .opacity)) + } + } + } +} -```swift -// Speed -.animation(.default.speed(2.0), value: flag) +// BAD - animation inside conditional (gets removed with the view!) +struct BadTransition: View { + @State private var showDetail = false + + var body: some View { + VStack { + Button("Toggle") { + showDetail.toggle() + } -// Delay -.animation(.default.delay(1.0), value: flag) + if showDetail { + DetailView() + .transition(.slide) + .animation(.spring, value: showDetail) // Won't work on removal! + } + } + } +} -// Repeat -.animation(.default.repeatCount(3, autoreverses: true), value: flag) -``` +// BAD - transition without any animation +struct BadNoAnimationTransition: View { + @State private var showDetail = false -### Custom Timing Curves (iOS 17+) + var body: some View { + VStack { + Button("Toggle") { + showDetail.toggle() // No animation context + } -```swift -struct MyCustomAnimation: CustomAnimation { - func animate(value: V, time: TimeInterval, context: inout AnimationContext) -> V? where V : VectorArithmetic { - // Custom interpolation logic - // Return nil when animation is complete + if showDetail { + DetailView() + .transition(.slide) // Transition ignored - view just appears/disappears + } + } } } ``` -## Transactions +### 2. Asymmetric Transitions -### Understanding Transactions +```swift +// GOOD - different animations for insertion vs removal +struct GoodAsymmetricTransition: View { + @State private var showCard = false -Transactions are the underlying mechanism for all animations. Every view update is wrapped in a transaction carrying animation information. + var body: some View { + VStack { + Button("Toggle Card") { + withAnimation(.spring) { + showCard.toggle() + } + } -```swift -// Using withAnimation (convenient) -withAnimation(.default) { flag.toggle() } + if showCard { + CardView() + .transition( + .asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .move(edge: .bottom).combined(with: .opacity) + ) + ) + } + } + } +} -// Using withTransaction (explicit) -var transaction = Transaction(animation: .default) -withTransaction(transaction) { flag.toggle() } -``` +// BAD - using same transition when different behaviors are needed +struct BadSymmetricTransition: View { + @State private var showCard = false -### The .transaction Modifier + var body: some View { + VStack { + Button("Toggle Card") { + withAnimation { + showCard.toggle() + } + } -```swift -Rectangle() - .frame(width: flag ? 100 : 50, height: 50) - .transaction { t in - t.animation = .default + if showCard { + CardView() + .transition(.slide) // Same animation both ways - may feel awkward + } + } } +} ``` -### Animation Precedence - -**Implicit animations take precedence over explicit animations:** +### 3. Custom Transitions ```swift -Button("Tap") { - withAnimation(.linear) { flag.toggle() } +// GOOD - reusable custom transition (iOS 17+) +struct BlurTransition: Transition { + var radius: CGFloat + + func body(content: Content, phase: TransitionPhase) -> some View { + content + .blur(radius: phase.isIdentity ? 0 : radius) + .opacity(phase.isIdentity ? 1 : 0) + } } -.animation(.bouncy, value: flag) // This wins! -``` -### Disabling Animations +extension AnyTransition { + static var blur: AnyTransition { + .modifier( + active: BlurModifier(radius: 10), + identity: BlurModifier(radius: 0) + ) + } +} -```swift -.transaction { t in - t.disablesAnimations = true +struct GoodCustomTransition: View { + @State private var showContent = false + + var body: some View { + VStack { + Button("Toggle") { + withAnimation(.easeInOut(duration: 0.5)) { + showContent.toggle() + } + } + + if showContent { + ContentView() + .transition(BlurTransition(radius: 10)) + } + } + } } -``` -## Completion Handlers (iOS 17+) +// BAD - inline complex transition logic +struct BadCustomTransition: View { + @State private var showContent = false -### Basic Usage + var body: some View { + VStack { + Button("Toggle") { + withAnimation { + showContent.toggle() + } + } -```swift -Button("Animate") { - withAnimation(.default) { - flag.toggle() - } completion: { - print("Animation complete!") + if showContent { + ContentView() + .blur(radius: showContent ? 0 : 10) // Not a transition - won't animate on removal + .opacity(showContent ? 1 : 0) + } + } } } ``` -### Common Pitfall: Completion Not Firing +--- + +## Property Animations vs Transitions ```swift -// BAD - completion only fires once -.transaction { - $0.addAnimationCompletion { print("Done!") } -} +// GOOD - property animation for existing view changes +struct GoodPropertyAnimation: View { + @State private var isExpanded = false -// GOOD - use value parameter for reexecution -.transaction(value: flag) { - $0.addAnimationCompletion { print("Done!") } + var body: some View { + // Same Rectangle exists before and after - property animation + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + .onTapGesture { isExpanded.toggle() } + } } -``` -## The Animatable Protocol +// GOOD - transition for view insertion/removal +struct GoodTransitionUsage: View { + @State private var showLarge = false -### Understanding Animatable + var body: some View { + VStack { + Button("Toggle") { + withAnimation { + showLarge.toggle() + } + } + + // Different views - use transition + if showLarge { + LargeRectangle() + .transition(.scale) + } else { + SmallRectangle() + .transition(.scale) + } + } + } +} -The `Animatable` protocol enables property interpolation: +// BAD - expecting property animation when identity changes +struct BadIdentityChange: View { + @State private var isExpanded = false -```swift -protocol Animatable { - associatedtype AnimatableData : VectorArithmetic - var animatableData: Self.AnimatableData { get set } + var body: some View { + // Different branches = different identities = transition, not property animation + if isExpanded { + Rectangle() + .frame(width: 200, height: 50) + .animation(.spring, value: isExpanded) // Won't smoothly animate! + } else { + Rectangle() + .frame(width: 100, height: 50) + .animation(.spring, value: isExpanded) + } + } } ``` -### Custom Animatable Modifier +--- + +## The Animatable Protocol + +### 1. Custom Animatable Modifier ```swift -struct MyOpacity: ViewModifier, Animatable { - var animatableData: Double +// GOOD - proper Animatable implementation +struct ShakeModifier: ViewModifier, Animatable { + var shakeCount: Double - init(_ opacity: Double) { - animatableData = opacity + var animatableData: Double { + get { shakeCount } + set { shakeCount = newValue } } func body(content: Content) -> some View { - content.opacity(animatableData) + content + .offset(x: sin(shakeCount * .pi * 2) * 10) } } -``` -### Creating Custom Animations: Shake Effect +extension View { + func shake(count: Int) -> some View { + modifier(ShakeModifier(shakeCount: Double(count))) + } +} -```swift -struct Shake: ViewModifier, Animatable { - var numberOfShakes: Double +struct GoodShakeAnimation: View { + @State private var shakeCount = 0 - var animatableData: Double { - get { numberOfShakes } - set { numberOfShakes = newValue } + var body: some View { + Button("Shake Me") { + shakeCount += 3 + } + .shake(count: shakeCount) + .animation(.default, value: shakeCount) } +} + +// BAD - missing animatableData implementation +struct BadShakeModifier: ViewModifier { + var shakeCount: Double + + // Missing animatableData! Uses default EmptyAnimatableData func body(content: Content) -> some View { content - .offset(x: -sin(numberOfShakes * 2 * .pi) * 30) + .offset(x: sin(shakeCount * .pi * 2) * 10) } } -// Usage -struct ContentView: View { - @State private var shakes = 0 +struct BadShakeAnimation: View { + @State private var shakeCount = 0 var body: some View { - Button("Shake!") { shakes += 1 } - .modifier(Shake(numberOfShakes: Double(shakes))) - .animation(.default, value: shakes) + Button("Shake Me") { + shakeCount += 3 + } + .modifier(BadShakeModifier(shakeCount: Double(shakeCount))) + .animation(.default, value: shakeCount) // Won't animate - jumps to final value } } ``` -### Multiple Animatable Properties - -Use `AnimatablePair` to compose multiple values: +### 2. Multiple Animatable Properties ```swift -struct ComplexAnimation: ViewModifier, Animatable { - var offset: CGFloat +// GOOD - using AnimatablePair for multiple properties +struct ComplexAnimationModifier: ViewModifier, Animatable { + var scale: CGFloat var rotation: Double var animatableData: AnimatablePair { - get { AnimatablePair(offset, rotation) } + get { AnimatablePair(scale, rotation) } set { - offset = newValue.first + scale = newValue.first rotation = newValue.second } } func body(content: Content) -> some View { content - .offset(x: offset) + .scaleEffect(scale) .rotationEffect(.degrees(rotation)) } } -``` -**Pitfall:** Default `animatableData` implementation does nothing. Always explicitly implement it. +// GOOD - nested AnimatablePair for 3+ properties +struct ThreePropertyModifier: ViewModifier, Animatable { + var x: CGFloat + var y: CGFloat + var rotation: Double -## Transitions + var animatableData: AnimatablePair, Double> { + get { AnimatablePair(AnimatablePair(x, y), rotation) } + set { + x = newValue.first.first + y = newValue.first.second + rotation = newValue.second + } + } -### Default Behavior + func body(content: Content) -> some View { + content + .offset(x: x, y: y) + .rotationEffect(.degrees(rotation)) + } +} -Without specifying a transition, SwiftUI applies `.opacity`: +// BAD - separate state for each property (can't coordinate animation) +struct BadMultiPropertyAnimation: View { + @State private var scale: CGFloat = 1 + @State private var rotation: Double = 0 -```swift -if flag { - Rectangle() - .frame(width: 100, height: 100) + var body: some View { + Rectangle() + .scaleEffect(scale) + .rotationEffect(.degrees(rotation)) + .animation(.spring, value: scale) + .animation(.spring, value: rotation) + .onTapGesture { + // These animate separately, potentially out of sync + scale = scale == 1 ? 1.5 : 1 + rotation = rotation == 0 ? 45 : 0 + } + } } ``` -### Transition States +--- -- **Active State**: Appearance when inserting/removing begins -- **Identity State**: Normal, at-rest appearance +## Animation Performance -### Built-in Transitions +### 1. Prefer Transforms Over Layout ```swift -.transition(.opacity) // Fade -.transition(.scale) // Scale -.transition(.slide) // Slide from edge -.transition(.move(edge: .leading)) // Move from specific edge -.transition(.offset(x: 100, y: 0)) // Offset by amount -``` +// GOOD - animating transforms (GPU accelerated) +struct GoodPerformanceAnimation: View { + @State private var isActive = false -### Combining Transitions + var body: some View { + Rectangle() + .frame(width: 100, height: 100) + .scaleEffect(isActive ? 1.5 : 1.0) // Transform - fast + .offset(x: isActive ? 50 : 0) // Transform - fast + .rotationEffect(.degrees(isActive ? 45 : 0)) // Transform - fast + .animation(.spring, value: isActive) + .onTapGesture { isActive.toggle() } + } +} -```swift -// Parallel -.transition(.slide.combined(with: .opacity)) - -// Asymmetric -.transition( - .asymmetric( - insertion: .slide, - removal: .scale - ) -) +// BAD - animating layout properties (triggers layout passes) +struct BadPerformanceAnimation: View { + @State private var isActive = false + + var body: some View { + Rectangle() + .frame( + width: isActive ? 150 : 100, // Layout change - expensive + height: isActive ? 150 : 100 + ) + .padding(isActive ? 50 : 0) // Layout change - expensive + .animation(.spring, value: isActive) + .onTapGesture { isActive.toggle() } + } +} ``` -### Custom Transitions (Pre-iOS 17) +### 2. Narrow Animation Scope ```swift -struct Blur: ViewModifier { - var radius: CGFloat +// GOOD - animation scoped to specific subview +struct GoodScopedAnimation: View { + @State private var isExpanded = false - func body(content: Content) -> some View { - content.blur(radius: radius) - } -} + var body: some View { + VStack { + HeaderView() // Not affected by animation -extension AnyTransition { - static func blur(radius: CGFloat) -> Self { - .modifier( - active: Blur(radius: radius), - identity: Blur(radius: 0) - ) + ExpandableContent(isExpanded: isExpanded) + .animation(.spring, value: isExpanded) // Only this animates + + FooterView() // Not affected by animation + } } } -// Usage -.transition(.blur(radius: 5)) -``` - -### Custom Transitions (iOS 17+) +// BAD - animation at root affects entire tree +struct BadBroadAnimation: View { + @State private var isExpanded = false -```swift -struct BlurTransition: Transition { - var radius: CGFloat - - func body(content: Content, phase: TransitionPhase) -> some View { - content - .blur(radius: phase.isIdentity ? 0 : radius) + var body: some View { + VStack { + HeaderView() + ExpandableContent(isExpanded: isExpanded) + FooterView() + } + .animation(.spring, value: isExpanded) // Animates everything unnecessarily } } ``` -### Critical: Transitions Require Animations +### 3. Avoid Animation in Hot Paths ```swift -// BAD - animation is in the subtree being removed -if flag { - Rectangle() - .transition(.blur(radius: 5)) - .animation(.default, value: flag) -} +// GOOD - gate animations by threshold +struct GoodScrollAnimation: View { + @State private var showTitle = false -// GOOD - animation is outside the conditional -VStack { - if flag { - Rectangle() - .transition(.blur(radius: 5)) + var body: some View { + ScrollView { + // Content + } + .onPreferenceChange(ScrollOffsetKey.self) { offset in + let shouldShow = offset.y < -50 + if shouldShow != showTitle { // Only update when crossing threshold + withAnimation(.easeOut(duration: 0.2)) { + showTitle = shouldShow + } + } + } } } -.animation(.default, value: flag) -// ALSO GOOD - explicit animation -Button("Toggle") { - withAnimation(.default) { flag.toggle() } -} -``` +// BAD - animating on every scroll position change +struct BadScrollAnimation: View { + @State private var offset: CGFloat = 0 -### Identity Changes Trigger Transitions - -```swift -Rectangle() - .id(flag) // Different identity when flag changes - .transition(.scale) + var body: some View { + ScrollView { + // Content + } + .onPreferenceChange(ScrollOffsetKey.self) { newOffset in + withAnimation { // Fires constantly during scroll! + offset = newOffset.y + } + } + } +} ``` -## Phase-Based Animations (iOS 17+) +--- -Phase animations cycle through discrete phases automatically: +## Phase Animations (iOS 17+) ```swift -struct Sample: View { - @State private var shakes = 0 +// GOOD - phase animator for multi-step sequences +struct GoodPhaseAnimation: View { + @State private var trigger = 0 var body: some View { - Button("Shake") { - shakes += 1 + Button("Animate") { + trigger += 1 } - .phaseAnimator([0, -20, 20], trigger: shakes) { content, offset in + .phaseAnimator( + [0.0, -10.0, 10.0, -5.0, 5.0, 0.0], + trigger: trigger + ) { content, offset in content.offset(x: offset) + } animation: { phase in + switch phase { + case 0: .smooth + default: .snappy + } } } } -``` -**Infinite Loop (no trigger):** -```swift -.phaseAnimator([0, -20, 20]) { content, offset in - content.offset(x: offset) -} -``` +// GOOD - using enum phases for clarity +enum BouncePhase: CaseIterable { + case initial, up, down, settle -**Custom Timing Per Phase:** -```swift -.phaseAnimator([0, -20, 20], trigger: shakes) { content, offset in - content.offset(x: offset) -} animation: { phase in - switch phase { - case -20: return .bouncy - case 20: return .linear - default: return .smooth + var scale: CGFloat { + switch self { + case .initial: 1.0 + case .up: 1.2 + case .down: 0.9 + case .settle: 1.0 + } } } -``` -**Using Enum Phases:** -```swift -enum AnimationPhase: Equatable { - case initial - case expanded - case rotated +struct GoodEnumPhaseAnimation: View { + @State private var trigger = 0 + + var body: some View { + Circle() + .frame(width: 50, height: 50) + .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in + content.scaleEffect(phase.scale) + } + .onTapGesture { trigger += 1 } + } } -.phaseAnimator([.initial, .expanded, .rotated]) { content, phase in - switch phase { - case .initial: - content - case .expanded: - content.scaleEffect(1.5) - case .rotated: - content.scaleEffect(1.5).rotationEffect(.degrees(45)) +// BAD - using custom Animatable when phaseAnimator would be simpler +struct BadManualMultiStep: View { + @State private var animationProgress: Double = 0 + + var body: some View { + Button("Animate") { + // Complex manual animation sequencing + withAnimation(.easeOut(duration: 0.1)) { + animationProgress = 0.25 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeInOut(duration: 0.1)) { + animationProgress = 0.5 + } + } + // ... more manual sequencing + } + .offset(x: sin(animationProgress * .pi * 4) * 20) } } ``` -## Keyframe-Based Animations (iOS 17+) +--- -Keyframe animations provide fine-grained control with exact values at specific times: +## Keyframe Animations (iOS 17+) ```swift -struct ShakeSample: View { +// GOOD - keyframe animation for precise timing control +struct GoodKeyframeAnimation: View { @State private var trigger = 0 var body: some View { - Button("Shake") { + Button("Bounce") { trigger += 1 } .keyframeAnimator( - initialValue: 0, + initialValue: AnimationValues(), trigger: trigger - ) { content, offset in - content.offset(x: offset) - } keyframes: { value in - CubicKeyframe(-30, duration: 0.25) - CubicKeyframe(30, duration: 0.5) - CubicKeyframe(0, duration: 0.25) + ) { content, value in + content + .scaleEffect(value.scale) + .offset(y: value.verticalOffset) + } keyframes: { _ in + KeyframeTrack(\.scale) { + SpringKeyframe(1.2, duration: 0.15) + SpringKeyframe(0.9, duration: 0.1) + SpringKeyframe(1.0, duration: 0.15) + } + + KeyframeTrack(\.verticalOffset) { + LinearKeyframe(-20, duration: 0.15) + LinearKeyframe(0, duration: 0.25) + } } } } -``` -### Keyframe Types +struct AnimationValues { + var scale: CGFloat = 1.0 + var verticalOffset: CGFloat = 0 +} + +// GOOD - multiple synchronized tracks +struct GoodMultiTrackKeyframe: View { + @State private var trigger = 0 + + var body: some View { + Image(systemName: "bell.fill") + .font(.largeTitle) + .keyframeAnimator( + initialValue: BellAnimation(), + trigger: trigger + ) { content, value in + content + .rotationEffect(.degrees(value.rotation)) + .scaleEffect(value.scale) + } keyframes: { _ in + KeyframeTrack(\.rotation) { + CubicKeyframe(15, duration: 0.1) + CubicKeyframe(-15, duration: 0.1) + CubicKeyframe(10, duration: 0.1) + CubicKeyframe(-10, duration: 0.1) + CubicKeyframe(0, duration: 0.1) + } + + KeyframeTrack(\.scale) { + CubicKeyframe(1.1, duration: 0.25) + CubicKeyframe(1.0, duration: 0.25) + } + } + .onTapGesture { trigger += 1 } + } +} -- **CubicKeyframe**: Smooth interpolation -- **LinearKeyframe**: Straight-line interpolation -- **MoveKeyframe**: Instant jump (no interpolation) +struct BellAnimation { + var rotation: Double = 0 + var scale: CGFloat = 1.0 +} -### Multiple Tracks +// BAD - manual timer-based animation +struct BadManualKeyframe: View { + @State private var rotation: Double = 0 + @State private var scale: CGFloat = 1.0 + + var body: some View { + Image(systemName: "bell.fill") + .rotationEffect(.degrees(rotation)) + .scaleEffect(scale) + .onTapGesture { + // Manual timing - error prone and hard to maintain + withAnimation(.easeOut(duration: 0.1)) { rotation = 15 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeOut(duration: 0.1)) { rotation = -15 } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.easeOut(duration: 0.1)) { rotation = 0 } + } + // ... more manual timing + } + } +} +``` + +--- + +## Animation Completion Handlers (iOS 17+) ```swift -struct ShakeData { - var offset: CGFloat = 0 - var rotation: Angle = .zero +// GOOD - completion with withAnimation +struct GoodCompletionHandler: View { + @State private var isExpanded = false + @State private var showNextStep = false + + var body: some View { + VStack { + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + + if showNextStep { + Text("Animation Complete!") + } + } + .onTapGesture { + withAnimation(.spring) { + isExpanded.toggle() + } completion: { + showNextStep = true + } + } + } } -.keyframeAnimator( - initialValue: ShakeData(), - trigger: trigger -) { content, data in - content - .offset(x: data.offset) - .rotationEffect(data.rotation) -} keyframes: { value in - KeyframeTrack(\.offset) { - CubicKeyframe(-30, duration: 0.25) - CubicKeyframe(30, duration: 0.5) - CubicKeyframe(0, duration: 0.25) +// GOOD - completion with transaction (reexecutes properly) +struct GoodTransactionCompletion: View { + @State private var bounceCount = 0 + @State private var message = "" + + var body: some View { + VStack { + Circle() + .frame(width: 50, height: 50) + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .transaction(value: bounceCount) { transaction in + transaction.animation = .spring + transaction.addAnimationCompletion { + message = "Bounce \(bounceCount) complete" + } + } + + Text(message) + + Button("Bounce") { + bounceCount += 1 + } + } } +} + +// BAD - completion handler without value parameter (only fires once) +struct BadCompletionHandler: View { + @State private var bounceCount = 0 + @State private var completionCount = 0 - KeyframeTrack(\.rotation) { - LinearKeyframe(.degrees(20), duration: 0.1) - LinearKeyframe(.degrees(-20), duration: 0.2) - LinearKeyframe(.degrees(0), duration: 0.1) + var body: some View { + VStack { + Circle() + .frame(width: 50, height: 50) + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .animation(.spring, value: bounceCount) + .transaction { transaction in // No value parameter! + transaction.addAnimationCompletion { + completionCount += 1 // Only fires once, ever + } + } + + Text("Completions: \(completionCount)") + + Button("Bounce") { + bounceCount += 1 + } + } } } ``` -**Tracks run in parallel**, each animating one property. +--- -### KeyframeTimeline - -Query animation values directly: +## Timing Curves ```swift -let timeline = KeyframeTimeline(initialValue: ShakeData()) { - KeyframeTrack(\.offset) { - CubicKeyframe(-30, duration: 0.25) - CubicKeyframe(30, duration: 0.5) - CubicKeyframe(0, duration: 0.25) +// GOOD - appropriate timing curve for the interaction +struct GoodTimingCurves: View { + @State private var isActive = false + + var body: some View { + VStack(spacing: 20) { + // Quick response for interactive elements + Button("Tap Me") { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isActive.toggle() + } + } + .scaleEffect(isActive ? 0.95 : 1.0) + + // Smooth for appearance changes + Rectangle() + .foregroundStyle(isActive ? .blue : .gray) + .animation(.easeInOut(duration: 0.25), value: isActive) + + // Bouncy for playful feedback + Circle() + .scaleEffect(isActive ? 1.2 : 1.0) + .animation(.bouncy(duration: 0.4), value: isActive) + } } } -let valueAt50Percent = timeline.value(time: 0.5) +// BAD - inappropriate timing curves +struct BadTimingCurves: View { + @State private var isActive = false + + var body: some View { + VStack(spacing: 20) { + // Too slow for button feedback + Button("Tap Me") { + withAnimation(.easeInOut(duration: 1.0)) { // Way too slow! + isActive.toggle() + } + } + .scaleEffect(isActive ? 0.95 : 1.0) + + // Linear feels robotic for UI + Rectangle() + .foregroundStyle(isActive ? .blue : .gray) + .animation(.linear(duration: 0.5), value: isActive) // Feels mechanical + + // No easing on size changes feels abrupt + Circle() + .scaleEffect(isActive ? 1.2 : 1.0) + .animation(.linear(duration: 0.1), value: isActive) // Too abrupt + } + } +} ``` -## Best Practices +--- + +## Disabling Animations -### Animation Guidelines +```swift +// GOOD - selectively disable animations +struct GoodDisableAnimation: View { + @State private var count = 0 -1. **Apply Animations Locally** - - Place animations close to what's being animated - - Prevents unintended side effects + var body: some View { + VStack { + // This should animate + Circle() + .frame(width: CGFloat(count * 10 + 50)) + .animation(.spring, value: count) + + // This should NOT animate (immediate feedback) + Text("Count: \(count)") + .transaction { $0.animation = nil } + + Button("Increment") { + count += 1 + } + } + } +} -2. **Always Use Value Parameter** - - Use `.animation(_:value:)` not deprecated `.animation(_:)` - - Prevents unexpected animation triggers +// GOOD - disable animations from parent context +struct GoodDisableParentAnimation: View { + @State private var isLoading = false -3. **Understand View Identity** - - Property animations require stable view identity - - Changing identity triggers transitions + var body: some View { + VStack { + if isLoading { + ProgressView() + .transition(.opacity) + } -4. **Choose the Right Type** - - Property animations: interpolating values on existing views - - Transitions: inserting/removing views - - Phase animations: multi-step sequences returning to start - - Keyframe animations: complex, precisely-timed animations + DataView() + .transaction { transaction in + transaction.disablesAnimations = true // No animations for data updates + } + } + .animation(.default, value: isLoading) + } +} -### Performance Considerations +// BAD - fighting animation system with zero duration +struct BadDisableAnimation: View { + @State private var count = 0 -1. **Narrow Animation Scope** - - Animate only what needs to change - - Consider breaking views into smaller pieces + var body: some View { + VStack { + Text("Count: \(count)") + .animation(.linear(duration: 0), value: count) // Hacky way to disable -2. **Prefer Transforms Over Layout** - - Animate `offset`, `scale`, `rotation` instead of `frame` - - Layout animations are more expensive + Button("Increment") { + count += 1 + } + } + } +} +``` -3. **Spring Animation Awareness** - - Can be more expensive than simple curves - - Complete later than "logical" completion +--- -### Debugging Animations +## Debugging Animations ```swift -// Print interpolated values -struct MyModifier: ViewModifier, Animatable { +// GOOD - debug modifier to inspect animation values +struct AnimationDebugModifier: ViewModifier, Animatable { var value: Double + let label: String + var animatableData: Double { get { value } set { value = newValue - print("Animation value: \(newValue)") + #if DEBUG + print("[\(label)] Animation value: \(newValue)") + #endif } } -} -// Slow down for inspection -.animation(.linear(duration: 3.0).speed(0.2), value: flag) -``` - -## Common Pitfalls + func body(content: Content) -> some View { + content.opacity(value) + } +} -1. **Animations Without State Changes** - - Animations only work with state changes - - Can't animate constants directly +// GOOD - slow down animation for inspection +struct GoodDebugAnimation: View { + @State private var isExpanded = false -2. **Transition Without Animation** - - Always pair transitions with animations - - Place animations outside conditional structure + var body: some View { + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + #if DEBUG + .animation(.linear(duration: 3.0).speed(0.2), value: isExpanded) // 15 second animation + #else + .animation(.spring, value: isExpanded) + #endif + .onTapGesture { isExpanded.toggle() } + } +} -3. **Forgetting animatableData** - - Default implementation does nothing - - No compiler error, but animation won't work +// BAD - no way to debug animation issues +struct BadNoDebugAnimation: View { + @State private var isExpanded = false -4. **Completion Handlers Not Reexecuting** - - Use `.transaction(value:)` variant - - Ensure closure depends on state + var body: some View { + Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) // Animation not working? No way to tell why + .onTapGesture { isExpanded.toggle() } + } +} +``` -5. **Animation Precedence Confusion** - - Implicit animations override explicit ones - - Use `disablesAnimations` when needed +--- ## Summary Checklist -- [ ] Using `.animation(_:value:)` with value parameter (not deprecated version) -- [ ] Transitions paired with animations outside conditional structure -- [ ] Custom `Animatable` implementations have explicit `animatableData` -- [ ] Completion handlers use `.transaction(value:)` for reexecution -- [ ] Animations placed close to animated content -- [ ] Preferring transforms over layout changes for performance -- [ ] Using phase animations for multi-step sequences (iOS 17+) -- [ ] Using keyframe animations for precise timing control (iOS 17+) -- [ ] View identity stable for property animations -- [ ] Explicit animations for event-driven changes -- [ ] Implicit animations for value-dependent changes +### Do +- [ ] Use `.animation(_:value:)` with value parameter +- [ ] Use `withAnimation` for event-driven animations +- [ ] Place transitions outside conditional structures +- [ ] Implement `animatableData` explicitly for custom Animatable types +- [ ] Prefer transforms (`scale`, `offset`, `rotation`) over layout changes +- [ ] Use `.phaseAnimator` for multi-step sequences (iOS 17+) +- [ ] Use `.keyframeAnimator` for precise timing (iOS 17+) +- [ ] Use `.transaction(value:)` for completion handlers that should refire +- [ ] Scope animations narrowly to affected views +- [ ] Choose appropriate timing curves for the interaction type + +### Don't +- [ ] Use deprecated `.animation(_:)` without value parameter +- [ ] Put animation modifiers inside conditionals for transitions +- [ ] Forget `animatableData` implementation (silent failure) +- [ ] Animate layout properties in performance-critical paths +- [ ] Use manual DispatchQueue timing for sequenced animations +- [ ] Apply broad animations at root view level +- [ ] Use linear timing for UI interactions (feels robotic) +- [ ] Animate on every frame in scroll handlers From 7ed647c45005dacac7afbc401b3908a5b88fe63a Mon Sep 17 00:00:00 2001 From: Omar Elsayed Date: Fri, 30 Jan 2026 22:50:08 +0200 Subject: [PATCH 5/8] spirited the animation file to different refrences --- swiftui-expert-skill/SKILL.md | 12 +- .../references/animation-advanced.md | 350 +++++ .../references/animation-basics.md | 284 +++++ .../references/animation-patterns.md | 1126 ----------------- .../references/animation-transitions.md | 326 +++++ 5 files changed, 967 insertions(+), 1131 deletions(-) create mode 100644 swiftui-expert-skill/references/animation-advanced.md create mode 100644 swiftui-expert-skill/references/animation-basics.md delete mode 100644 swiftui-expert-skill/references/animation-patterns.md create mode 100644 swiftui-expert-skill/references/animation-transitions.md diff --git a/swiftui-expert-skill/SKILL.md b/swiftui-expert-skill/SKILL.md index ba23048..ce1a826 100644 --- a/swiftui-expert-skill/SKILL.md +++ b/swiftui-expert-skill/SKILL.md @@ -16,7 +16,7 @@ Use this skill to build, review, or improve SwiftUI features with correct state - Verify view composition follows extraction rules (see `references/view-structure.md`) - Check performance patterns are applied (see `references/performance-patterns.md`) - Verify list patterns use stable identity (see `references/list-patterns.md`) -- Check animation patterns for correctness (see `references/animation-patterns.md`) +- Check animation patterns for correctness (see `references/animation-basics.md`, `references/animation-transitions.md`) - Inspect Liquid Glass usage for correctness and consistency (see `references/liquid-glass.md`) - Validate iOS 26+ availability handling with sensible fallbacks @@ -26,7 +26,7 @@ Use this skill to build, review, or improve SwiftUI features with correct state - Extract complex views into separate subviews (see `references/view-structure.md`) - Refactor hot paths to minimize redundant state updates (see `references/performance-patterns.md`) - Ensure ForEach uses stable identity (see `references/list-patterns.md`) -- Improve animation patterns (use value parameter, proper transitions, see `references/animation-patterns.md`) +- Improve animation patterns (use value parameter, proper transitions, see `references/animation-basics.md`, `references/animation-transitions.md`) - Suggest image downsampling when `UIImage(data:)` is used (as optional optimization, see `references/image-optimization.md`) - Adopt Liquid Glass only when explicitly requested by the user @@ -36,7 +36,7 @@ Use this skill to build, review, or improve SwiftUI features with correct state - Use `@Observable` for shared state (with `@MainActor` if not using default actor isolation) - Structure views for optimal diffing (extract subviews early, keep views small, see `references/view-structure.md`) - Separate business logic into testable models (see `references/layout-best-practices.md`) -- Use correct animation patterns (implicit vs explicit, transitions, see `references/animation-patterns.md`) +- Use correct animation patterns (implicit vs explicit, transitions, see `references/animation-basics.md`, `references/animation-transitions.md`, `references/animation-advanced.md`) - Apply glass effects after layout/appearance modifiers (see `references/liquid-glass.md`) - Gate iOS 26+ features with `#available` and provide fallbacks @@ -246,7 +246,7 @@ Button("Confirm") { } - [ ] Using relative layout (not hard-coded constants) - [ ] Views work in any context (context-agnostic) -### Animations (see `references/animation-patterns.md`) +### Animations (see `references/animation-basics.md`, `references/animation-transitions.md`, `references/animation-advanced.md`) - [ ] Using `.animation(_:value:)` with value parameter - [ ] Using `withAnimation` for event-driven animations - [ ] Transitions paired with animations outside conditional structure @@ -270,7 +270,9 @@ Button("Confirm") { } - `references/list-patterns.md` - ForEach identity, stability, and list best practices - `references/layout-best-practices.md` - Layout patterns, context-agnostic views, and testability - `references/modern-apis.md` - Modern API usage and deprecated replacements -- `references/animation-patterns.md` - Animation patterns, transitions, and iOS 17+ APIs +- `references/animation-basics.md` - Core animation concepts, implicit/explicit animations, timing, performance +- `references/animation-transitions.md` - Transitions, custom transitions, Animatable protocol +- `references/animation-advanced.md` - Transactions, phase/keyframe animations (iOS 17+), completion handlers (iOS 17+) - `references/sheet-navigation-patterns.md` - Sheet presentation and navigation patterns - `references/scroll-patterns.md` - ScrollView patterns and programmatic scrolling - `references/text-formatting.md` - Modern text formatting and string operations diff --git a/swiftui-expert-skill/references/animation-advanced.md b/swiftui-expert-skill/references/animation-advanced.md new file mode 100644 index 0000000..a57f88d --- /dev/null +++ b/swiftui-expert-skill/references/animation-advanced.md @@ -0,0 +1,350 @@ +# SwiftUI Advanced Animations + +Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), and completion handlers (iOS 17+). + +## Table of Contents +- [Transactions](#transactions) +- [Phase Animations (iOS 17+)](#phase-animations-ios-17) +- [Keyframe Animations (iOS 17+)](#keyframe-animations-ios-17) +- [Animation Completion Handlers (iOS 17+)](#animation-completion-handlers-ios-17) + +--- + +## Transactions + +The underlying mechanism for all animations in SwiftUI. + +### Basic Usage + +```swift +// withAnimation is shorthand for withTransaction +withAnimation(.default) { flag.toggle() } + +// Equivalent explicit transaction +var transaction = Transaction(animation: .default) +withTransaction(transaction) { flag.toggle() } +``` + +### The .transaction Modifier + +```swift +Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .transaction { t in + t.animation = .default + } +``` + +**Note:** This behaves like the deprecated `.animation(_:)` without value parameter - it animates on every state change. + +### Animation Precedence + +**Implicit animations override explicit animations** (later in view tree wins). + +```swift +Button("Tap") { + withAnimation(.linear) { flag.toggle() } +} +.animation(.bouncy, value: flag) // .bouncy wins! +``` + +### Disabling Animations + +```swift +// Prevent implicit animations from overriding +.transaction { t in + t.disablesAnimations = true +} + +// Remove animation entirely +.transaction { $0.animation = nil } +``` + +### Custom Transaction Keys (iOS 17+) + +Pass metadata through transactions. + +```swift +struct ChangeSourceKey: TransactionKey { + static let defaultValue: String = "unknown" +} + +extension Transaction { + var changeSource: String { + get { self[ChangeSourceKey.self] } + set { self[ChangeSourceKey.self] = newValue } + } +} + +// Set source +var transaction = Transaction(animation: .default) +transaction.changeSource = "server" +withTransaction(transaction) { flag.toggle() } + +// Read in view tree +.transaction { t in + if t.changeSource == "server" { + t.animation = .smooth + } else { + t.animation = .bouncy + } +} +``` + +--- + +## Phase Animations (iOS 17+) + +Cycle through discrete phases automatically. Each phase change is a separate animation. + +### Basic Usage + +```swift +// GOOD - triggered phase animation +Button("Shake") { trigger += 1 } + .phaseAnimator( + [0.0, -10.0, 10.0, -5.0, 5.0, 0.0], + trigger: trigger + ) { content, offset in + content.offset(x: offset) + } + +// Infinite loop (no trigger) +Circle() + .phaseAnimator([1.0, 1.2, 1.0]) { content, scale in + content.scaleEffect(scale) + } +``` + +### Enum Phases (Recommended for Clarity) + +```swift +// GOOD - enum phases are self-documenting +enum BouncePhase: CaseIterable { + case initial, up, down, settle + + var scale: CGFloat { + switch self { + case .initial: 1.0 + case .up: 1.2 + case .down: 0.9 + case .settle: 1.0 + } + } +} + +Circle() + .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in + content.scaleEffect(phase.scale) + } +``` + +### Custom Timing Per Phase + +```swift +.phaseAnimator([0, -20, 20], trigger: trigger) { content, offset in + content.offset(x: offset) +} animation: { phase in + switch phase { + case -20: .bouncy + case 20: .linear + default: .smooth + } +} +``` + +### Good vs Bad + +```swift +// GOOD - use phaseAnimator for multi-step sequences +.phaseAnimator([0, -10, 10, 0], trigger: trigger) { content, offset in + content.offset(x: offset) +} + +// BAD - manual DispatchQueue sequencing +Button("Animate") { + withAnimation(.easeOut(duration: 0.1)) { offset = -10 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { offset = 10 } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation { offset = 0 } + } +} +``` + +--- + +## Keyframe Animations (iOS 17+) + +Precise timing control with exact values at specific times. + +### Basic Usage + +```swift +Button("Bounce") { trigger += 1 } + .keyframeAnimator( + initialValue: AnimationValues(), + trigger: trigger + ) { content, value in + content + .scaleEffect(value.scale) + .offset(y: value.verticalOffset) + } keyframes: { _ in + KeyframeTrack(\.scale) { + SpringKeyframe(1.2, duration: 0.15) + SpringKeyframe(0.9, duration: 0.1) + SpringKeyframe(1.0, duration: 0.15) + } + KeyframeTrack(\.verticalOffset) { + LinearKeyframe(-20, duration: 0.15) + LinearKeyframe(0, duration: 0.25) + } + } + +struct AnimationValues { + var scale: CGFloat = 1.0 + var verticalOffset: CGFloat = 0 +} +``` + +### Keyframe Types + +| Type | Behavior | +|------|----------| +| `CubicKeyframe` | Smooth interpolation | +| `LinearKeyframe` | Straight-line interpolation | +| `SpringKeyframe` | Spring physics | +| `MoveKeyframe` | Instant jump (no interpolation) | + +### Multiple Synchronized Tracks + +Tracks run **in parallel**, each animating one property. + +```swift +// GOOD - bell shake with synchronized rotation and scale +struct BellAnimation { + var rotation: Double = 0 + var scale: CGFloat = 1.0 +} + +Image(systemName: "bell.fill") + .keyframeAnimator( + initialValue: BellAnimation(), + trigger: trigger + ) { content, value in + content + .rotationEffect(.degrees(value.rotation)) + .scaleEffect(value.scale) + } keyframes: { _ in + KeyframeTrack(\.rotation) { + CubicKeyframe(15, duration: 0.1) + CubicKeyframe(-15, duration: 0.1) + CubicKeyframe(10, duration: 0.1) + CubicKeyframe(-10, duration: 0.1) + CubicKeyframe(0, duration: 0.1) + } + KeyframeTrack(\.scale) { + CubicKeyframe(1.1, duration: 0.25) + CubicKeyframe(1.0, duration: 0.25) + } + } + +// BAD - manual timer-based animation +Image(systemName: "bell.fill") + .onTapGesture { + withAnimation(.easeOut(duration: 0.1)) { rotation = 15 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { rotation = -15 } + } + // ... more manual timing - error prone + } +``` + +### KeyframeTimeline (iOS 17+) + +Query animation values directly for testing or non-SwiftUI use. + +```swift +let timeline = KeyframeTimeline(initialValue: AnimationValues()) { + KeyframeTrack(\.scale) { + CubicKeyframe(1.2, duration: 0.25) + CubicKeyframe(1.0, duration: 0.25) + } +} + +let midpoint = timeline.value(time: 0.25) +print(midpoint.scale) // Value at 0.25 seconds +``` + +--- + +## Animation Completion Handlers (iOS 17+) + +Execute code when animations finish. + +### With withAnimation + +```swift +// GOOD - completion with withAnimation +Button("Animate") { + withAnimation(.spring) { + isExpanded.toggle() + } completion: { + showNextStep = true + } +} +``` + +### With Transaction (For Reexecution) + +```swift +// GOOD - completion fires on every trigger change +Circle() + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .transaction(value: bounceCount) { transaction in + transaction.animation = .spring + transaction.addAnimationCompletion { + message = "Bounce \(bounceCount) complete" + } + } + +// BAD - completion only fires ONCE (no value parameter) +Circle() + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .animation(.spring, value: bounceCount) + .transaction { transaction in // No value! + transaction.addAnimationCompletion { + completionCount += 1 // Only fires once, ever + } + } +``` + +--- + +## Quick Reference + +### Transactions (All iOS versions) +- `withTransaction` is the explicit form of `withAnimation` +- Implicit animations override explicit (later in view tree wins) +- Use `disablesAnimations` to prevent override +- Use `.transaction { $0.animation = nil }` to remove animation + +### Custom Transaction Keys (iOS 17+) +- Pass metadata through animation system via `TransactionKey` + +### Phase Animations (iOS 17+) +- Use for multi-step sequences returning to start +- Prefer enum phases for clarity +- Each phase change is a separate animation +- Use `trigger` parameter for one-shot animations + +### Keyframe Animations (iOS 17+) +- Use for precise timing control +- Tracks run in parallel +- Use `KeyframeTimeline` for testing/advanced use +- Prefer over manual DispatchQueue timing + +### Completion Handlers (iOS 17+) +- Use `.transaction(value:)` for handlers that should refire +- Without `value:` parameter, completion only fires once diff --git a/swiftui-expert-skill/references/animation-basics.md b/swiftui-expert-skill/references/animation-basics.md new file mode 100644 index 0000000..859682a --- /dev/null +++ b/swiftui-expert-skill/references/animation-basics.md @@ -0,0 +1,284 @@ +# SwiftUI Animation Basics + +Core animation concepts, implicit vs explicit animations, timing curves, and performance patterns. + +## Table of Contents +- [Core Concepts](#core-concepts) +- [Implicit Animations](#implicit-animations) +- [Explicit Animations](#explicit-animations) +- [Animation Placement](#animation-placement) +- [Selective Animation](#selective-animation) +- [Timing Curves](#timing-curves) +- [Animation Performance](#animation-performance) +- [Disabling Animations](#disabling-animations) +- [Debugging](#debugging) + +--- + +## Core Concepts + +State changes trigger view updates. SwiftUI provides mechanisms to animate these changes. + +**Animation Process:** +1. State change triggers view tree re-evaluation +2. SwiftUI compares new tree to current render tree +3. Animatable properties are identified and interpolated (~60 fps) + +**Key Characteristics:** +- Animations are additive and cancelable +- Always start from current render tree state +- Blend smoothly when interrupted + +--- + +## Implicit Animations + +Use `.animation(_:value:)` to animate when a specific value changes. + +```swift +// GOOD - uses value parameter +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + .onTapGesture { isExpanded.toggle() } + +// BAD - deprecated, animates all changes unexpectedly +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring) // Deprecated! +``` + +--- + +## Explicit Animations + +Use `withAnimation` for event-driven state changes. + +```swift +// GOOD - explicit animation +Button("Toggle") { + withAnimation(.spring) { + isExpanded.toggle() + } +} + +// BAD - no animation context +Button("Toggle") { + isExpanded.toggle() // Abrupt change +} +``` + +**When to use which:** +- **Implicit**: Animations tied to specific value changes, precise view tree scope +- **Explicit**: Event-driven animations (button taps, gestures) + +--- + +## Animation Placement + +Place animation modifiers after the properties they should animate. + +```swift +// GOOD - animation after properties +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .foregroundStyle(isExpanded ? .blue : .red) + .animation(.default, value: isExpanded) // Animates both + +// BAD - animation before properties +Rectangle() + .animation(.default, value: isExpanded) // Too early! + .frame(width: isExpanded ? 200 : 100, height: 50) +``` + +--- + +## Selective Animation + +Animate only specific properties using multiple animation modifiers or scoped animations. + +```swift +// GOOD - selective animation +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) // Animate size + .foregroundStyle(isExpanded ? .blue : .red) + .animation(nil, value: isExpanded) // Don't animate color + +// iOS 17+ scoped animation +Rectangle() + .foregroundStyle(isExpanded ? .blue : .red) // Not animated + .animation(.spring) { + $0.frame(width: isExpanded ? 200 : 100, height: 50) // Animated + } +``` + +--- + +## Timing Curves + +### Built-in Curves + +| Curve | Use Case | +|-------|----------| +| `.spring` | Interactive elements, most UI | +| `.easeInOut` | Appearance changes | +| `.bouncy` | Playful feedback (iOS 17+) | +| `.linear` | Progress indicators only | + +### Modifiers + +```swift +.animation(.default.speed(2.0), value: flag) // 2x faster +.animation(.default.delay(0.5), value: flag) // Delayed start +.animation(.default.repeatCount(3, autoreverses: true), value: flag) +``` + +### Good vs Bad Timing + +```swift +// GOOD - appropriate timing for interaction type +Button("Tap") { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isActive.toggle() + } +} +.scaleEffect(isActive ? 0.95 : 1.0) + +// BAD - too slow for button feedback +Button("Tap") { + withAnimation(.easeInOut(duration: 1.0)) { // Way too slow! + isActive.toggle() + } +} + +// BAD - linear feels robotic +Rectangle() + .animation(.linear(duration: 0.5), value: isActive) // Mechanical +``` + +--- + +## Animation Performance + +### Prefer Transforms Over Layout + +```swift +// GOOD - GPU accelerated transforms +Rectangle() + .frame(width: 100, height: 100) + .scaleEffect(isActive ? 1.5 : 1.0) // Fast + .offset(x: isActive ? 50 : 0) // Fast + .rotationEffect(.degrees(isActive ? 45 : 0)) // Fast + .animation(.spring, value: isActive) + +// BAD - layout changes are expensive +Rectangle() + .frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100) // Expensive + .padding(isActive ? 50 : 0) // Expensive +``` + +### Narrow Animation Scope + +```swift +// GOOD - animation scoped to specific subview +VStack { + HeaderView() // Not affected + ExpandableContent(isExpanded: isExpanded) + .animation(.spring, value: isExpanded) // Only this + FooterView() // Not affected +} + +// BAD - animation at root +VStack { + HeaderView() + ExpandableContent(isExpanded: isExpanded) + FooterView() +} +.animation(.spring, value: isExpanded) // Animates everything +``` + +### Avoid Animation in Hot Paths + +```swift +// GOOD - gate by threshold +.onPreferenceChange(ScrollOffsetKey.self) { offset in + let shouldShow = offset.y < -50 + if shouldShow != showTitle { // Only when crossing threshold + withAnimation(.easeOut(duration: 0.2)) { + showTitle = shouldShow + } + } +} + +// BAD - animating every scroll change +.onPreferenceChange(ScrollOffsetKey.self) { offset in + withAnimation { // Fires constantly! + self.offset = offset.y + } +} +``` + +--- + +## Disabling Animations + +```swift +// GOOD - disable with transaction +Text("Count: \(count)") + .transaction { $0.animation = nil } + +// GOOD - disable from parent context +DataView() + .transaction { $0.disablesAnimations = true } + +// BAD - hacky zero duration +Text("Count: \(count)") + .animation(.linear(duration: 0), value: count) // Hacky +``` + +--- + +## Debugging + +```swift +// Slow down for inspection +#if DEBUG +.animation(.linear(duration: 3.0).speed(0.2), value: isExpanded) +#else +.animation(.spring, value: isExpanded) +#endif + +// Debug modifier to log values +struct AnimationDebugModifier: ViewModifier, Animatable { + var value: Double + var animatableData: Double { + get { value } + set { + value = newValue + print("Animation: \(newValue)") + } + } + func body(content: Content) -> some View { + content.opacity(value) + } +} +``` + +--- + +## Quick Reference + +### Do +- Use `.animation(_:value:)` with value parameter +- Use `withAnimation` for event-driven animations +- Prefer transforms over layout changes +- Scope animations narrowly +- Choose appropriate timing curves + +### Don't +- Use deprecated `.animation(_:)` without value +- Animate layout properties in hot paths +- Apply broad animations at root level +- Use linear timing for UI (feels robotic) +- Animate on every frame in scroll handlers diff --git a/swiftui-expert-skill/references/animation-patterns.md b/swiftui-expert-skill/references/animation-patterns.md deleted file mode 100644 index 1f5f536..0000000 --- a/swiftui-expert-skill/references/animation-patterns.md +++ /dev/null @@ -1,1126 +0,0 @@ -# SwiftUI Animation Patterns Reference - -## Core Concepts - -### How SwiftUI Animations Work - -State changes are the only way to trigger view updates. By default, changes aren't animated, but SwiftUI provides mechanisms to animate them. - -**Animation Process:** -1. State change triggers view tree re-evaluation -2. SwiftUI compares new view tree to current render tree -3. Animatable properties are identified -4. Timing curve generates progress values (0 to 1) -5. Values interpolate smoothly (~60 fps) - -**Key Characteristics:** -- Animations are additive and cancelable -- Always start from current render tree state -- Blend smoothly when interrupted - ---- - -## Implicit vs Explicit Animations - -### 1. Implicit Animations - -Use `.animation(_:value:)` to animate when a specific value changes. - -```swift -// GOOD - uses value parameter for precise control -struct GoodImplicitAnimation: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - .animation(.spring, value: isExpanded) - .onTapGesture { isExpanded.toggle() } - } -} - -// BAD - deprecated animation without value (animates everything) -struct BadImplicitAnimation: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - .animation(.spring) // Deprecated! Animates all changes unexpectedly - .onTapGesture { isExpanded.toggle() } - } -} -``` - -### 2. Explicit Animations - -Use `withAnimation` to wrap state changes that should be animated. - -```swift -// GOOD - explicit animation for event-driven changes -struct GoodExplicitAnimation: View { - @State private var isExpanded = false - - var body: some View { - VStack { - Button("Toggle") { - withAnimation(.spring) { - isExpanded.toggle() - } - } - - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - } - } -} - -// BAD - state change without animation context -struct BadExplicitAnimation: View { - @State private var isExpanded = false - - var body: some View { - VStack { - Button("Toggle") { - isExpanded.toggle() // No animation - abrupt change - } - - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - } - } -} -``` - -### 3. Animation Placement - -```swift -// GOOD - animation placed after the properties it should animate -struct GoodAnimationPlacement: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - .foregroundStyle(isExpanded ? .blue : .red) - .animation(.default, value: isExpanded) // Animates both frame and color - } -} - -// BAD - animation placed before properties (may not animate as expected) -struct BadAnimationPlacement: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .animation(.default, value: isExpanded) // Too early! - .frame(width: isExpanded ? 200 : 100, height: 50) - .foregroundStyle(isExpanded ? .blue : .red) - } -} -``` - -### 4. Selective Animation - -```swift -// GOOD - animate only specific properties -struct GoodSelectiveAnimation: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - .animation(.spring, value: isExpanded) // Animate size - .foregroundStyle(isExpanded ? .blue : .red) - .animation(nil, value: isExpanded) // Don't animate color - } -} - -// iOS 17+ scoped animation -struct GoodScopedAnimation: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .foregroundStyle(isExpanded ? .blue : .red) // Not animated - .animation(.spring) { - $0.frame(width: isExpanded ? 200 : 100, height: 50) // Only this is animated - } - } -} - -// BAD - animating everything when only some properties should animate -struct BadSelectiveAnimation: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - .foregroundStyle(isExpanded ? .blue : .red) - .animation(.spring, value: isExpanded) // Animates both - maybe unintended - } -} -``` - ---- - -## Transitions - -### 1. Basic Transitions - -```swift -// GOOD - transition with animation outside conditional -struct GoodTransition: View { - @State private var showDetail = false - - var body: some View { - VStack { - Button("Toggle") { - showDetail.toggle() - } - - if showDetail { - DetailView() - .transition(.slide) - } - } - .animation(.spring, value: showDetail) // Animation outside conditional - } -} - -// GOOD - explicit animation for transitions -struct GoodExplicitTransition: View { - @State private var showDetail = false - - var body: some View { - VStack { - Button("Toggle") { - withAnimation(.spring) { - showDetail.toggle() - } - } - - if showDetail { - DetailView() - .transition(.scale.combined(with: .opacity)) - } - } - } -} - -// BAD - animation inside conditional (gets removed with the view!) -struct BadTransition: View { - @State private var showDetail = false - - var body: some View { - VStack { - Button("Toggle") { - showDetail.toggle() - } - - if showDetail { - DetailView() - .transition(.slide) - .animation(.spring, value: showDetail) // Won't work on removal! - } - } - } -} - -// BAD - transition without any animation -struct BadNoAnimationTransition: View { - @State private var showDetail = false - - var body: some View { - VStack { - Button("Toggle") { - showDetail.toggle() // No animation context - } - - if showDetail { - DetailView() - .transition(.slide) // Transition ignored - view just appears/disappears - } - } - } -} -``` - -### 2. Asymmetric Transitions - -```swift -// GOOD - different animations for insertion vs removal -struct GoodAsymmetricTransition: View { - @State private var showCard = false - - var body: some View { - VStack { - Button("Toggle Card") { - withAnimation(.spring) { - showCard.toggle() - } - } - - if showCard { - CardView() - .transition( - .asymmetric( - insertion: .scale.combined(with: .opacity), - removal: .move(edge: .bottom).combined(with: .opacity) - ) - ) - } - } - } -} - -// BAD - using same transition when different behaviors are needed -struct BadSymmetricTransition: View { - @State private var showCard = false - - var body: some View { - VStack { - Button("Toggle Card") { - withAnimation { - showCard.toggle() - } - } - - if showCard { - CardView() - .transition(.slide) // Same animation both ways - may feel awkward - } - } - } -} -``` - -### 3. Custom Transitions - -```swift -// GOOD - reusable custom transition (iOS 17+) -struct BlurTransition: Transition { - var radius: CGFloat - - func body(content: Content, phase: TransitionPhase) -> some View { - content - .blur(radius: phase.isIdentity ? 0 : radius) - .opacity(phase.isIdentity ? 1 : 0) - } -} - -extension AnyTransition { - static var blur: AnyTransition { - .modifier( - active: BlurModifier(radius: 10), - identity: BlurModifier(radius: 0) - ) - } -} - -struct GoodCustomTransition: View { - @State private var showContent = false - - var body: some View { - VStack { - Button("Toggle") { - withAnimation(.easeInOut(duration: 0.5)) { - showContent.toggle() - } - } - - if showContent { - ContentView() - .transition(BlurTransition(radius: 10)) - } - } - } -} - -// BAD - inline complex transition logic -struct BadCustomTransition: View { - @State private var showContent = false - - var body: some View { - VStack { - Button("Toggle") { - withAnimation { - showContent.toggle() - } - } - - if showContent { - ContentView() - .blur(radius: showContent ? 0 : 10) // Not a transition - won't animate on removal - .opacity(showContent ? 1 : 0) - } - } - } -} -``` - ---- - -## Property Animations vs Transitions - -```swift -// GOOD - property animation for existing view changes -struct GoodPropertyAnimation: View { - @State private var isExpanded = false - - var body: some View { - // Same Rectangle exists before and after - property animation - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - .animation(.spring, value: isExpanded) - .onTapGesture { isExpanded.toggle() } - } -} - -// GOOD - transition for view insertion/removal -struct GoodTransitionUsage: View { - @State private var showLarge = false - - var body: some View { - VStack { - Button("Toggle") { - withAnimation { - showLarge.toggle() - } - } - - // Different views - use transition - if showLarge { - LargeRectangle() - .transition(.scale) - } else { - SmallRectangle() - .transition(.scale) - } - } - } -} - -// BAD - expecting property animation when identity changes -struct BadIdentityChange: View { - @State private var isExpanded = false - - var body: some View { - // Different branches = different identities = transition, not property animation - if isExpanded { - Rectangle() - .frame(width: 200, height: 50) - .animation(.spring, value: isExpanded) // Won't smoothly animate! - } else { - Rectangle() - .frame(width: 100, height: 50) - .animation(.spring, value: isExpanded) - } - } -} -``` - ---- - -## The Animatable Protocol - -### 1. Custom Animatable Modifier - -```swift -// GOOD - proper Animatable implementation -struct ShakeModifier: ViewModifier, Animatable { - var shakeCount: Double - - var animatableData: Double { - get { shakeCount } - set { shakeCount = newValue } - } - - func body(content: Content) -> some View { - content - .offset(x: sin(shakeCount * .pi * 2) * 10) - } -} - -extension View { - func shake(count: Int) -> some View { - modifier(ShakeModifier(shakeCount: Double(count))) - } -} - -struct GoodShakeAnimation: View { - @State private var shakeCount = 0 - - var body: some View { - Button("Shake Me") { - shakeCount += 3 - } - .shake(count: shakeCount) - .animation(.default, value: shakeCount) - } -} - -// BAD - missing animatableData implementation -struct BadShakeModifier: ViewModifier { - var shakeCount: Double - - // Missing animatableData! Uses default EmptyAnimatableData - - func body(content: Content) -> some View { - content - .offset(x: sin(shakeCount * .pi * 2) * 10) - } -} - -struct BadShakeAnimation: View { - @State private var shakeCount = 0 - - var body: some View { - Button("Shake Me") { - shakeCount += 3 - } - .modifier(BadShakeModifier(shakeCount: Double(shakeCount))) - .animation(.default, value: shakeCount) // Won't animate - jumps to final value - } -} -``` - -### 2. Multiple Animatable Properties - -```swift -// GOOD - using AnimatablePair for multiple properties -struct ComplexAnimationModifier: ViewModifier, Animatable { - var scale: CGFloat - var rotation: Double - - var animatableData: AnimatablePair { - get { AnimatablePair(scale, rotation) } - set { - scale = newValue.first - rotation = newValue.second - } - } - - func body(content: Content) -> some View { - content - .scaleEffect(scale) - .rotationEffect(.degrees(rotation)) - } -} - -// GOOD - nested AnimatablePair for 3+ properties -struct ThreePropertyModifier: ViewModifier, Animatable { - var x: CGFloat - var y: CGFloat - var rotation: Double - - var animatableData: AnimatablePair, Double> { - get { AnimatablePair(AnimatablePair(x, y), rotation) } - set { - x = newValue.first.first - y = newValue.first.second - rotation = newValue.second - } - } - - func body(content: Content) -> some View { - content - .offset(x: x, y: y) - .rotationEffect(.degrees(rotation)) - } -} - -// BAD - separate state for each property (can't coordinate animation) -struct BadMultiPropertyAnimation: View { - @State private var scale: CGFloat = 1 - @State private var rotation: Double = 0 - - var body: some View { - Rectangle() - .scaleEffect(scale) - .rotationEffect(.degrees(rotation)) - .animation(.spring, value: scale) - .animation(.spring, value: rotation) - .onTapGesture { - // These animate separately, potentially out of sync - scale = scale == 1 ? 1.5 : 1 - rotation = rotation == 0 ? 45 : 0 - } - } -} -``` - ---- - -## Animation Performance - -### 1. Prefer Transforms Over Layout - -```swift -// GOOD - animating transforms (GPU accelerated) -struct GoodPerformanceAnimation: View { - @State private var isActive = false - - var body: some View { - Rectangle() - .frame(width: 100, height: 100) - .scaleEffect(isActive ? 1.5 : 1.0) // Transform - fast - .offset(x: isActive ? 50 : 0) // Transform - fast - .rotationEffect(.degrees(isActive ? 45 : 0)) // Transform - fast - .animation(.spring, value: isActive) - .onTapGesture { isActive.toggle() } - } -} - -// BAD - animating layout properties (triggers layout passes) -struct BadPerformanceAnimation: View { - @State private var isActive = false - - var body: some View { - Rectangle() - .frame( - width: isActive ? 150 : 100, // Layout change - expensive - height: isActive ? 150 : 100 - ) - .padding(isActive ? 50 : 0) // Layout change - expensive - .animation(.spring, value: isActive) - .onTapGesture { isActive.toggle() } - } -} -``` - -### 2. Narrow Animation Scope - -```swift -// GOOD - animation scoped to specific subview -struct GoodScopedAnimation: View { - @State private var isExpanded = false - - var body: some View { - VStack { - HeaderView() // Not affected by animation - - ExpandableContent(isExpanded: isExpanded) - .animation(.spring, value: isExpanded) // Only this animates - - FooterView() // Not affected by animation - } - } -} - -// BAD - animation at root affects entire tree -struct BadBroadAnimation: View { - @State private var isExpanded = false - - var body: some View { - VStack { - HeaderView() - ExpandableContent(isExpanded: isExpanded) - FooterView() - } - .animation(.spring, value: isExpanded) // Animates everything unnecessarily - } -} -``` - -### 3. Avoid Animation in Hot Paths - -```swift -// GOOD - gate animations by threshold -struct GoodScrollAnimation: View { - @State private var showTitle = false - - var body: some View { - ScrollView { - // Content - } - .onPreferenceChange(ScrollOffsetKey.self) { offset in - let shouldShow = offset.y < -50 - if shouldShow != showTitle { // Only update when crossing threshold - withAnimation(.easeOut(duration: 0.2)) { - showTitle = shouldShow - } - } - } - } -} - -// BAD - animating on every scroll position change -struct BadScrollAnimation: View { - @State private var offset: CGFloat = 0 - - var body: some View { - ScrollView { - // Content - } - .onPreferenceChange(ScrollOffsetKey.self) { newOffset in - withAnimation { // Fires constantly during scroll! - offset = newOffset.y - } - } - } -} -``` - ---- - -## Phase Animations (iOS 17+) - -```swift -// GOOD - phase animator for multi-step sequences -struct GoodPhaseAnimation: View { - @State private var trigger = 0 - - var body: some View { - Button("Animate") { - trigger += 1 - } - .phaseAnimator( - [0.0, -10.0, 10.0, -5.0, 5.0, 0.0], - trigger: trigger - ) { content, offset in - content.offset(x: offset) - } animation: { phase in - switch phase { - case 0: .smooth - default: .snappy - } - } - } -} - -// GOOD - using enum phases for clarity -enum BouncePhase: CaseIterable { - case initial, up, down, settle - - var scale: CGFloat { - switch self { - case .initial: 1.0 - case .up: 1.2 - case .down: 0.9 - case .settle: 1.0 - } - } -} - -struct GoodEnumPhaseAnimation: View { - @State private var trigger = 0 - - var body: some View { - Circle() - .frame(width: 50, height: 50) - .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in - content.scaleEffect(phase.scale) - } - .onTapGesture { trigger += 1 } - } -} - -// BAD - using custom Animatable when phaseAnimator would be simpler -struct BadManualMultiStep: View { - @State private var animationProgress: Double = 0 - - var body: some View { - Button("Animate") { - // Complex manual animation sequencing - withAnimation(.easeOut(duration: 0.1)) { - animationProgress = 0.25 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation(.easeInOut(duration: 0.1)) { - animationProgress = 0.5 - } - } - // ... more manual sequencing - } - .offset(x: sin(animationProgress * .pi * 4) * 20) - } -} -``` - ---- - -## Keyframe Animations (iOS 17+) - -```swift -// GOOD - keyframe animation for precise timing control -struct GoodKeyframeAnimation: View { - @State private var trigger = 0 - - var body: some View { - Button("Bounce") { - trigger += 1 - } - .keyframeAnimator( - initialValue: AnimationValues(), - trigger: trigger - ) { content, value in - content - .scaleEffect(value.scale) - .offset(y: value.verticalOffset) - } keyframes: { _ in - KeyframeTrack(\.scale) { - SpringKeyframe(1.2, duration: 0.15) - SpringKeyframe(0.9, duration: 0.1) - SpringKeyframe(1.0, duration: 0.15) - } - - KeyframeTrack(\.verticalOffset) { - LinearKeyframe(-20, duration: 0.15) - LinearKeyframe(0, duration: 0.25) - } - } - } -} - -struct AnimationValues { - var scale: CGFloat = 1.0 - var verticalOffset: CGFloat = 0 -} - -// GOOD - multiple synchronized tracks -struct GoodMultiTrackKeyframe: View { - @State private var trigger = 0 - - var body: some View { - Image(systemName: "bell.fill") - .font(.largeTitle) - .keyframeAnimator( - initialValue: BellAnimation(), - trigger: trigger - ) { content, value in - content - .rotationEffect(.degrees(value.rotation)) - .scaleEffect(value.scale) - } keyframes: { _ in - KeyframeTrack(\.rotation) { - CubicKeyframe(15, duration: 0.1) - CubicKeyframe(-15, duration: 0.1) - CubicKeyframe(10, duration: 0.1) - CubicKeyframe(-10, duration: 0.1) - CubicKeyframe(0, duration: 0.1) - } - - KeyframeTrack(\.scale) { - CubicKeyframe(1.1, duration: 0.25) - CubicKeyframe(1.0, duration: 0.25) - } - } - .onTapGesture { trigger += 1 } - } -} - -struct BellAnimation { - var rotation: Double = 0 - var scale: CGFloat = 1.0 -} - -// BAD - manual timer-based animation -struct BadManualKeyframe: View { - @State private var rotation: Double = 0 - @State private var scale: CGFloat = 1.0 - - var body: some View { - Image(systemName: "bell.fill") - .rotationEffect(.degrees(rotation)) - .scaleEffect(scale) - .onTapGesture { - // Manual timing - error prone and hard to maintain - withAnimation(.easeOut(duration: 0.1)) { rotation = 15 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation(.easeOut(duration: 0.1)) { rotation = -15 } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - withAnimation(.easeOut(duration: 0.1)) { rotation = 0 } - } - // ... more manual timing - } - } -} -``` - ---- - -## Animation Completion Handlers (iOS 17+) - -```swift -// GOOD - completion with withAnimation -struct GoodCompletionHandler: View { - @State private var isExpanded = false - @State private var showNextStep = false - - var body: some View { - VStack { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - - if showNextStep { - Text("Animation Complete!") - } - } - .onTapGesture { - withAnimation(.spring) { - isExpanded.toggle() - } completion: { - showNextStep = true - } - } - } -} - -// GOOD - completion with transaction (reexecutes properly) -struct GoodTransactionCompletion: View { - @State private var bounceCount = 0 - @State private var message = "" - - var body: some View { - VStack { - Circle() - .frame(width: 50, height: 50) - .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) - .transaction(value: bounceCount) { transaction in - transaction.animation = .spring - transaction.addAnimationCompletion { - message = "Bounce \(bounceCount) complete" - } - } - - Text(message) - - Button("Bounce") { - bounceCount += 1 - } - } - } -} - -// BAD - completion handler without value parameter (only fires once) -struct BadCompletionHandler: View { - @State private var bounceCount = 0 - @State private var completionCount = 0 - - var body: some View { - VStack { - Circle() - .frame(width: 50, height: 50) - .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) - .animation(.spring, value: bounceCount) - .transaction { transaction in // No value parameter! - transaction.addAnimationCompletion { - completionCount += 1 // Only fires once, ever - } - } - - Text("Completions: \(completionCount)") - - Button("Bounce") { - bounceCount += 1 - } - } - } -} -``` - ---- - -## Timing Curves - -```swift -// GOOD - appropriate timing curve for the interaction -struct GoodTimingCurves: View { - @State private var isActive = false - - var body: some View { - VStack(spacing: 20) { - // Quick response for interactive elements - Button("Tap Me") { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - isActive.toggle() - } - } - .scaleEffect(isActive ? 0.95 : 1.0) - - // Smooth for appearance changes - Rectangle() - .foregroundStyle(isActive ? .blue : .gray) - .animation(.easeInOut(duration: 0.25), value: isActive) - - // Bouncy for playful feedback - Circle() - .scaleEffect(isActive ? 1.2 : 1.0) - .animation(.bouncy(duration: 0.4), value: isActive) - } - } -} - -// BAD - inappropriate timing curves -struct BadTimingCurves: View { - @State private var isActive = false - - var body: some View { - VStack(spacing: 20) { - // Too slow for button feedback - Button("Tap Me") { - withAnimation(.easeInOut(duration: 1.0)) { // Way too slow! - isActive.toggle() - } - } - .scaleEffect(isActive ? 0.95 : 1.0) - - // Linear feels robotic for UI - Rectangle() - .foregroundStyle(isActive ? .blue : .gray) - .animation(.linear(duration: 0.5), value: isActive) // Feels mechanical - - // No easing on size changes feels abrupt - Circle() - .scaleEffect(isActive ? 1.2 : 1.0) - .animation(.linear(duration: 0.1), value: isActive) // Too abrupt - } - } -} -``` - ---- - -## Disabling Animations - -```swift -// GOOD - selectively disable animations -struct GoodDisableAnimation: View { - @State private var count = 0 - - var body: some View { - VStack { - // This should animate - Circle() - .frame(width: CGFloat(count * 10 + 50)) - .animation(.spring, value: count) - - // This should NOT animate (immediate feedback) - Text("Count: \(count)") - .transaction { $0.animation = nil } - - Button("Increment") { - count += 1 - } - } - } -} - -// GOOD - disable animations from parent context -struct GoodDisableParentAnimation: View { - @State private var isLoading = false - - var body: some View { - VStack { - if isLoading { - ProgressView() - .transition(.opacity) - } - - DataView() - .transaction { transaction in - transaction.disablesAnimations = true // No animations for data updates - } - } - .animation(.default, value: isLoading) - } -} - -// BAD - fighting animation system with zero duration -struct BadDisableAnimation: View { - @State private var count = 0 - - var body: some View { - VStack { - Text("Count: \(count)") - .animation(.linear(duration: 0), value: count) // Hacky way to disable - - Button("Increment") { - count += 1 - } - } - } -} -``` - ---- - -## Debugging Animations - -```swift -// GOOD - debug modifier to inspect animation values -struct AnimationDebugModifier: ViewModifier, Animatable { - var value: Double - let label: String - - var animatableData: Double { - get { value } - set { - value = newValue - #if DEBUG - print("[\(label)] Animation value: \(newValue)") - #endif - } - } - - func body(content: Content) -> some View { - content.opacity(value) - } -} - -// GOOD - slow down animation for inspection -struct GoodDebugAnimation: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - #if DEBUG - .animation(.linear(duration: 3.0).speed(0.2), value: isExpanded) // 15 second animation - #else - .animation(.spring, value: isExpanded) - #endif - .onTapGesture { isExpanded.toggle() } - } -} - -// BAD - no way to debug animation issues -struct BadNoDebugAnimation: View { - @State private var isExpanded = false - - var body: some View { - Rectangle() - .frame(width: isExpanded ? 200 : 100, height: 50) - .animation(.spring, value: isExpanded) // Animation not working? No way to tell why - .onTapGesture { isExpanded.toggle() } - } -} -``` - ---- - -## Summary Checklist - -### Do -- [ ] Use `.animation(_:value:)` with value parameter -- [ ] Use `withAnimation` for event-driven animations -- [ ] Place transitions outside conditional structures -- [ ] Implement `animatableData` explicitly for custom Animatable types -- [ ] Prefer transforms (`scale`, `offset`, `rotation`) over layout changes -- [ ] Use `.phaseAnimator` for multi-step sequences (iOS 17+) -- [ ] Use `.keyframeAnimator` for precise timing (iOS 17+) -- [ ] Use `.transaction(value:)` for completion handlers that should refire -- [ ] Scope animations narrowly to affected views -- [ ] Choose appropriate timing curves for the interaction type - -### Don't -- [ ] Use deprecated `.animation(_:)` without value parameter -- [ ] Put animation modifiers inside conditionals for transitions -- [ ] Forget `animatableData` implementation (silent failure) -- [ ] Animate layout properties in performance-critical paths -- [ ] Use manual DispatchQueue timing for sequenced animations -- [ ] Apply broad animations at root view level -- [ ] Use linear timing for UI interactions (feels robotic) -- [ ] Animate on every frame in scroll handlers diff --git a/swiftui-expert-skill/references/animation-transitions.md b/swiftui-expert-skill/references/animation-transitions.md new file mode 100644 index 0000000..29b3f98 --- /dev/null +++ b/swiftui-expert-skill/references/animation-transitions.md @@ -0,0 +1,326 @@ +# SwiftUI Transitions + +Transitions for view insertion/removal, custom transitions, and the Animatable protocol. + +## Table of Contents +- [Property Animations vs Transitions](#property-animations-vs-transitions) +- [Basic Transitions](#basic-transitions) +- [Asymmetric Transitions](#asymmetric-transitions) +- [Custom Transitions](#custom-transitions) +- [Identity and Transitions](#identity-and-transitions) +- [The Animatable Protocol](#the-animatable-protocol) + +--- + +## Property Animations vs Transitions + +**Property animations**: Interpolate values on views that exist before AND after state change. + +**Transitions**: Animate views being inserted or removed from the render tree. + +```swift +// Property animation - same view, different properties +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + +// Transition - view inserted/removed +if showDetail { + DetailView() + .transition(.scale) +} +``` + +--- + +## Basic Transitions + +### Critical: Transitions Require Animation Context + +```swift +// GOOD - animation outside conditional +VStack { + Button("Toggle") { showDetail.toggle() } + if showDetail { + DetailView() + .transition(.slide) + } +} +.animation(.spring, value: showDetail) + +// GOOD - explicit animation +Button("Toggle") { + withAnimation(.spring) { + showDetail.toggle() + } +} +if showDetail { + DetailView() + .transition(.scale.combined(with: .opacity)) +} + +// BAD - animation inside conditional (removed with view!) +if showDetail { + DetailView() + .transition(.slide) + .animation(.spring, value: showDetail) // Won't work on removal! +} + +// BAD - no animation context +Button("Toggle") { + showDetail.toggle() // No animation +} +if showDetail { + DetailView() + .transition(.slide) // Ignored - just appears/disappears +} +``` + +### Built-in Transitions + +| Transition | Effect | +|------------|--------| +| `.opacity` | Fade in/out (default) | +| `.scale` | Scale up/down | +| `.slide` | Slide from leading edge | +| `.move(edge:)` | Move from specific edge | +| `.offset(x:y:)` | Move by offset amount | + +### Combining Transitions + +```swift +// Parallel - both simultaneously +.transition(.slide.combined(with: .opacity)) + +// Chained +.transition(.scale.combined(with: .opacity).combined(with: .offset(y: 20))) +``` + +--- + +## Asymmetric Transitions + +Different animations for insertion vs removal. + +```swift +// GOOD - different animations for insert/remove +if showCard { + CardView() + .transition( + .asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .move(edge: .bottom).combined(with: .opacity) + ) + ) +} + +// BAD - same transition when different behaviors needed +if showCard { + CardView() + .transition(.slide) // Same both ways - may feel awkward +} +``` + +--- + +## Custom Transitions + +### Pre-iOS 17 + +```swift +struct BlurModifier: ViewModifier { + var radius: CGFloat + func body(content: Content) -> some View { + content.blur(radius: radius) + } +} + +extension AnyTransition { + static func blur(radius: CGFloat) -> AnyTransition { + .modifier( + active: BlurModifier(radius: radius), + identity: BlurModifier(radius: 0) + ) + } +} + +// Usage +.transition(.blur(radius: 10)) +``` + +### iOS 17+ (Transition Protocol) + +```swift +struct BlurTransition: Transition { + var radius: CGFloat + + func body(content: Content, phase: TransitionPhase) -> some View { + content + .blur(radius: phase.isIdentity ? 0 : radius) + .opacity(phase.isIdentity ? 1 : 0) + } +} + +// Usage +.transition(BlurTransition(radius: 10)) +``` + +### Good vs Bad Custom Transitions + +```swift +// GOOD - reusable transition +if showContent { + ContentView() + .transition(BlurTransition(radius: 10)) +} + +// BAD - inline logic (won't animate on removal!) +if showContent { + ContentView() + .blur(radius: showContent ? 0 : 10) // Not a transition + .opacity(showContent ? 1 : 0) +} +``` + +--- + +## Identity and Transitions + +View identity changes trigger transitions, not property animations. + +```swift +// Triggers transition - different branches have different identities +if isExpanded { + Rectangle().frame(width: 200, height: 50) +} else { + Rectangle().frame(width: 100, height: 50) +} + +// Triggers transition - .id() changes identity +Rectangle() + .id(flag) // Different identity when flag changes + .transition(.scale) + +// Property animation - same view, same identity +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) +``` + +--- + +## The Animatable Protocol + +Enables custom property interpolation during animations. + +### Protocol Definition + +```swift +protocol Animatable { + associatedtype AnimatableData: VectorArithmetic + var animatableData: AnimatableData { get set } +} +``` + +### Basic Implementation + +```swift +// GOOD - explicit animatableData +struct ShakeModifier: ViewModifier, Animatable { + var shakeCount: Double + + var animatableData: Double { + get { shakeCount } + set { shakeCount = newValue } + } + + func body(content: Content) -> some View { + content.offset(x: sin(shakeCount * .pi * 2) * 10) + } +} + +extension View { + func shake(count: Int) -> some View { + modifier(ShakeModifier(shakeCount: Double(count))) + } +} + +// Usage +Button("Shake") { shakeCount += 3 } + .shake(count: shakeCount) + .animation(.default, value: shakeCount) + +// BAD - missing animatableData (silent failure!) +struct BadShakeModifier: ViewModifier { + var shakeCount: Double + // Missing animatableData! Uses EmptyAnimatableData + + func body(content: Content) -> some View { + content.offset(x: sin(shakeCount * .pi * 2) * 10) + } +} +// Animation jumps to final value instead of interpolating +``` + +### Multiple Properties with AnimatablePair + +```swift +// GOOD - AnimatablePair for two properties +struct ComplexModifier: ViewModifier, Animatable { + var scale: CGFloat + var rotation: Double + + var animatableData: AnimatablePair { + get { AnimatablePair(scale, rotation) } + set { + scale = newValue.first + rotation = newValue.second + } + } + + func body(content: Content) -> some View { + content + .scaleEffect(scale) + .rotationEffect(.degrees(rotation)) + } +} + +// GOOD - nested AnimatablePair for 3+ properties +struct ThreePropertyModifier: ViewModifier, Animatable { + var x: CGFloat + var y: CGFloat + var rotation: Double + + var animatableData: AnimatablePair, Double> { + get { AnimatablePair(AnimatablePair(x, y), rotation) } + set { + x = newValue.first.first + y = newValue.first.second + rotation = newValue.second + } + } + + func body(content: Content) -> some View { + content + .offset(x: x, y: y) + .rotationEffect(.degrees(rotation)) + } +} +``` + +--- + +## Quick Reference + +### Do +- Place transitions outside conditional structures +- Use `withAnimation` or `.animation` outside the `if` +- Implement `animatableData` explicitly for custom Animatable +- Use `AnimatablePair` for multiple animated properties +- Use asymmetric transitions when insert/remove need different effects + +### Don't +- Put animation modifiers inside conditionals for transitions +- Forget `animatableData` implementation (silent failure) +- Use inline blur/opacity instead of proper transitions +- Expect property animation when view identity changes From 8b50d9c88e9be11b79d8977084b4928112862bf6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:50:24 +0000 Subject: [PATCH 6/8] chore: sync README references [skip ci] --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 03adc55..bca1ecc 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,9 @@ This skill gives your AI coding tool practical SwiftUI guidance. It can: swiftui-expert-skill/ SKILL.md references/ - animation-patterns.md + animation-advanced.md + animation-basics.md + animation-transitions.md image-optimization.md - AsyncImage usage, downsampling, caching layout-best-practices.md - Layout patterns and GeometryReader alternatives liquid-glass.md - iOS 26+ glass effects and fallback patterns From 3f7b4f383c539647090661812e65b1bc9d43ff4d Mon Sep 17 00:00:00 2001 From: Antoine van der Lee <4329185+AvdLee@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:37:56 +0100 Subject: [PATCH 7/8] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bca1ecc..101b2ed 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ This skill gives your AI coding tool practical SwiftUI guidance. It can: swiftui-expert-skill/ SKILL.md references/ - animation-advanced.md - animation-basics.md - animation-transitions.md + animation-advanced.md - Performance, interpolation, and complex animation chains + animation-basics.md - Core animation concepts, implicit/explicit animations, timing + animation-transitions.md - View transitions, matchedGeometryEffect, and state changes image-optimization.md - AsyncImage usage, downsampling, caching layout-best-practices.md - Layout patterns and GeometryReader alternatives liquid-glass.md - iOS 26+ glass effects and fallback patterns From 6c32547911af70d9c054959442229026dd063c52 Mon Sep 17 00:00:00 2001 From: Omar Elsayed Date: Mon, 2 Feb 2026 12:57:11 +0200 Subject: [PATCH 8/8] Update animation-advanced.md --- swiftui-expert-skill/references/animation-advanced.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/swiftui-expert-skill/references/animation-advanced.md b/swiftui-expert-skill/references/animation-advanced.md index a57f88d..6df634d 100644 --- a/swiftui-expert-skill/references/animation-advanced.md +++ b/swiftui-expert-skill/references/animation-advanced.md @@ -346,5 +346,6 @@ Circle() - Prefer over manual DispatchQueue timing ### Completion Handlers (iOS 17+) -- Use `.transaction(value:)` for handlers that should refire +- Use `withAnimation(.animation) { } completion: { }` for one-shot completion handlers +- Use `.transaction(value:)` for handlers that should refire on every value change - Without `value:` parameter, completion only fires once