返回 Skill 列表
extension
分类: 开发与工程无需 API Key

"animations"

动画执行:包含、修饰符顺序、时机、性能和过渡安全。在实现与动画相关的UI模式时使用。

person作者: jakexiaohubgithub

Animations

Enforce safe, performant animations that never escape their parent bounds.

CONTAINMENT (CRITICAL): Animated content inside a container (card, row, sheet, etc.) MUST NOT overflow its parent. Apply containment modifiers on the PARENT that clips:

// CORRECT — compositingGroup flattens, clipped constrains
CardContainer {
    AnimatedContent()
        .transition(.scale.combined(with: .opacity))
}
.compositingGroup()
.clipped()

// WRONG — animated child overflows parent during transition
CardContainer {
    AnimatedContent()
        .transition(.move(edge: .bottom))
}
// No containment — content renders outside CardContainer

WHY .compositingGroup().clipped():

  • .compositingGroup() flattens child layers into one compositing pass (no Metal overhead like .drawingGroup())
  • .clipped() then clips that single composited layer to the parent frame
  • Together they guarantee zero visual overflow during any animation phase
  • Do NOT use .drawingGroup() for this — it rasterizes via Metal, wastes memory, and redraws the entire group on any change
  • Do NOT rely on .scaleEffect(1) hack — it is fragile and undocumented behavior

WHEN TO APPLY CONTAINMENT:

  • Any view with .transition() inside a sized container (cards, rows, sheets, popovers)
  • Spring/bouncy animations on child views that may overshoot parent bounds
  • Phase animations or keyframe animations that scale or offset children
  • ScrollView items with animated insertion/removal

WHEN CONTAINMENT IS NOT NEEDED:

  • Full-screen views with no parent clipping boundary
  • Opacity-only animations (no spatial overflow possible)
  • Navigation transitions handled by the system

MODIFIER ORDER:

// CORRECT — animation AFTER layout, containment on parent
VStack {
    content
        .offset(y: animating ? -20 : 0)
        .opacity(animating ? 0 : 1)
        .animation(.spring(duration: 0.4), value: animating)
}
.compositingGroup()
.clipped()

// WRONG — animation before layout modifiers
content
    .animation(.spring, value: state)
    .padding()
    .frame(maxWidth: .infinity)

PREFER GPU TRANSFORMS:

  • Use .scaleEffect, .offset, .rotationEffect, .opacity — GPU-accelerated, no layout pass
  • Avoid animating .frame, .padding, .font — triggers full layout recalculation
// GOOD — GPU transform, no layout hit
Text("Hello")
    .scaleEffect(isPressed ? 0.95 : 1.0)
    .animation(.spring(duration: 0.2), value: isPressed)

// BAD — layout-driven animation
Text("Hello")
    .padding(isPressed ? 10 : 16)
    .animation(.spring, value: isPressed)

TIMING CURVES:

  • .spring(duration: 0.3) — default for most UI (buttons, toggles, cards)
  • .spring(duration: 0.4, bounce: 0.3) — playful emphasis (success states, celebrations)
  • .easeInOut(duration: 0.25) — subtle transitions (opacity, color changes)
  • .bouncy — ONLY for intentional delight moments, never on frequent actions
  • Keep durations under 0.5s for responsive feel

TRANSITIONS:

  • Place withAnimation or .animation OUTSIDE the conditional — not inside the branch
// CORRECT
withAnimation(.spring(duration: 0.3)) {
    showDetail.toggle()
}
// In body:
if showDetail {
    DetailView()
        .transition(.opacity.combined(with: .move(edge: .bottom)))
}

// WRONG — animation inside the conditional
if showDetail {
    DetailView()
        .animation(.spring, value: showDetail) // too late
}

ANIMATION SCOPE:

  • Bind .animation to a specific value — NEVER use .animation(.spring) without value parameter
  • Use withAnimation for user-triggered state changes
  • Use .animation(_:value:) for derived/computed state changes
// CORRECT — scoped to specific value
.animation(.easeInOut(duration: 0.2), value: isSelected)

// WRONG — unscoped, animates everything
.animation(.easeInOut)

LIST AND SCROLL ANIMATIONS:

  • Use .animation on the List/ForEach container, not individual rows
  • Containment is especially important for row insertion/removal animations
List {
    ForEach(items) { item in
        ItemRow(item: item)
    }
}
.animation(.spring(duration: 0.3), value: items.count)

PHASE AND KEYFRAME (iOS 17+):

  • PhaseAnimator: use for looping multi-step sequences (loading indicators, attention pulses)
  • KeyframeAnimator: use for precise multi-property choreography
  • Both MUST have containment if inside a bounded parent
// Phase animation with containment
PhaseAnimator([false, true]) { phase in
    Icon()
        .scaleEffect(phase ? 1.1 : 1.0)
        .opacity(phase ? 1.0 : 0.7)
}
.compositingGroup()
.clipped()