๊ฐœ์š”

SwiftUI๋Š” ์„ ์–ธํ˜•(Declarative) ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹œ์Šคํ…œ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. UIKit์ด โ€œ์–ด๋–ป๊ฒŒ ์›€์ง์ผ์ง€โ€(begin/commit, durations, curves)๋ฅผ ๋ช…๋ นํ˜•์œผ๋กœ ๊ธฐ์ˆ ํ•ด์•ผ ํ–ˆ๋‹ค๋ฉด, SwiftUI๋Š” โ€œ์ตœ์ข… ์ƒํƒœ๊ฐ€ ๋ฌด์—‡์ธ์ง€โ€๋งŒ ์„ ์–ธํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ƒํƒœ(State) ๋ณ€๊ฒฝ์„ ๊ฐ์ง€ํ•œ ํ”„๋ ˆ์ž„์›Œํฌ๊ฐ€ ์ž๋™์œผ๋กœ ์ค‘๊ฐ„ ํ”„๋ ˆ์ž„์„ ๋ณด๊ฐ„(interpolate)ํ•ด ๋ถ€๋“œ๋Ÿฌ์šด ์ „ํ™˜์„ ๋งŒ๋“ค์–ด ์ค๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ๋‘ ํŒจํ„ด:

// Implicit
Circle()
    .scaleEffect(isExpanded ? 1.5 : 1.0)
    .animation(.spring, value: isExpanded)

// Explicit
withAnimation(.spring(response: 0.5, dampingFraction: 0.3)) {
    isExpanded.toggle()
}

์ด ๋ชจ๋“ˆ์˜ 4๊ฐœ ๋ฐ๋ชจ๋Š” ๊ฐ๊ฐ SwiftUI ์• ๋‹ˆ๋ฉ”์ด์…˜์˜ ๋‹ค๋ฅธ ์ธก๋ฉด์„ ๋‹ค๋ฃน๋‹ˆ๋‹ค.


1. Spring Playground

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” ์• ๋‹ˆ๋ฉ”์ด์…˜์˜ ํƒ€์ด๋ฐ ๊ณก์„ (curve): ๊ฐ™์€ ๋‘ ์ƒํƒœ ์‚ฌ์ด๋ผ๋„ ์–ด๋–ป๊ฒŒ ์›€์ง์ด๋Š”๊ฐ€์— ๋”ฐ๋ผ ๋А๋‚Œ์ด ์™„์ „ํžˆ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค.

.spring์€ SwiftUI์˜ ๊ฐ€์žฅ ์ž์—ฐ์Šค๋Ÿฌ์šด ๊ธฐ๋ณธ ๊ณก์„ ์ž…๋‹ˆ๋‹ค. ๋ฌผ๋ฆฌ์˜ ์Šคํ”„๋ง-๋Œํผ ์‹œ์Šคํ…œ์—์„œ ์˜๊ฐ์„ ๋ฐ›์•„ ์„ธ ๊ฐ€์ง€ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋™์ž‘์„ ์ œ์–ดํ•ฉ๋‹ˆ๋‹ค.

ํŒŒ๋ผ๋ฏธํ„ฐ ์˜๋ฏธ ์ง๊ด€
response ๋ชฉํ‘œ์— ๋„๋‹ฌํ•˜๋Š” ๋ฐ ๊ฑธ๋ฆฌ๋Š” ๋Œ€๋žต์˜ ์‹œ๊ฐ„(์ดˆ) ์ž‘์„์ˆ˜๋ก ๋น ๋ฅด๊ณ , ํด์ˆ˜๋ก ๋А๊ธ‹
dampingFraction ์ง„๋™(ํŠ•๊น€)์˜ ๊ฐ์‡  ๋น„์œจ (0~1) 0์— ๊ฐ€๊นŒ์šธ์ˆ˜๋ก ๋งŽ์ด ํŠ•๊น€, 1์ด๋ฉด ์•ˆ ํŠ•๊น€
blendDuration ์ง„ํ–‰ ์ค‘์ด๋˜ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ƒˆ ์• ๋‹ˆ๋ฉ”์ด์…˜์œผ๋กœ ์„ž์ด๋Š” ์‹œ๊ฐ„ ๋ณดํ†ต 0์ด๋ฉด ์ถฉ๋ถ„
withAnimation(.spring(response: 0.5, dampingFraction: 0.3)) {
    animated.toggle()
}

๋ฐ๋ชจ์—์„œ๋Š” Bouncy / Smooth / Snappy ํ”„๋ฆฌ์…‹์„ ๋น„๊ตํ•˜๊ณ , Custom ์Šฌ๋ผ์ด๋”๋กœ ์ง์ ‘ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์กฐ์ •ํ•ด ๊ณก์„ ์˜ ์ฐจ์ด๋ฅผ ์ง๊ด€์ ์œผ๋กœ ๋А๋‚„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


2. Morphing Shapes

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” ์ปค์Šคํ…€ Shape์— animatableData๋ฅผ ์ •์˜ํ•˜์—ฌ ๋„ํ˜• ์ž์ฒด๋ฅผ ๋ณ€ํ˜•์‹œํ‚ค๋Š” ๋ฐฉ๋ฒ•.

SwiftUI์˜ Shape ํ”„๋กœํ† ์ฝœ์€ path(in:) ๋ฉ”์„œ๋“œ๋งŒ ๊ตฌํ˜„ํ•˜๋ฉด ์–ด๋–ค ๋„ํ˜•์ด๋“  ๊ทธ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์— animatableData: Double ํ•œ ์ค„์„ ์ถ”๊ฐ€ํ•˜๋ฉด, SwiftUI๊ฐ€ 0โ†’1 ์‚ฌ์ด๋ฅผ ๋ณด๊ฐ„ํ•˜๋ฉด์„œ ๋งค ํ”„๋ ˆ์ž„๋งˆ๋‹ค path(in:)์„ ๋‹ค์‹œ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.

struct MorphShape: Shape {
    let shapeA: [CGPoint]
    let shapeB: [CGPoint]
    var progress: Double

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

    func path(in rect: CGRect) -> Path {
        // shapeA[i]์™€ shapeB[i]๋ฅผ progress(0~1)๋กœ lerpํ•ด์„œ ๊ทธ๋ฆผ
    }
}

๋ฐ๋ชจ์—์„œ๋Š” circle โ†” star, square โ†” triangle, heart โ†” diamond๋ฅผ ๋ชจํ•‘ํ•˜๋ฉฐ, ์Šฌ๋ผ์ด๋”๋กœ progress๋ฅผ ์ง์ ‘ ์กฐ์ž‘ํ•ด ๋ณด๊ฐ„์ด ์–ด๋–ป๊ฒŒ ์ผ์–ด๋‚˜๋Š”์ง€ ๋‹จ๊ณ„๋ณ„๋กœ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ’ก ํฌ์ธํŠธ: animatableData๋Š” ๋‹จ์ผ Double ๋˜๋Š” AnimatablePair ๊ฐ™์€ ์Šค์นผ๋ผ ํƒ€์ž…์ด์–ด์•ผ ์•ˆ์ •์ ์œผ๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. ์ž„์˜์˜ Array<CGPoint>๋ฅผ ์ง์ ‘ ๋ณด๊ฐ„ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ๋ฏธ๋ฌ˜ํ•œ ๋ฒ„๊ทธ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.


3. Keyframe Animations

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” ์—ฌ๋Ÿฌ ํ”„๋กœํผํ‹ฐ๊ฐ€ ๋™์‹œ์—, ๊ฐ๊ฐ ๋…๋ฆฝ๋œ ์‹œ๊ฐ„ ๊ณก์„ ์œผ๋กœ ์›€์ง์ด๋Š” ๋ณตํ•ฉ ์‹œํ€€์Šค.

iOS 17์˜ KeyframeAnimator๋Š” โ€œํ‚คํ”„๋ ˆ์ž„ + ํŠธ๋ž™โ€ ๊ตฌ์กฐ๋ฅผ ๋„์ž…ํ–ˆ์Šต๋‹ˆ๋‹ค. ํŠธ๋ž™๋งˆ๋‹ค ์ž์‹ ๋งŒ์˜ ์‹œ๊ฐ„/๊ณก์„ ์„ ๊ฐ€์ง€๊ณ , ๋ชจ๋‘ ๋™์‹œ์— ์ง„ํ–‰๋˜์–ด ๊ณต์ด ํŠ•๊ธฐ๋ฉด์„œ ํšŒ์ „ํ•˜๋Š” ๊ฐ™์€ ํ•ฉ์„ฑ ๋ชจ์…˜์„ ๊น”๋”ํ•˜๊ฒŒ ํ‘œํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

struct AnimValues {
    var offset: CGFloat = 0
    var scale: CGFloat = 1
    var rotation: Angle = .zero
}

KeyframeAnimator(initialValue: AnimValues(), trigger: trigger) { values in
    Image(systemName: "soccerball")
        .offset(y: values.offset)
        .scaleEffect(values.scale)
        .rotationEffect(values.rotation)
} keyframes: { _ in
    KeyframeTrack(\.offset) {
        SpringKeyframe(-200, duration: 0.4)
        SpringKeyframe(0, duration: 0.6, spring: .bouncy)
    }
    KeyframeTrack(\.rotation) {
        LinearKeyframe(.degrees(720), duration: 1.0)
    }
}

๋ฐ๋ชจ๋Š” bounce / orbit / shake / wave 4๊ฐ€์ง€ ํ”„๋ฆฌ์…‹์„ ์ œ๊ณตํ•ด ํŠธ๋ž™์„ ์–ด๋–ป๊ฒŒ ์กฐํ•ฉํ•˜๋ฉด ์–ด๋–ค ๋ชจ์…˜์ด ๋‚˜์˜ค๋Š”์ง€ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.


4. Phase Animations

๋ฌด์—‡์„ ๋ฐฐ์šฐ๋‚˜ โ€” ์—ฌ๋Ÿฌ ๋‹จ๊ณ„๋ฅผ ์ž๋™์œผ๋กœ ์ˆœํ™˜ํ•˜๋Š” ์ƒํƒœ ๋จธ์‹  ํŒจํ„ด. ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์—†์ด ๊ณ„์† ํ๋ฅด๋Š” ์• ๋‹ˆ๋ฉ”์ด์…˜์— ์ ํ•ฉ.

PhaseAnimator๋Š” phase ๋ฐฐ์—ด์„ ์ •์˜ํ•˜๊ณ , SwiftUI๊ฐ€ ์ž๋™์œผ๋กœ ๋‹ค์Œ phase๋กœ ๋„˜์–ด๊ฐ€๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค. ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ๋‚˜ ํŽ„์‹ฑ ๋ฑƒ์ง€์ฒ˜๋Ÿผ โ€œ๊ฐ€๋งŒํžˆ ์žˆ์–ด๋„ ๊ณ„์† ์›€์ง์ด๋Š”โ€ ํ‘œํ˜„์— ์•ˆ์„ฑ๋งž์ถค.

enum LoadPhase: CaseIterable { case dot1, dot2, dot3 }

HStack {
    ForEach(0..<3) { i in
        Circle()
            .phaseAnimator(LoadPhase.allCases) { view, phase in
                let scale = (phase.rawValue == i) ? 1.3 : 0.7
                view.scaleEffect(scale)
            } animation: { _ in
                .easeInOut(duration: 0.4)
            }
    }
}

๋ฐ๋ชจ์—๋Š” ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ, ํŽ„์‹ฑ ์•Œ๋ฆผ ๋ฑƒ์ง€, ์ƒํƒœ ์ „ํ™˜(connecting โ†’ connected โ†’ synced) ์„ธ ๊ฐ€์ง€ ์‚ฌ์šฉ ์˜ˆ์‹œ๊ฐ€ ์žˆ์–ด, ๊ฐ™์€ API๋กœ๋„ ์–ผ๋งˆ๋‚˜ ๋‹ค์–‘ํ•œ UX๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋Š”์ง€ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.

๐Ÿ’ก Spring๊ณผ์˜ ์ฐจ์ด: Spring์€ โ€œ๊ณก์„ โ€์„, Phase๋Š” โ€œ์ƒํƒœ ์‹œํ€€์Šคโ€๋ฅผ ๋‹ค๋ฃน๋‹ˆ๋‹ค. ์ฆ‰, Phase Animator์˜ ๊ฐ phase ์ „ํ™˜์˜ ๊ณก์„ ์œผ๋กœ Spring์„ ์‚ฌ์šฉํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค โ€” ๋‘ ๊ฐœ๋…์€ ์ง๊ตํ•ฉ๋‹ˆ๋‹ค.


์‹ค์ „ ํŒ

Best Practices

์ฃผ์˜ ์‚ฌํ•ญ