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 diff --git a/README.md b/README.md index 57973f9..101b2ed 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ This skill gives your AI coding tool practical SwiftUI guidance. It can: swiftui-expert-skill/ SKILL.md references/ + 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 diff --git a/swiftui-expert-skill/SKILL.md b/swiftui-expert-skill/SKILL.md index 851f875..ce1a826 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-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 @@ -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-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 @@ -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-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 @@ -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-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 +- [ ] 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,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-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..6df634d --- /dev/null +++ b/swiftui-expert-skill/references/animation-advanced.md @@ -0,0 +1,351 @@ +# 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 `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 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-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