본문으로 건너뛰기
cccskills
무료GitHub에 공개

swiftui-motion

SwiftUI animation foundations - withAnimation, transitions, matchedGeometryEffect, PhaseAnimator, KeyframeAnimator, springs, gestures.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

설치 방법 보기

포함된 파일(4)

  • SKILL.md15.5 KB
  • references/gestures-swiftui.md9.5 KB
  • references/phase-keyframe-deep.md9.2 KB
  • references/springs-cheatsheet.md5.2 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

Version-sensitive. Every API name, SDK gate and browser-support claim below was verified on 2026-09-08 against primary sources. What against, and when, is in _jutsu/VERSIONS.md. If that date is old, re-verify before acting on a version number.

SwiftUI Motion

SwiftUI animation core. Loaded for any SwiftUI project (iOS, macOS, multi-target Apple). Concise rules here. Deep-dive in references/. Pair with ../motion-principles/SKILL.md (foundation) and ../mobile-principles/SKILL.md (touch UX).


Animation API decision tree

NeedAPI
Single value over timewithAnimation { } + @State or .animation(_, value:)
Multiple coordinated statesPhaseAnimator(phases) (iOS 17+)
Time-based keyframesKeyframeAnimator(initialValue:repeating:content:) (iOS 17+)
Custom property animations@Animatable macro (needs Xcode 26 / SwiftUI 2025 SDK to compile; runtime availability iOS 13.0+ / macOS 10.15+ / watchOS 6.0+) or the Animatable protocol by hand (iOS 13+)
Shared element transitionsmatchedGeometryEffect(id:in:)
Gesture-drivenDragGesture / MagnifyGesture + .offset / .scaleEffect
Loop forever.animation(.linear.repeatForever(autoreverses: true), value: ...) or .phaseAnimator

Rule: start with withAnimation. Reach for PhaseAnimator only when you have 3+ ordered states. Reach for KeyframeAnimator only when you need parallel time-based tracks.


Springs (the only easing you should care about)

SwiftUI ships named spring presets (iOS 17+): .snappy, .bouncy, .smooth, plus the parameterised .spring(duration:bounce:blendDuration:) and .interactiveSpring(...). Each of the three named ones also takes (duration:extraBounce:) overloads. Use them. Tune response / dampingFraction only when a preset is wrong.

Preset (iOS 17+)EquivalentMood
.snappy.spring(response: 0.5, dampingFraction: 0.85)UI snappy
.bouncy.spring(response: 0.5, dampingFraction: 0.7)playful
.smooth.spring(response: 0.5, dampingFraction: 1.0)calm, no bounce
.interactiveSpring().spring(response: 0.15, dampingFraction: 0.86)gesture follow

response is the spring's period - its stiffness expressed as a duration, i.e. how long one oscillation would take. It is not the settling time: a .spring(response: 0.5, dampingFraction: 1.0) keeps moving well past 0.5s. Lower = snappier, higher = softer. dampingFraction is the overshoot intensity in 0...1 (1 = no overshoot, 0 = perpetual oscillation - never use 0). For UI work, stay in response: 0.2...0.5 and dampingFraction: 0.7...1.0. Deep-dive: references/springs-cheatsheet.md.

iOS 17+ also exposes .spring(duration:bounce:) where bounce is 0...1 (0 = critically damped, 1 = full bounce). It's the same spring, exposed in a more designer-friendly way:

.animation(.spring(duration: 0.4, bounce: 0.3), value: state)

Implicit vs explicit animations

// Implicit - via .animation modifier (binds to a value)
Circle()
    .scaleEffect(scale)
    .animation(.spring(.snappy), value: scale)
// Explicit - via withAnimation block
Button("Grow") {
    withAnimation(.smooth) { scale = 1.5 }
}

Rule: prefer explicit (withAnimation) for state changes triggered by user actions; use implicit when any change to a value should always animate (e.g., a progress bar that updates from anywhere). Never both on the same property - the outer withAnimation wins, but the implicit .animation modifier still runs and stacks confusingly.


Transitions

Transitions drive insertion / removal of views inside an if, switch, or ForEach. They run when the parent's animation context fires (so wrap state mutations in withAnimation).

if visible {
    Card().transition(.asymmetric(
        insertion: .move(edge: .bottom).combined(with: .opacity),
        removal: .opacity.animation(.easeIn(duration: 0.15))
    ))
}

BAD - vanish into a black hole:

Card().transition(.scale)  // scales to 0, the universal "broken" feel

GOOD - never scale to 0:

Card().transition(
    .scale(scale: 0.95).combined(with: .opacity)
)

iOS 17+ also has the .transition(_:) modifier with custom transitions via the Transition protocol - useful for shared timing across many views. For 90% of work, the built-in combinators (.move, .opacity, .scale, .slide, .push, .asymmetric, .combined(with:)) are enough.


matchedGeometryEffect (hero animations)

Tag two views with the same id in the same Namespace. SwiftUI interpolates frame and position when the source view is replaced.

struct Gallery: View {
    @Namespace private var ns
    @State private var expanded = false

    var body: some View {
        ZStack {
            if expanded {
                LargeCard()
                    .matchedGeometryEffect(id: "card", in: ns)
                    .onTapGesture { withAnimation(.spring(.smooth)) { expanded = false } }
            } else {
                SmallCard()
                    .matchedGeometryEffect(id: "card", in: ns)
                    .onTapGesture { withAnimation(.spring(.smooth)) { expanded = true } }
            }
        }
    }
}

isSource: true (default on the source-of-truth view) tells SwiftUI which frame to interpolate from. Common gotchas: id collisions across unrelated namespaces, view identity instability (use stable ids, not array indices), and animating out of an if branch where the destination view doesn't exist yet (wrap both branches inside the same parent, use opacity to hide instead of removing).


PhaseAnimator (iOS 17+)

For ordered state choreography. The API takes some Sequence of a phase type constrained to Equatable (not Hashable); CaseIterable is only a convenience so you can pass Phase.allCases. SwiftUI walks the sequence in order and settles on the last element.

enum SuccessPhase: CaseIterable { case start, scaleUp, rotate, settle }

struct SuccessCheck: View {
    @State private var trigger = false

    var body: some View {
        Image(systemName: "checkmark.circle.fill")
            .font(.system(size: 64))
            .foregroundStyle(.green)
            .phaseAnimator(SuccessPhase.allCases, trigger: trigger) { view, phase in
                view
                    .scaleEffect(phase == .start ? 0 : phase == .settle ? 1 : 1.2)
                    .rotationEffect(.degrees(phase == .rotate ? 360 : 0))
                    .opacity(phase == .start ? 0 : 1)
            } animation: { phase in
                switch phase {
                case .start: .smooth(duration: 0.05)
                case .scaleUp: .spring(.bouncy, blendDuration: 0.25)
                case .rotate: .spring(response: 0.4, dampingFraction: 0.8)
                case .settle: .smooth(duration: 0.2)
                }
            }
            .onTapGesture { trigger.toggle() }
    }
}

trigger: is optional - omit it to advance through phases automatically once on appear. Use it when you need an external signal (button tap, model update). Phases run sequentially, never in parallel - if you need parallelism, use KeyframeAnimator.


KeyframeAnimator (iOS 17+)

For continuous, time-based animations with parallel tracks. Each KeyframeTrack animates one keypath independently; SwiftUI runs them all together.

struct AnimationValues {
    var scale: Double = 1
    var rotation: Angle = .zero
    var opacity: Double = 1
}

struct HeartTap: View {
    @State private var counter = 0

    var body: some View {
        Image(systemName: "heart.fill")
            .font(.system(size: 64))
            .foregroundStyle(.pink)
            .keyframeAnimator(initialValue: AnimationValues(), trigger: counter) { content, values in
                content
                    .scaleEffect(values.scale)
                    .rotationEffect(values.rotation)
                    .opacity(values.opacity)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.3, duration: 0.15)
                    SpringKeyframe(1.0, duration: 0.3, spring: .bouncy)
                }
                KeyframeTrack(\.rotation) {
                    CubicKeyframe(.degrees(15), duration: 0.1)
                    CubicKeyframe(.degrees(-15), duration: 0.2)
                    CubicKeyframe(.degrees(0), duration: 0.15)
                }
            }
            .onTapGesture { counter += 1 }
    }
}

Four keyframe types: LinearKeyframe (constant velocity between points), SpringKeyframe (settles with spring), CubicKeyframe (cubic bezier ease), MoveKeyframe (jump cut, no interpolation). Trigger on a value change to re-run the animation. Deep-dive: references/phase-keyframe-deep.md.


Animatable / @Animatable

For custom drawing that needs interpolation. The @Animatable macro (introduced with the iOS 26 SDK at WWDC25, so it needs Xcode 26+; its declared availability is iOS 13.0+ / macOS 10.15+ / watchOS 6.0+, so it back-deploys) attaches to a struct/class/enum and synthesizes both the Animatable conformance and animatableData from the stored properties that are themselves animatable (Double, CGFloat, Angle, CGSize, CGPoint, UnitPoint, any VectorArithmetic). Mark a stored property @AnimatableIgnored (same availability) to keep it out of the synthesized data. Writing animatableData by hand still works and is what you do on an older toolchain.

struct ProgressRing: Shape {
    var progress: Double  // 0...1

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func path(in rect: CGRect) -> Path {
        var p = Path()
        p.addArc(
            center: CGPoint(x: rect.midX, y: rect.midY),
            radius: rect.width / 2,
            startAngle: .degrees(-90),
            endAngle: .degrees(-90 + 360 * progress),
            clockwise: false
        )
        return p
    }
}

ProgressRing(progress: progress)
    .stroke(.tint, lineWidth: 6)
    .animation(.spring(.smooth), value: progress)

For multi-property shapes use AnimatablePair<A, B> (or nested pairs) as animatableData. The @Animatable macro removes that boilerplate: annotate the shape and every stored property conforming to VectorArithmetic (Double, CGFloat, Angle, CGSize, CGPoint, UnitPoint, ...) is folded into the synthesized animatableData automatically; opt a property out with @AnimatableIgnored.


Gestures

GestureTypeUse
TapGesturediscretetap, double-tap (count: 2)
LongPressGesturediscrete + continuous (onEnded/onChanged)context menus, hold-to-record
DragGesturecontinuousdrag, swipe-to-dismiss
MagnifyGesturecontinuouspinch-zoom (iOS 17+, replaces MagnificationGesture)
RotateGesturecontinuousrotate (iOS 17+, replaces RotationGesture)
SpatialTapGesture (iOS 16+, macOS 13+)discretetap with location info
let tap = TapGesture().onEnded { print("tap") }
let drag = DragGesture().onChanged { value in offset = value.translation }
ZStack { ... }
    .gesture(tap.simultaneously(with: drag))

Three composition operators: .simultaneously(with:) (parallel recognition), .sequenced(before:) (one must complete first), .exclusively(before:) (one or the other, not both). For fine-grained control over when child views can claim the gesture, use .simultaneousGesture(_, including: GestureMask) with .gesture, .subviews, .all, or .none. Deep-dive: references/gestures-swiftui.md.


Anti-Patterns (BAD / GOOD)

1. Deprecated .animation form (no value binding)

// BAD - implicit-anim-everywhere, deprecated in iOS 15+
Circle().scaleEffect(scale).animation(.easeInOut)
// GOOD - bind to a specific value
Circle().scaleEffect(scale).animation(.easeInOut, value: scale)
// OR
withAnimation(.easeInOut) { scale = 1.5 }

2. Animating frame size directly

// BAD - .frame() drives layout pass every frame, drops fps under load
Card().frame(height: expanded ? 400 : 100)
    .animation(.spring(), value: expanded)
// GOOD - animate transform-equivalents (scale, offset) that the compositor handles
Card()
    .frame(height: 400)
    .scaleEffect(expanded ? 1 : 0.4, anchor: .top)
    .animation(.spring(), value: expanded)
// OR for actual layout transitions, use matchedGeometryEffect

3. Scale to 0 (the vanish-into-nothing trap)

// BAD - element vanishes into a black hole, feels broken
Card().transition(.scale)
// GOOD - minimum scale 0.9-0.95 + opacity
Card().transition(.scale(scale: 0.95).combined(with: .opacity))

4. withAnimation inside body

// BAD - body runs on every render, animation re-fires arbitrarily
var body: some View {
    let _ = withAnimation(.spring()) { scale = 1.2 }  // never do this
    Circle().scaleEffect(scale)
}
// GOOD - trigger from user actions or .onChange
var body: some View {
    Circle()
        .scaleEffect(scale)
        .onTapGesture {
            withAnimation(.spring()) { scale = scale == 1 ? 1.2 : 1 }
        }
}

Reduced motion respect

Mandatory. SwiftUI exposes the iOS / macOS "Reduce Motion" accessibility setting via the environment. See ../motion-principles/SKILL.md for the cross-platform rationale.

struct Hero: View {
    @Environment(\.accessibilityReduceMotion) var reduceMotion
    @State private var shown = false

    var body: some View {
        Text("Welcome")
            .opacity(shown ? 1 : 0)
            .offset(y: shown ? 0 : (reduceMotion ? 0 : 20))
            .animation(reduceMotion ? .none : .spring(.smooth), value: shown)
            .onAppear { shown = true }
    }
}

Rule: cross-fades and opacity are still allowed under reduced motion; large translations, scale-from-zero, parallax, and looping motion must be neutralized.


Quick Reference: Loading sub-skills

NeedLoad
Springs deep-dive (response/dampingFraction tuning, mood)references/springs-cheatsheet.md
PhaseAnimator + KeyframeAnimator complex sequencesreferences/phase-keyframe-deep.md
Gesture composition + conflict resolutionreferences/gestures-swiftui.md
Advanced visuals (Metal, Liquid Glass)../swiftui-graphics/SKILL.md
Cross-platform UX (mobile, desktop)../mobile-principles/SKILL.md, ../desktop-principles/SKILL.md
Foundation (timing, easing, a11y)../motion-principles/SKILL.md

Sources

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

bunshin

무료

Summon shadow clones: genjutsu's full-scale pipeline for a whole website or web app, from scratch or as a full redesign. Harvests the client's real material, researches in parallel, lets Impeccable lead the direction and genjutsu the motion, builds the pages with parallel agents, then loops fixes, verdicts and a cold-eyes art director until only minor issues remain, the round cap of the chosen tier is reached, or a round fixes nothing. Announces its cost first and asks twice. Web. Needs a host that can spawn subagents.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AThevon/genjutsu5552026년 10월 6일 업데이트

Algorithmic and generative art with Canvas 2D - particles, flow fields, noise, fractals, L-systems.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AThevon/genjutsu5552026년 10월 6일 업데이트

cast

무료

Cast genjutsu on a UI - creative coding for motion, micro-interactions, and wow-factor. Scans the stack, proposes an interaction thesis, loads the right sub-skills, implements the illusion. Adapts to Web, Android (Compose), Apple (SwiftUI).

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AThevon/genjutsu5552026년 10월 6일 업데이트

Advanced Compose visuals - Material 3 Expressive motion physics, AGSL shaders (Android 13+), Canvas/DrawScope generative, graphicsLayer effects.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AThevon/genjutsu5552026년 10월 6일 업데이트

Jetpack Compose animation foundations - animate*AsState, AnimatedVisibility, Crossfade, updateTransition, SharedTransitionLayout, gestures.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AThevon/genjutsu5552026년 10월 6일 업데이트

Compose Multiplatform / KMP patterns - expect/actual composables, platform-specific code, density and font handling cross-target, iOS/Android/Desktop interop.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

AThevon/genjutsu5552026년 10월 6일 업데이트

AThevon의 스킬 모두 보기

이 스킬의 문제 신고