iOS/SwiftUI

[WWDC2023] Explore SwiftUI animation

Hamp 2025. 10. 29. 09:07
반응형

👋 들어가기 전

요즘 애니메이션과 트랜지션에 관심 많이 생겨.. 늦었지만 WWDC를 통해

기초를 차근차근 다져나갈려고한다.

 

오늘은 WWDC23의 영상으로 SwiftUI의 애니메이션 탐험 파트를 살펴보자.


🏁 학습할 내용

  • 뷰 업데이트 해부하기
  • Animatable
  • Animation
  • Transaction

🧑‍⚕️ 뷰 업데이트 해부하기

 

🤖 예시 코드 (시작, 애니메이션 X)

struct ContentView: View {
    @State private var selected: Bool = false
    
    var body: some View {
        Image(systemName: "pencil")
            .resizable()
            .frame(width: 100, height: 100)
            .scaleEffect(selected ? 1.5 : 1.0)
            .onTapGesture {
                selected.toggle()
            }
    }
}

 

위 코드를 그림으로 살펴보면 다음과 같다

코드 뒤에는 뷰와 뷰 데이터의 수명을 관리하는 Attributegraph가 있다.

 

여기서 Image의 Tap을 ㄹ통해 selected가 갱신되면, selected를 기준으로 다운스트림은 만료된다.

 

이벤트가 들어오면 다음 순서로 업데이트가 진행됨

 

  • 업데이트 트랜잭션이 열림
  • 업스트림 종속성, 여기서는 selected가 변경되고
  • view body 호출
  • 다운스트림 업데이트
  • 업데이트 트랜잭션 닫힘

 

이제 애니메이션이 걸리면 어떻게 달라질까?

 

🤖 예시 코드 (시작, 애니메이션 적용)

struct ContentView: View {
    @State private var toogle: Bool = false
    
    var body: some View {
        Image(systemName: "pencil")
            .resizable()
            .frame(width: 100, height: 100)
            .scaleEffect(toogle ? 1.5 : 1.0)
            .onTapGesture {
                withAnimation {
                    toogle.toggle()
                }
            }

 

애니메이션 설정 이후, 실행하면 아래 사진처럼 그래프가 동작한다.

  • 트랜잭션에 애니메이션이 설정됨
  • 그후 selected가 토글되며, 다운 스트림이 만료됨
  • 이 때, scaleEffect는 특수 속성으로 애니메이션이 가능한 속성이다.
  • 애니메이션이 가능한 속성의 값이 변경되면, 트랜잭션에 애니메이션이 설정되어 있는 지 확인
  • 애니메이션이 설정되어 있다면, 사본을 만들고 애니메이션을 사용해 이전 값에서 새 값으로 보간한다.

 

 

애니메이션을 시간에 따라 살펴보자.

  • 업데이트 트랜잭션이 열리는 것 까지는 위와 동일
  • 값이 변경되면, attribute은 애니메이션의 로컬 사본을 만들어 현재 프레젠테이션 값을 계싼
  • 이후, 적절한 애니메이션이 가능한 attribute 호출 뒤, 다음 프레임 생성


🍏 Animatable

protocol Animatable {
    associatedtype AnimatableData: VectorArithmetic
    var animatableData: AnimatableData { get set }
}

🧩 역할

  • VIew의 속성을 애니메이션으로 자연스럽게 변화시키기 위한 핵심 인터페이스
  • 애니메이션 대상을 결정, Animation을 통해 보간될 데이터를 정의하는 프로토콜

🔹 배우면 좋은 것들

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct EmptyAnimatableData : VectorArithmetic {
...
}

// animatableData 프로퍼티에 어떤 애니메이션도 가지고 있지 않는 타입
@frozen public struct AnimatablePair<First, Second> : VectorArithmetic where First : VectorArithmetic, Second : VectorArithmetic {

    /// The first value.
    public var first: First

    /// The second value.
    public var second: Second
    
	...    
}

// 그냥 2개의 벡터를 하나로 묶어주는 객체
// 이름에 Animatable이 앞에 붙어서 암묵적으로 Animatable값 2개를 1하나의 값으로 묶어주는 구조체

 

🔬 구현

  • 데이터는 VectorArithmetic을 준수해야함
  • VectorArithmetic는 수학의 Vector와 같음, 각종 벡터 연산과 스칼로 곱을 지원함
  • 벡터는 기본적으로 길이가 고정된 숫자 목록
    • CGFloat, Double: 1차원 벡터
    • CGPoint, CGSize: 2차원 벡터
    • CGRect: 4차원 벡터

 

크기에 대한 효과를 줄 때, 우리는 scaleEffect를 사용한다. 여기서 ,

내부는 아래와 같은 코드 형식으로 소개된다.

 

첫번 째로, View의 Extension 함수로 파라미터를 받아올 인터페이스를 제공한다.

View Modifier라고 생각하면 편할 듯 하다.

@inlinable nonisolated public func scaleEffect(
_ scale: CGSize, 
anchor: UnitPoint = .center
) -> some View

 

이후 내부적으로 사용될 Animatable 구현체와 VIew를 조합한다.

struct ScaleEffect: Animatable {
  private var scale: CGSize
  private var anchor: UnitPoint

  init(scale: CGSize, anchor: UnitPoint) {
    self.scale = scale
    self.anchor = anchor
  }

  var animatableData: AnimatablePair<CGSize.AnimatableData, UnitPoint.AnimatableData> {
    get { AnimatablePair(scale.animatableData, anchor.animatableData) }
    set {
      scale.animatableData = newValue.first
      anchor.animatableData = newValue.second
    }
  }
}

📺 Animation

 

🎁 내장되있는 강력한 애니메이션 종류

 

⏰ Timing curve

프리셋

  • linear
  • easeIn
  • easeOut
  • easeInout
public static func timingCurve(
_ curve: UnitCurve, 
duration: TimeInterval
) -> Animation

 

애니메이션의 속도를 정의하는 커브와 지속시간을 할당받는다.

// x = 시간 범위([0...1]) 
// y = value 
public struct UnitCurve {
	// 베지어 곡선
    public static func bezier(startControlPoint: UnitPoint, endControlPoint: UnitPoint) -> UnitCurve
    
    // x = progress에 해당하는 y 값
    public func value(at progress: Double) -> Double

    // x = progress에 해당하는 velocity (곡선의 기울기)
    public func velocity(at progress: Double) -> Double

    // 역전된 curve
    public var inverse: UnitCurve { get }
}

 

linear - easeIn - easeout - easeInOut

 

🕸️ Spring

프리셋

  • smooth: no bounce
  • snappy: small bounce
  • bouncy: medium bounce

 

과거, 스프링 프로퍼티들은, 실제 공학적인 네이밍이 되어있어, 다소 사용하기 힘들었다.

extension Spring {
    public init(mass: Double = 1.0, stiffness: Double, damping: Double, allowOverDamping: Bool = false)

    // 질량, 커질수록 무겁게 움직이는 느낌
    public var mass: Double { get }

	// 스프링 강성(탄성 계수), 목표 지점으로 얼마나 강하게 끌어당길지
    // 클수록 빠르게 진동하지만, 딱딱한 움직임
    public var stiffness: Double { get }

	// 감쇠계수, 스프링의 진동이 얼마나 빨리 줄지
    public var damping: Double { get }
}

 

현재는 애니메이션 지속 시간과, 스프링의 탄력정도만 명시하는 생성자를 제공한다.

extension Spring {
    public init(duration: TimeInterval = 0.5, bounce: Double = 0.0)

    /// The perceptual duration, which defines the pace of the spring.
    public var duration: TimeInterval { get }
    
    /// A value of 0 indicates no bounces (a critically damped spring), positive
    /// values indicate increasing amounts of bounciness up to a maximum of 1.0
    /// (corresponding to undamped oscillation), and negative values indicate
    /// overdamped springs with a minimum value of -1.0.
    public var bounce: Double { get }
}

 

 

🏬 Higher ordder

기본 애니메이션을 수정하는 애니메이션

 

프리셋

  • speed: 속도 조절
  • delay: 지연시간
  • repeatCount: 반복

speed - delay - repeatCount

🎉 Custom Animation

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
@preconcurrency public protocol CustomAnimation : Hashable, Sendable {
    nonisolated func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic
    nonisolated func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool where V : VectorArithmetic
    nonisolated func velocity<V>(value: V, time: TimeInterval, context: AnimationContext<V>) -> V? where V : VectorArithmetic
}
  • animate(필수 구현): 애니메이션 시간동안, 변환된 vector을 리턴, nil을 리턴 시 애니메이션 종료
    • value: 변화시킬 벡터 타입
    • time: 애니메이션 경과 시간(초 단위)
    • context: 추가 애니메이션 상태를 담고있는 객체
  • shouldMerge(선택적): 애니메이션이 진핸중일 때, 새로운 애니메이션 실행 시, 두 애니메이션을 병합할지?
    • previous: 이전에 실행 중이던 Animation 인스턴스
    • value: 현재 애니메이션 벡터값
    • time: 새 애니메이션이 시작된 시점의 시간값
    • contetxt: 애니메이션 컨텍스트
  • velocity(선택적): shouldMerge가 true일 떄 사용되며, 병합될 때 속도를 유지할 수 있음
    • value: 현재 애니메이션 벡터값
    • time: 새 애니메이션이 시작된 시점의 시간값
    • contetxt: 애니메이션 컨텍스

 

 

애니메이션읜 항상 delta(차이)에 주목함

 

1.0 -> 1.5 변화될 떄, 백터 덧셈과 스칼라 곱을 통해, 벡터의 차이를 알아냄

실제로 애니메이션은 1.0 -> 1.5에서 보간하는게 아닌, 0 -> 0.5로 보간함

 

 

이렇게 하면, 범용성 있는 구현이 가능하고, 여러 차원의 애니메이션 역시 같은 방법으로 통일됨

 

참고해서 만든 CustomLinaer 애니메이션을 마지막으로 Custom Animation은 마무리하자.

 

// === 1) 간단한 선형(CustomLinear) 애니메이션 ===
struct CustomLinear: CustomAnimation {
    // duration(초)
    let duration: TimeInterval
    
    func animate<V>(
        value: V,
        time: TimeInterval,
        context: inout AnimationContext<V>
    ) -> V? where V : VectorArithmetic {
        // time이 duration 이상이면 애니메이션 종료(nil 반환)
        if time >= duration {
            return nil
        }
        // 선형 보간: 비율 * 델타 벡터 반환
        let delta = time / duration
        return value.scaled(by: delta)
    }
    
    // shouldMerge와 velocity 미구현
}

struct ContentView: View {
    @State private var scale: CGFloat = 1.0

    var body: some View {
        VStack(spacing: 24) {
            Spacer()

            Circle()
                .fill(.blue)
                .frame(width: 120, height: 120)
                .scaleEffect(scale)
                // animatable 데이터는 scale에 의해 만들어지고, 시스템은
                // 해당 animatable delta 벡터를 CustomAnimation에 넘긴다.

            Spacer()

            HStack(spacing: 12) {
                Button("Linear 애니메이션") {
                    // 선형 애니메이션으로 1 -> 1.7 로 변경
                    withAnimation(Animation(CustomLinear(duration: 0.6))) {
                        scale = (scale == 1.0) ? 1.7 : 1.0
                    }
                }
            }
            .buttonStyle(.borderedProminent)
            .padding(.bottom, 40)
        }
        .padding()
    }
}


♻️ Transaction

이번 포스팅의 핵심이다.

 

솔직히 내용이 조금 충격적이다.

여기까지 컨트롤이 가능했다고 ???

 

🧩 역할

현재 업데이트에 대한 컨텍스트를 전파함

특히, 암묵적인 애니메이션을 통한 업데이트를 전파할 때, 사용되는 딕셔너리 역할을 함

 

왜 딕셔너리 같다고 했는지는, 밑에서 설명하겠다.

 

앞선 설명에서, Animatable 속성이 어떻게 애니메이션을 읽는 지, 다소 설명이 모호했다.

바로 Transaction 개념이 빠졌기 때문이다.

 

🏭 과정


1️⃣ withAnimation 사용

  • withAnimation 클로저가 실행되면, Root transaction에 애니메이션을 설정한다.
  • view body가 업데이트되면서, 트랜잭션 딕셔너리가 Attribute Graph를 따라 전판된다.
  • 이 때, Animatable 속성을 만나면 (여기서는 scaleEffect), 애니메이션 설정 여부를 확인 한다
  • 설정이 됐다면, 애니메이션을 동작하고, 없다면 그냥 넘어감
  • 이 때, 업데이트가 됐다면. 트랜잭션은 폐기된다.

 

2️⃣ .transaction 사용

  • withAnimationanimation를 설정했더라도, .transaction을 통해 애니메이션이 재정의 된다
  • 여기서 큰 문제가 있음, swiftUI 뷰를 새로 고칠 떄, 파생된 돌발 애니메이션이 발생할 수 있음
  • 이런 문제를 간단히 해결하는 법은, withAnimation같이 파생 애니메이션을 지양
  • .animation(animation:, value:) modifer를 이용하여 스코프를 제한한다.

 

3️⃣ .animation(animation:, value)는 만능이 아니다.

.animation(animation:, value:)만 쓰면 다 해결될까?? , 당연히 그렇지 않다.

  • 이상적으로는 scaleEffecot에는 .bouncy를, shadow는 .smooth를 적용하려는 의도
  • 만약, 어떤 이유로 같은 transaction에서 실행되면, 하나의 animation이 씹힐 수 있다.

 

4️⃣ .animation(animation:, body)

  • 트랜잭션에 애니메이션이 처음에 없다.
  • 애니메이션 modifer에 도착 시, 지정된 애니메이션으로 설정된 복사본이 , body 클로저에 전달
  • 복사본이 타겟된 범위에서 실행된 이후 삭제
  • 이후 원본 트랜잭션이, 다음 애니메이션 도착하여, 복사본을 body 클로저에 또 전달 후, 복사본 삭제
  • 원본 트랜잭션은 중간 애니메이션 modifer의 영향을 받지 않아, 돌발 애니메이션이 없음

 

5️⃣ TransactionKey

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol TransactionKey {
    associatedtype Value

    /// The default value for the transaction key.
    static var defaultValue: Self.Value { get }
}
  • 커스텀 트랜잭션키를 도입해, 업데이트 관련 데이터를 암시적으로 전파할 수 있음

 

  • 이게 바로 트랜잭션이 딕셔너리와 같다고하는 이유, 키를 통해 전파되므로
  • TrasactionKey프로토콜을 정의하여, defaultValue를 정의
  • Transaction의 Extension에서 computedProperty 형태로 정의

 

  • 이후 withTransaction을 통해, 어떤 키를 어떤 값으로 바꿀 것인지 명시
  • withAnimation withTransaction의 얇은 래퍼임
  • .transaction modifer에서 custom TransactionKey에 접근하여 값을 읽어, 분기할 수 있음
  • .transaction modifer 역시 돌발 애니메이션 변수가 있어, 더 세밀한 modifier제공

 

 

    • transaction(value: _ transform)는 값을 이용해 범위를 제한하고
    • transaction<V>(_ transform:, @ViewBuilder body): body 클로저를 이용해 범위 제한
    • animation modifer에 대해 개선된 형태와 동일, 모두 돌발 애니메이션 방지

출처

https://developer.apple.com/videos/play/wwdc2023/10156/

 

Explore SwiftUI animation - WWDC23 - Videos - Apple Developer

Explore SwiftUI's powerful animation capabilities and find out how these features work together to produce impressive visual effects...

developer.apple.com

https://developer.apple.com/documentation/SwiftUI/Animatable

 

Animatable | Apple Developer Documentation

A type that describes how to animate a property of a view.

developer.apple.com

https://developer.apple.com/documentation/swiftui/customanimation

 

CustomAnimation | Apple Developer Documentation

A type that defines how an animatable value changes over time.

developer.apple.com

https://developer.apple.com/documentation/SwiftUI/Transaction

 

Transaction | Apple Developer Documentation

The context of the current state-processing update.

developer.apple.com

https://developer.apple.com/documentation/SwiftUI/TransactionKey

 

반응형