[WWDC2023] Explore SwiftUI animation
👋 들어가기 전
요즘 애니메이션과 트랜지션에 관심 많이 생겨.. 늦었지만 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()
}
}
}
위 코드를 그림으로 살펴보면 다음과 같다
코드 뒤에는 뷰와 뷰 데이터의 수명을 관리하는 Attribute의 graph가 있다.

여기서 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 }
}




🕸️ 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: 반복



🎉 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 사용

- withAnimation에 animation를 설정했더라도, .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