Animation
Chris Eidhof와 Florian Kugler의 『Thinking in SwiftUI』 iOS 17 개정판, 원문 105~130쪽의 한국어 번역입니다.
6장 Animation의 장 표지입니다.
원문 105쪽과 도표

SwiftUI에서 View update를 일으키는 유일한 방법은 state를 변경하는 것입니다. 기본적으로 이전 view tree와 새로운 view tree 사이의 변화에는 animation이 적용되지 않습니다. 하지만 state 변경으로 발생한 변화의 일부 또는 전부에 animation을 적용하도록 SwiftUI에 알리는 방법은 여러 가지가 있습니다. 다음 예제를 보겠습니다.
struct ContentView: View {
@State private var flag = false
var body: some View {
Rectangle()
.frame(width: flag ? 100 : 50, height: 50)
.onTapGesture {
withAnimation(.linear) { flag.toggle() }
}
}
}사용자가 사각형을 처음 탭하면 flag state가 false에서 true로 바뀌고 body가 다시 실행됩니다. 새로운 view tree의 구조는 여전히 같습니다. frame 안에 사각형이 들어 있습니다. 여기서는 설명을 간단히 하기 위해 view tree에 포함되는 tap gesture를 생략합니다.
하지만 frame의 너비는 50포인트에서 100포인트로 바뀌었습니다. state 변경을 withAnimation { ... } 호출로 감쌌으므로 SwiftUI는 새로운 view tree를 현재 render tree와 비교해 animation을 적용할 수 있는 변화를 찾습니다. frame의 너비는 대부분의 View modifier parameter처럼 animatable property이므로, withAnimation 호출에 암시적으로 지정된 .default timing curve로 animation의 진행 값을 0에서 1까지 생성합니다. 그런 다음 이 진행 값으로 사각형의 너비를 이전 너비인 50포인트에서 새로운 너비인 100포인트까지 보간합니다.
역주: 원문 예제 코드는
.linear를 지정하지만, 이어지는 설명은 인수를 생략한withAnimation의.default를 설명합니다. 원문의 코드와 설명을 그대로 보존했습니다.
진행 값이 0에서 시작해 1에서 끝난다고 생각할 수 있지만, animation이 진행되는 동안 값이 0보다 작거나 1보다 클 수도 있습니다. 예를 들어 iOS 17의
.bouncytiming curve를 사용하면 animation이 목표 값을 넘어서는 overshoot가 발생합니다.

원문 106쪽과 도표

Animation은 항상 render tree의 현재 state에서 시작해 새로운 view tree가 기술하는 새로운 state를 향해 진행됩니다. 이 새로운 view tree는 state 변경에 응답해 생성됩니다. render tree의 현재 state는 실행 중인 animation의 일시적인 state일 수도 있습니다. 따라서 SwiftUI의 animation은 기본적으로 누적할 수 있고 취소할 수 있습니다.
이 장의 내용 대부분은 최근의 모든 iOS 버전에 적용되지만, iOS 17에서는 SwiftUI의 animation 시스템에 크고 작은 변화가 많이 도입되었습니다. 주요 추가 사항은 다음과 같습니다.
- Animation이 끝나면 호출되는 completion handler가 추가되었습니다.
animationmodifier에 animation 범위를 특정 modifier로 한정할 수 있는 새로운 overload가 추가되었습니다.- 새로운
CustomAnimationprotocol을 사용해 완전히 사용자 정의한 animation curve를 만들 수 있게 되었습니다. - Phase-based animation으로 차례로 자동 실행되며 항상 시작 값으로 돌아오는 일련의 animation을 지정할 수 있습니다.
- Keyframe-based animation은 임의의 값을 보간해 완전히 사용자 정의한 animation을 만드는 새로운 추상화를 제공합니다.
Transitionprotocol을 중심으로 하는 새로운 transition API가 도입되었습니다.
Property animation과 Transition의 차이
Property animation은 state 변경 전후의 view tree에 모두 존재하는 View에서 변경된 property를 보간합니다. 다시 말해 View의 property는 바뀌지만, 화면에 어떤 View가 있는지는 바뀌지 않습니다. 앞의 예제로 돌아가 보면, 별개의 View 두 개를 표시하는 대신 frame의 width parameter 값만 바꾼다는 점이 매우 중요합니다. frame 자체는 state 변경 전후에 안정적으로 유지됩니다.
다음과 같은 다른 구현을 살펴보겠습니다. 도표에서는 간단히 표현하기 위해 tap gesture를 생략했습니다.
원문 107쪽과 도표

var body: some View {
let rect = Rectangle().onTapGesture {
withAnimation { flag.toggle() }
}
if flag {
rect
.frame(width: 200, height: 100)
} else {
rect
.frame(width: 100, height: 100)
}
}flag가 false이면 파란색 subtree만 render tree에 존재합니다. flag가 true로 바뀌면 파란색 subtree는 render tree에서 제거되고 주황색 subtree가 삽입됩니다. 이는 state 변경 전에 있던 frame과 state 변경 후에 있는 frame이 같지 않다는 뜻입니다. 이 두 frame은 view tree의 서로 다른 위치에 있으므로 서로 다른 identity를 가집니다. 따라서 SwiftUI는 frame의 width property에 animation을 적용하지 않습니다. state 변경 전후에 모두 render tree에 속하는 frame이 없기 때문입니다.
이 예제를 실행하면 좁은 사각형은 서서히 사라지고 넓은 사각형은 서서히 나타납니다. 여기서 보이는 것이 transition입니다. Property animation과 달리 transition은 render tree에서 제거되거나 render tree에 삽입되는 View에 적용되는 animation입니다. Transition은 이 장의 뒷부분에서 더 자세히 살펴보겠습니다.
역주: 원문 코드의 너비는 100과 200이지만 도표는 50과 100으로 표시합니다. 또한 코드에서
true인 분기는 파란색으로 그려져 있으나 본문과 아래 도표의 조건 표시는 반대로 되어 있습니다. 원문의 코드, 설명, 도표를 보존했습니다.


원문 108쪽과 도표

Animation 제어
SwiftUI에서 animation이 발생하는 시점을 지정하는 방법은 여러 가지입니다.
- Implicit animation은 특정 값이 바뀔 때 발생합니다.
- Explicit animation은 특정 event가 발생할 때 일어납니다.
Implicit animation을 지정하는 .animation(_:value:) View modifier는 view tree 어디에나 적용할 수 있으며 두 개의 parameter를 받습니다. 첫 번째는 animation에 사용할 timing curve를 지정하고, 두 번째는 animation이 적용되려면 어떤 값이 바뀌어야 하는지 지정합니다. value parameter는 animation의 범위를 특정 변경으로 제한하는 데 사용합니다. 이는 animation이 실제로 언제 적용될지 제어하는 데 중요합니다.
SwiftUI의 첫 버전에는
valueparameter가 없는.animation(_:)View modifier가 있었으며, 현재는 deprecated 상태입니다. 이 modifier는 animation을 지나치게 넓은 범위에 적용했으며, 기기 방향이 바뀔 때 의도하지 않은 animation이 적용되는 등 예상하지 못한 동작을 자주 일으켰습니다.
첫 번째 예제의 explicit animation을 implicit animation으로 바꾸면 다음과 같습니다.
struct ContentView: View {
@State private var flag = false
var body: some View {
Rectangle()
.frame(width: flag ? 100 : 50, height: 50)
.animation(.default, value: flag)
.onTapGesture { flag.toggle() }
}
}.animation View modifier를 사용하는 implicit animation은 자신의 view subtree 안에 있는 모든 것에 animation을 적용하므로, modifier를 올바른 위치에 두는 것이 중요합니다. 특히 나중에 코드를 변경할 때 의도하지 않은 부작용이 생기지 않도록 animation을 가능한 한 좁은 범위에 적용하는 것이 좋습니다. 위 animation의 view tree를 보면 frame과 사각형이 animation modifier의 subtree를 이룹니다.

원문 109쪽과 도표

아쉽게도 원서 집필 시점에는
.animation(_:value:)가 자신의 subtree에 있는 변경에만 animation을 적용한다는 규칙에 몇 가지 예외가 있습니다. 예를 들어 앞의 예제에서.frame과.animation의 줄 순서를 바꾸더라도 frame에는 여전히 animation이 적용됩니다. 저희가 이해하기로는 의도된 동작입니다. Ole Begemann이 블로그 글에서 이러한 예외를 잘 설명합니다.
iOS 17부터 새로운 .animation(_:body:) modifier를 사용하면 다음 코드처럼 implicit animation의 범위를 특정 modifier로 한정할 수 있습니다.
Text("Hello World")
.opacity(flag ? 1 : 0)
.animation(.default) {
$0.rotationEffect(flag ? .zero : .degrees(90))
}flag 값이 바뀌면 rotation effect에만 animation이 적용되고 opacity에는 적용되지 않습니다. 저희의 테스트에서는 다음 코드와 동일하게 동작했습니다.
Text("Hello World")
.opacity(flag ? 1 : 0)
.animation(nil)
.rotationEffect(flag ? .zero : .degrees(90))
.animation(.default)새로운 .animation 형태에는 주의할 점이 두 가지 있습니다.
.animation(_:value:)modifier와 달리,bodyclosure를 받는 새로운animationmodifier에는 animation 적용 시점을 제어하는valueparameter가 없습니다. 따라서bodyclosure 안의 animatable modifier는 변경이 어디에서 비롯되었는지와 관계없이 parameter가 바뀔 때마다 항상 animation을 적용합니다.- 앞서 설명했듯
.frame,.offset,.foregroundColor같은 일부 modifier는 animation과 함께 사용할 때 예상하지 못한 동작을 보일 수 있습니다. 이 modifier는 view tree에서 삽입한 위치가 아니라 leaf View에서 효과를 적용합니다. 따라서 modifier를 사용한 view tree 위치에 animation이 없었더라도 이 modifier에 animation이 적용될 수 있습니다. 이처럼 다른 위치에서 효과를 적용하는 modifier를.animation(_:body:)의bodyclosure 안에 지정하면 leaf View에는 animation이 없으므로 animation이 일어나지 않습니다.
특정 view subtree와 특정 값의 변경에 animation을 정의하는 대신, 특정 state 변경으로 animation 범위를 한정할 수도 있습니다. 이를 explicit animation이라고 합니다. 실제로 이 장의 첫 번째 예제에서도 withAnimation modifier를 사용해 이렇게 시작했습니다.
원문 110쪽과 도표

struct ContentView: View {
@State private var flag = false
var body: some View {
Rectangle()
.frame(width: flag ? 200 : 100, height: 100)
.onTapGesture {
withAnimation { flag.toggle() }
}
}
}onTapGesture의 perform closure와 같은 callback이 있으면, animation을 적용할 state 변경을 withAnimation으로 감쌀 수 있습니다.
Binding animation은 explicit animation의 또 다른 형태입니다. Binding에 .animation을 호출하면 Binding의 setter가 explicit animation으로 감싸집니다. 예를 들어 위 코드를 다음처럼 다시 작성할 수 있습니다.
struct ToggleRectangle: View {
@Binding var flag: Bool
var body: some View {
Rectangle()
.frame(width: flag ? 100 : 50, height: 50)
.onTapGesture { flag.toggle() }
}
}
struct ContentView: View {
@State private var flag = false
var body: some View {
ToggleRectangle(flag: $flag.animation(.default))
}
}이 코드는 앞의 형태와 정확히 같은 일을 합니다. 물론 원래 버전이 훨씬 더 직관적입니다. 하지만 event closure의 코드를 수정하지 않고 explicit animation을 적용하거나, event closure 자체가 없을 때는 Binding에 animation을 사용하는 방법이 좋은 선택일 수 있습니다.
역주: 원문 두 예제는 animation을 적용하는 방식은 같지만 frame 크기는 서로 다릅니다. 첫 예제는 너비 100과 200, 높이 100을 사용하며 두 번째 예제는 너비 50과 100, 높이 50을 사용합니다.
얼핏 보면 implicit animation과 explicit animation은 같은 일을 하는 것처럼 보입니다. 하지만 implicit animation과 explicit animation의 효과가 달라질 수 있다는 점을 알아야 합니다. 예를 들어 서버가 새 데이터를 보내는 등의 model layer 변경으로 특정 값이 바뀌는 경우를 생각해 보겠습니다. 그 값으로 범위를 한정한 implicit animation을 사용하면 변경이 어디에서 비롯되었는지와 관계없이 render tree의 변경에 animation이 적용되며, View 안에서의 범위는 명확하게 정해집니다.
원문 111쪽과 도표

Explicit animation을 사용하면 model layer의 update와 사용자 상호작용을 쉽게 구별할 수 있습니다. 하지만 animation을 view tree의 특정 부분으로 직접 제한할 수는 없습니다.
여기에 옳거나 그른 해법이 있는 것은 아닙니다. Explicit animation과 implicit animation 중 무엇을 사용할지는 구현하려는 동작에 달려 있습니다. 바로 테스트했을 때 차이가 드러나지 않더라도, animation을 정의하는 방법에 따라 동작이 달라질 수 있다는 점을 알아야 합니다.
Timing curve
SwiftUI는 linear, ease-in, ease-out, spring 등 일반적으로 사용하는 animation timing curve를 모두 내장하고 있습니다. 모든 animation API에는 timing curve를 지정하는 parameter가 있으며, 생략하면 기본 timing curve를 사용합니다.
.speed(_:)는 배율을 사용해 animation의 속도를 늦추거나 높입니다..delay(_:)는 animation의 시작을 지정한 시간만큼 지연합니다..repeatCount(_:autoreverses:)는 animation을 지정한 횟수만큼 반복합니다.
내장 timing curve가 원하는 동작을 제공하지 않는다면 iOS 17에서 도입된 CustomAnimation protocol을 사용해 timing curve를 완전히 직접 구현할 수 있습니다. iOS 17 이전에는 사용자 정의 timing curve를 사용하는 animation을 만드는 유일한 방법이 linear timing curve 위에 사용자 정의 animation을 구현하는 것이었습니다. 이때는 아래에서 살펴볼 Animatable protocol을 사용합니다.
Transaction
내부적으로 implicit animation과 explicit animation은 transaction이라는 동일한 구성을 사용합니다. state 변경으로 발생하는 각각의 View update는 transaction으로 감싸지며, 이 transaction에는 적용할 animation에 관한 정보가 들어 있습니다. 기본적으로 transaction의 animation은 nil입니다.
withAnimation API는 암시적으로 transaction을 만들고 그 transaction의 animation property를 설정합니다. 이 암시적 transaction의 범위는 withAnimation에 전달한 closure의 본문입니다. 실제로 withTransaction API를 사용해도 같은 효과를 얻을 수 있습니다.
struct ContentView: View {
@State private var flag = false
var body: some View {
Rectangle()
.frame(width: flag ? 100 : 50, height: 50)
.onTapGesture {
var t = Transaction(animation: .default)
withTransaction(t) { flag.toggle() }
}
}
}원문 112쪽과 도표

마찬가지로 implicit animation도 .transaction View modifier를 사용해 다시 작성할 수 있습니다.
struct ContentView: View {
@State private var flag = false
var body: some View {
Rectangle()
.frame(width: flag ? 100 : 50, height: 50)
.transaction { t in
t.animation = .default
}
.onTapGesture { flag.toggle() }
}
}다만 이 코드는 value parameter가 없는 deprecated .animation(_:) API처럼 동작합니다. .transaction을 사용해 .animation(animation:value:) API를 흉내 내려면 flag의 이전 값을 기억했다가 새로운 값과 비교한 후 transaction에 animation을 설정해야 합니다. Implicit animation과 마찬가지로 transaction은 view tree 아래쪽으로 전달됩니다. 이 경우에는 frame과 사각형으로 전달됩니다.
두 종류의 animation 모두 내부적으로 현재 transaction의 animation property를 설정할 뿐이므로, implicit animation이 explicit animation보다 우선합니다. Implicit animation을 설정하는 modifier는 view tree를 평가하는 과정에서 나중에 실행됩니다. 따라서 state 변경을 시작할 때 설정했을 수 있는 explicit animation을 덮어씁니다.
Animation의 우선순위와 관련해 Transaction에는 disablesAnimations라는 유용한 property가 하나 더 있습니다. 이 property를 true로 설정하면 transaction 동안 implicit animation이 영향을 주지 않습니다. disablesAnimations property가 모든 animation을 비활성화하는 것은 아닙니다. Implicit animation이 transaction의 animation을 덮어쓰지 못하게 할 뿐입니다.
앞의 예제를 explicit transaction을 사용하도록 바꾸고 transaction.disablesAnimations를 true로 설정해 implicit animation을 비활성화하면, 초록색 사각형을 탭할 때 천천히 움직이는 것을 볼 수 있습니다.
역주: 원문은 초록색 사각형이 천천히 움직인다고 설명하지만, 이 변경을 적용한 별도의 예제 코드는 제시하지 않습니다.
다시 말해 implicit animation은 다음과 비슷하게 구현되었다고 생각할 수 있습니다.
원문 113쪽과 도표

extension View {
func animation(_ animation: Animation?) -> some View {
transaction { t in
guard !t.disablesAnimations else { return }
t.animation = animation
}
}
}.transaction API는 현재 animation을 제거할 때도 유용합니다. .animation(_:) API가 deprecated되기 전에는 .animation(nil)을 작성하는 것만으로 해당 subtree의 animation을 비활성화할 수 있었습니다. 지금은 이렇게 작성하면 deprecation warning이 발생합니다. 대신 .transaction { $0.animation = nil }을 사용할 수 있습니다.
iOS 17부터는 environment와 마찬가지로 사용자 정의 key를 통해 transaction을 확장할 수 있습니다. EnvironmentKey protocol 대신 TransactionKey protocol을 사용합니다. 이를 통해 transaction에 state를 연결하고 transaction이 처리되는 동안 나중에 읽을 수 있습니다. 예를 들어 변경이 서버에서 시작되었는지 client에서 시작되었는지 그 출처를 transaction에 추가하고, 이 값에 따라 implicit animation을 선택할 수 있습니다.
Completion handler
Animation completion handler도 transaction의 일부이며 iOS 17부터 사용할 수 있습니다. Explicit withAnimation API를 사용할 때 completion handler를 직접 설정할 수 있습니다. 또는 .transaction modifier의 closure 안에서 .addAnimationCompletion을 사용해 transaction에 추가할 수도 있습니다. 대체로 예상대로 동작하지만, 완전히 직관적이지는 않은 몇 가지 경우를 살펴보겠습니다.
Transaction에 completion handler를 직접 추가할 때는 animation이 발생할 때마다 transaction을 수정하는 closure가 실행되는지 확인해야 합니다. 그렇지 않으면 completion handler가 한 번만 호출됩니다. 다음 예제를 보겠습니다.
struct ContentView: View {
@State private var flag = false
var body: some View {
VStack {
Button("Animate!") { flag.toggle() }
Circle()
.fill(flag ? .green : .red)
.frame(width: 50, height: 50)
.animation(.default, value: flag)
.transaction {
$0.addAnimationCompletion { print("Done!") }
}
}
}
}원문 114쪽과 도표

버튼을 탭할 때마다 flag state property를 토글하므로 View의 body도 매번 다시 실행되고, 원의 fill color가 빨간색에서 초록색으로, 다시 빨간색으로 animation됩니다. 하지만 SwiftUI는 .transaction closure의 dependency를 추적하는 것으로 보입니다. 이 closure는 어떠한 state에도 의존하지 않으므로 body가 실행될 때마다 다시 실행되지는 않습니다. 따라서 completion handler는 처음 한 번만 호출됩니다.
.transaction(value:_ transform:) modifier를 사용해 지정한 값이 바뀔 때마다 transform closure를 호출하도록 하면 이 문제를 해결할 수 있습니다.
var body: some View {
VStack {
Button("Animate!") { flag.toggle() }
Circle()
...
.transaction(value: flag) {
$0.addAnimationCompletion { print("Done!") }
}
}
}Completion handler는 animation이 논리적으로 완료될 때, 즉 사람이 보기에 완료된 것처럼 느껴질 때 호출할지, 아니면 animation curve가 실제로 끝나고 animation이 제거되었을 때 호출할지 지정할 수 있습니다. Spring animation처럼 후자가 훨씬 늦게 일어나는 경우도 있습니다. 새로운 CustomAnimation protocol을 사용해 사용자 정의 animation curve를 구현할 때는 AnimationContext의 isLogicallyComplete flag를 설정해 animation을 논리적으로 완료된 것으로 볼 시점을 지정할 수 있습니다.
원문 115쪽과 도표

Animatable protocol
SwiftUI의 property animation 시스템 중심에는 Animatable protocol이 있습니다. View와 View modifier가 이 protocol을 채택하면 animation을 적용할 수 있는 property를 SwiftUI에 드러낼 수 있습니다.
이 protocol의 유일한 요구 사항은 animatableData property입니다. 기본 구현은 아무 일도 하지 않습니다. animatableData의 type은 VectorArithmetic protocol을 준수해야 합니다. 이는 기본적으로 값에 float를 곱하고 두 값을 더할 수 있어야 한다는 뜻입니다. 이 조건 덕분에 SwiftUI는 한 값에서 다른 값으로의 변화를 보간할 수 있습니다.
Transaction에 animation이 연결되어 있으면 SwiftUI는 각각의 View를 살펴보며 변경된 animatable data property를 찾습니다. 그런 다음 현재 animation의 timing curve를 사용해 그 변화를 보간합니다. 보간된 각 값은 animatableData property를 통해 설정되며, 이때 View의 body 또는 View modifier의 body가 다시 실행됩니다.
보통 이 모든 과정은 드러나지 않게 내부에서 이루어지므로 직접 이 protocol을 준수할 필요는 없습니다. 하지만 원하는 결과를 얻으려면 사용자 정의 animatable View나 View modifier를 구현해야 하는 경우도 있습니다. 먼저 Animatable protocol이 SwiftUI에서 property animation을 가능하게 하는 방식을 더 잘 이해하기 위해 간단한 opacity animation 예제를 살펴보겠습니다.
struct ContentView: View {
@State private var flag = true
var body: some View {
Button("Animate") { flag.toggle() }
.opacity(flag ? 1 : 0.3)
.animation(.linear(duration: 1), value: flag)
}
}버튼을 탭하면 flag state property가 바뀌고 linear animation이 있는 새 transaction이 생성됩니다. SwiftUI는 view tree를 더 아래로 순회하면서 Animatable protocol을 준수하고 이전 state와 비교했을 때 animatableData 값이 다른 View나 View modifier를 찾습니다. 이 예제에서 유일한 변경은 opacity 값입니다. opacity View modifier는 기본적으로 animatable합니다. 즉, opacity 값을 animatable data로 드러냅니다. SwiftUI는 이제 duration이 1초인 linear timing curve를 사용해 이 값을 1에서 0.3으로 보간하기 시작합니다. 초당 60 frame이라면 opacity View modifier의 animatable data property가 60번 설정되며, 값은 1에서 0.3까지 작은 간격으로 선형 감소합니다.
원문 116쪽과 도표

내부에서 어떤 일이 일어나는지 더 자세히 알아보기 위해 animatable opacity modifier를 직접 구현할 수도 있습니다.
struct MyOpacity: ViewModifier, Animatable {
var animatableData: Double
init(_ opacity: Double) {
animatableData = opacity
}
func body(content: Content) -> some View {
let _ = print(animatableData)
content.opacity(animatableData)
}
}물론 직접 만든 modifier의 구현에서도 SwiftUI의 opacity modifier를 사용합니다. 하지만 이렇게 하면 animatable data property의 보간된 값을 console에 기록할 수 있습니다. 앞의 예제를 직접 만든 opacity modifier를 사용하도록 바꿔 보겠습니다.
Button("Animate Opacity") { flag.toggle() }
.modifier(MyOpacity(flag ? 1 : 0.3))
.animation(.linear(duration: 1), value: flag)이 앱을 실행하면 console에 많은 값이 나타납니다.
1.0
0.988333511352539
0.9766663551330566
0.9649998664855957
...
0.33500013351440433
0.3233336448669433
0.3116664886474609
0.3
원문 117쪽과 도표

Animatable data property의 각 값마다 View modifier의 body가 다시 실행되므로, 현재 animation state에 맞춰 View를 update할 수 있습니다.
Animatable protocol을 채택해야 하는 흔한 사례 중 하나는 시각적으로 시작할 때와 같은 state에서 끝나야 하는 animation입니다. iOS 17 이전에는 이런 animation을 구현할 때 문제가 있었습니다. view tree가 바뀌지 않으면 무엇을 어떻게 animation할지 SwiftUI에 알릴 방법이 없었습니다. 예를 들어 탭에 반응해 버튼을 흔들되 버튼이 시작한 위치로 돌아오게 하려면, view tree에는 animation을 적용할 변경 사항이 없습니다. iOS 17부터는 phase animation이나 keyframe animation을 사용할 수 있습니다. 두 가지 모두 이 장의 마지막에서 다룹니다.
새로운 iOS 17 API에 의존하지 않고 사용자 정의 animatable View modifier로 shake animation을 구현하는 방법을 살펴보겠습니다. 이 animation은 View를 현재 위치에서 왼쪽으로, 다시 오른쪽으로 흔든 다음 원래 위치로 되돌립니다.
struct Shake: ViewModifier, Animatable {
var numberOfShakes: Double
var animatableData: Double {
get { numberOfShakes }
set { numberOfShakes = newValue }
}
func body(content: Content) -> some View {
content
.offset(x: -sin(numberOfShakes * 2 * .pi) * 30)
}
}이 modifier는 다음처럼 사용할 수 있습니다.
struct ContentView: View {
@State private var shakes = 0
var body: some View {
Button("Shake!") { shakes += 1 }
.modifier(Shake(numberOfShakes: Double(shakes)))
.animation(.default, value: shakes)
}
}원문 118쪽과 도표

사용자가 shake 버튼을 탭하면 shakes state property가 1 증가합니다. 새로운 view tree와 render tree의 구조는 같고 property만 다릅니다. Render tree에서 Shake의 numberOfShakes property는 0이며, 새로운 view tree에서는 1입니다.
Shake의 animatableData property는 단순히 numberOfShakes property의 값을 전달하므로, Shake의 animatableData 값도 0에서 1로 바뀌었습니다. 따라서 SwiftUI는 지정한 .default timing curve를 사용해 이 변화를 0에서 1까지 보간합니다. animatableData가 바뀔 때마다 View modifier의 body가 다시 실행되고 화면에 있는 View의 위치가 update됩니다.
Shake modifier의 body method에서는 numberOfShakes 값, 또는 같은 값인 animatableData 값을 사용해 offset으로 실제 흔들림을 구현할 수 있습니다. numberOfShakes 값을 sine 함수에 넣고 계산을 추가해 View가 먼저 왼쪽으로 30포인트 이동하고, 이어서 오른쪽으로 60포인트 이동한 다음, 시작한 위치로 돌아오게 합니다.
macOS에 내장된 Apple의 Grapher 앱을 사용하면 이 View modifier의 계산이 하는 일을 시각적으로 재현할 수 있습니다.
사용자 정의 animatable View나 View modifier에서 둘 이상의 값을 보간해야 한다면, AnimatablePair type을 사용해 여러 animatable 값을 tuple로 조합할 수 있습니다.
Animatableprotocol의animatableDataproperty에는EmptyAnimatableDatatype의 기본 구현이 있습니다. 이는 animation할 것이 없다는 뜻이므로animatableData의 실제 구현을 잊기 쉽습니다. 또한VectorArithmetic을 준수하지 않는animatableDataproperty를 추가하더라도 기본 구현 때문에 compiler가 오류를 표시하지 않지만, animation은 동작하지 않습니다.

원문 119쪽과 도표

이처럼 시작한 곳에서 끝나는 animation은 사용자 정의 animation의 대표적인 예제이지만, 이 방법으로 구현할 수 있는 효과는 훨씬 많습니다. 기본적으로 SwiftUI가 보간해 주는 값을 바탕으로 원하는 animation을 자유롭게 만들 수 있습니다. 예를 들어 사용자 정의 timing curve를 사용하는 animation이나 하나의 SwiftUI animation 안에서 모두 실행되는 staggered animation을 만들 수 있습니다.
Transition
이미 화면에 있는 View의 property에 animation을 적용하는 것 외에도, View의 삽입과 제거에 animation을 적용할 수 있습니다. SwiftUI는 이런 animation을 transition이라고 부릅니다. iOS 17에는 protocol에 기반한 새로운 API가 있지만 기능은 이전과 같습니다. 먼저 iOS 17 이전 API를 설명한 다음 기존 API와 새로운 API의 차이를 살펴보겠습니다.
View를 삽입하거나 제거하는 state 변경에 animation을 적용하면 SwiftUI는 기본 .opacity transition을 적용합니다. Flag에 따라 사각형을 삽입하거나 제거하는 최소한의 예제를 살펴보겠습니다.
struct ContentView: View {
@State private var flag = true
var body: some View {
VStack {
Button("Toggle") {
withAnimation { flag.toggle() }
}
if flag {
Rectangle()
.frame(width: 100, height: 100)
}
}
}
}버튼의 action에서는 explicit animation을 사용해 transaction에 animation을 적용합니다. 그러면 사각형이 서서히 나타났다 사라집니다. 이것이 기본 .opacity transition의 동작입니다.
이 transition을 명시적으로 지정하려면 사각형에 .transition modifier를 추가합니다.
원문 120쪽과 도표

if flag {
Rectangle()
.frame(width: 100, height: 100)
.transition(.opacity)
}Transition에는 active state와 identity state라는 두 가지 state가 있습니다. View가 삽입될 때는 transition의 active state에서 identity state로 animation됩니다. 제거될 때는 identity state에서 active state로 animation됩니다. Opacity transition에서 active state는 .opacity(0) modifier를 적용하고 identity state는 .opacity(1)을 적용합니다.
SwiftUI API의 AnyTransition.modifier(active:identity:)를 사용하면 transition의 두 state로 사용자 정의 transition을 만들 수 있습니다. 두 parameter 모두 state를 정의하는 View modifier를 받습니다. 예를 들어 SwiftUI의 기본 .opacity transition을 다시 구현하려면 다음처럼 작성할 수 있습니다.
struct MyOpacity: ViewModifier {
var value: Double
func body(content: Content) -> some View {
content.opacity(value)
}
}
원문 121쪽과 도표

extension AnyTransition {
static let myOpacity = AnyTransition.modifier(
active: MyOpacity(value: 0),
identity: MyOpacity(value: 1)
)
}.myOpacity transition을 가진 View가 삽입되기 전에 MyOpacity(value: 0) View modifier를 적용해 View를 transition의 초기 active state로 둡니다. 그런 다음 modifier를 identity에 해당하는 것으로 바꿉니다. MyOpacity(value: 1) modifier가 적용되어 삽입 animation이 끝난 뒤 View가 완전히 불투명해집니다. 제거할 때는 과정이 반대입니다. View는 identity modifier가 적용된 정지 상태에서 시작합니다. 이어서 active modifier가 적용되어 사라지기 전의 최종 state까지 animation됩니다.
Transition이 발생하려면 반드시
if나switch문으로 View를 제거해야 하는 것은 아닙니다. View의 identity가 바뀌어도 transition이 발생합니다. SwiftUI의 관점에서는 이전 identity를 가진 View가 제거되고 새로운 identity를 가진 View가 삽입된 것이기 때문입니다..id(_:)modifier를 사용해 transition을 발생시키는 방법도 유용합니다.
SwiftUI에는 .slide, .move(edge:), .scale(scale:anchor:) 등 다양한 transition이 내장되어 있습니다. .combined(with:) transition을 사용하면 이들을 병렬로 조합할 수도 있습니다. 삽입과 제거에 서로 다른 transition을 지정하려면 .asymmetric(insertion:removal:) transition을 사용합니다.
if flag {
Rectangle()
.frame(width: 100, height: 100)
.transition(
.asymmetric(insertion: .slide, removal: .scale)
.combined(with: .opacity)
)
}이 transition은 삽입에 slide를, 제거에 scale을 사용하며 두 경우 모두 opacity transition과 조합합니다.
내장 transition만으로 충분하지 않다면 앞서 설명한 .modifier(active:identity:) API를 사용해 transition을 완전히 직접 구현할 수 있습니다. 예를 들어 blur transition을 만들 수 있습니다. 첫 단계로 사용자 정의 modifier가 필요합니다.
원문 122쪽과 도표

struct Blur: ViewModifier {
var radius: CGFloat
func body(content: Content) -> some View {
content
.blur(radius: radius)
}
}이 View modifier를 Animatable로 표시할 필요는 없습니다. 내장 .blur modifier가 이미 animatable하다는 사실을 이용하기 때문입니다. 편리한 API를 제공하도록 AnyTransition에 extension을 추가할 수도 있습니다.
extension AnyTransition {
static func blur(radius: CGFloat) -> Self {
.modifier(active: Blur(radius: 5), identity: Blur(radius: 0))
}
}역주: 원문 코드는
radiusparameter를 받지만 active modifier에는 고정 값5를 사용합니다. 원문을 그대로 보존했습니다.
앞의 예제에서 이 transition을 사용할 수 있습니다.
if flag {
Rectangle()
.frame(width: 100, height: 100)
.transition(.blur(radius: 5))
}Transition은 항상 animation과 함께 동작한다는 점을 기억해야 합니다. View에 .transition modifier만 적용한다고 변경에 animation이 생기지는 않습니다. 그러려면 transition과 함께 explicit animation이나 implicit animation을 항상 적용해야 합니다. Implicit animation을 사용한다면 해당 animation이 제거되거나 삽입되는 subtree에 속해서는 안 됩니다. 예를 들어 다음은 동작하지 않습니다.
if flag {
Rectangle()
.frame(width: 100, height: 100)
.transition(.blur(radius: 5))
.animation(.default, value: flag)
}대신 View가 삽입되거나 제거되어도 안정적으로 유지되는 위치에 animation을 두어야 합니다. 다음과 같습니다.
원문 123쪽과 도표

VStack {
if flag {
Rectangle()
.frame(width: 100, height: 100)
.transition(.blur(radius: 5))
}
}
.animation(.default, value: flag)여기서는 바깥에서 animation을 적용할 수 있도록 VStack으로 if 조건을 감쌌습니다. HStack이나 ZStack을 사용해도 됩니다. 이 목적에는 어느 것을 사용하는지가 중요하지 않습니다. View tree의 이 부분에 animation을 적용할 수 있도록 if 조건을 감쌀 것이 필요할 뿐입니다. 물론 explicit animation을 사용할 수도 있습니다.
iOS 17에서는 기능을 유지하면서 더 명확한 interface를 제공하도록 transition API가 바뀌었습니다. 내장 transition은 이제 Transition protocol을 준수하는 type입니다. 편의를 위해 Transition protocol의 static property로도 접근할 수 있으므로, 계속해서 .transition(.opacity)나 .transition(.scale(2)) 같은 코드를 작성할 수 있습니다.
사용자 정의 transition을 만들려면 이제 Transition protocol을 준수하는 사용자 정의 type을 만들 수 있습니다. Active state와 identity state라는 개념은 willAppear, identity, didDisappear phase를 구별하는 명시적인 TransitionPhase type으로 대체되었습니다. 사용자 정의 blur transition은 이제 다음과 같습니다.
struct BlurTransition: Transition {
var radius: CGFloat
func body(content: Content, phase: TransitionPhase) -> some View {
content
.blur(radius: phase.isIdentity ? 0 : radius)
}
}이 transition을 Transition의 static method로 제공하려면 다음처럼 작성할 수 있습니다.
extension Transition where Self == BlurTransition {
static func blur(radius: CGFloat) -> Self {
BlurTransition(radius: radius)
}
}이 절의 앞부분에서는 여러 transition을 조합하고 삽입과 제거에 서로 다른 transition을 지정하는 방법을 살펴봤습니다. Opacity transition에 삽입 시 slide, 제거 시 scale을 조합하는 예제를 새로운 API로 다시 작성하면 다음과 같습니다.
원문 124쪽과 도표

if flag {
Rectangle()
.frame(width: 100, height: 100)
.transition(
AsymmetricTransition(insertion: .slide, removal: .scale)
.combined(with: .opacity)
)
}사용자 정의 transition 정의 안에서는 asymmetric transition을 만드는 대신 TransitionPhase를 사용해 삽입, identity, 제거를 구별할 수도 있습니다.
전반적으로 저희는 새로운 API가 개선되었으며 SwiftUI의 나머지 부분과도 더 잘 어울린다고 생각합니다. 다만 기존 transition API는 아직 deprecated되지 않았으므로, 당분간 기존 코드도 warning 없이 계속 동작합니다.
Phase-based animation
iOS 17에서 도입된 새로운 phase animator는 시작한 곳에서 끝나는 animation을 구현하는 가장 쉬운 방법입니다. Phase는 phase animator가 순서대로 거치는 이산적인 값입니다. 한 phase가 끝나면 시스템은 다음 phase로 진행합니다. Phase animator는 무한히 실행되도록 할 수도 있습니다. 마지막 phase를 마치면 첫 phase로 이어집니다. 또는 trigger 값이 바뀔 때마다 전체 phase를 정확히 한 번씩 순회하도록 할 수도 있습니다.
예를 들어 앞에서 구현한 shake animation을 phase animator로 다시 구현할 수 있습니다.
struct Sample: View {
@State private var shakes = 0
var body: some View {
Button("Shake") {
shakes += 1
}
.phaseAnimator([0, -20, 20], trigger: shakes) { content, offset in
content.offset(x: offset)
}
}
}원문 125쪽과 도표

초기 phase는 배열의 첫 원소인 0이며 버튼은 offset(x: 0)으로 render됩니다. shakes property가 바뀔 때마다 phase animator는 자신의 내부 state를 다음 phase인 -20으로 바꿉니다. 이 내부 state 변경은 explicit animation으로 감싸집니다. 그 animation이 완료되면 animator는 다시 animation을 사용해 state를 세 번째 phase인 20으로 바꿉니다. 마지막으로 그 animation이 완료되면 animator는 state를 초기 state로 되돌립니다. 여기서는 trigger 값을 제공했으므로 animator가 이 지점에서 멈춥니다. Trigger 값을 생략했다면 animator는 반복 실행됩니다.
Phase animator 자체는 어떤 값도 보간하지 않습니다. 이 예제에서는 phase로
CGFloat를 사용했지만, 대신 세 개의 case를 가진 enum을 사용할 수도 있습니다. 위 예제에서 View의 위치에 animation을 적용하는 것은offsetmodifier입니다. 원서 집필 시점에Phase의 유일한 요구 사항은Equatableprotocol을 준수하는 것입니다.
각 phase 변경은 별개의 animation으로 일어납니다. 즉, phase animator가 모든 phase를 순회하는 과정에 하나의 전체 timing curve를 지정할 수는 없습니다. 대신 animation parameter를 사용해 phase마다 하나의 timing curve를 지정할 수 있습니다. Animation이 분리되어 있다는 점이 앞의 사용자 정의 shake animation과 다른 핵심입니다. 앞에서는 하나의 animation이 전체 shake 효과를 이끌었습니다.
Keyframe-based animation
Keyframe-based animation은 keyframe에 지정한 이산적인 값 사이를 전환하는 animation을 기술하는 방법입니다. Phase animation과 달리 완전히 별도의 subsystem으로 만들어지며, 일반 animation 위에 구축되지 않습니다.
원서 집필 시점에는 keyframe animation이 아직 완성되지 않은 것으로 보입니다. 예를 들어 아래의 shake animation은 처음에는 올바르게 실행되지만 두 번째에는 그렇지 않습니다.
Keyframe animation은 시간에 따라 하나의 값에 animation을 적용합니다. 이 값은 하나의 double 값일 수도 있고 여러 property를 포함한 struct일 수도 있습니다. SwiftUI는 이 값의 type에 어떠한 제약도 두지 않습니다. Keyframe animation은 하나 이상의 track으로 구성되며 property마다 track이 하나씩 있습니다. 예를 들어 rotation 값과 offset 값이 있는 struct를 생각해 보겠습니다. 각 property에 자신의 track을 사용해 독립적으로 animation을 적용할 수 있습니다. 하나의 track은 여러 항목을 포함한 timeline이라고 생각하면 이해하기 쉽습니다. 각 항목은 다음 값으로 어떻게 이동할지를 기술합니다. 이 설명에는 목표 값과 duration, 보간 방법이 포함됩니다.
원문 126쪽과 도표

Shake animation을 keyframe-based animation으로 다시 구현할 때는 하나의 float, 즉 offset에만 animation을 적용하면 됩니다. 따라서 여러 track을 지정할 필요가 없습니다.
struct ShakeSample: View {
@State private var trigger = 0
var body: some View {
Button("Shake") {
trigger += 1
}
.keyframeAnimator(initialValue: 0, trigger: trigger) { content, offset in
content.offset(x: offset)
} keyframes: { value in
CubicKeyframe(-30, duration: 0.25)
CubicKeyframe(30, duration: 0.5)
CubicKeyframe(0, duration: 0.25)
}
}
}trigger 값이 바뀔 때마다 animation이 실행됩니다. Offset은 0에서 시작하며 첫 번째 keyframe은 처음 0.25초 동안 -30을 향해 animation합니다. 두 번째 keyframe은 다음 0.5초 동안 30을 향해 animation하고, 마지막 keyframe은 다시 0으로 animation합니다. Cubic interpolation을 선택했으므로 animation은 초기 속도 0에서 부드럽게 속도를 높이며 keyframe 사이도 부드럽게 전환합니다. 다음 코드를 사용한 도표는 시간에 따른 값을 보여 줍니다. 점은 keyframe을 나타냅니다.
CubicKeyframe(-30, duration: 0.25)
CubicKeyframe(30, duration: 0.5)
CubicKeyframe(0, duration: 0.25)Keyframe은 주변 keyframe을 살펴보며 자신의 curve를 계산합니다. 예를 들어 위 도표에서는 cubic keyframe을 통과하는 부드러운 curve를 볼 수 있습니다.

원문 127쪽과 도표

반면 아래 도표에서 가운데 cubic keyframe은 첫 번째 linear keyframe의 마지막 속도와 마지막 linear keyframe의 시작 속도를 함께 고려해 부드러운 전환을 만듭니다.
LinearKeyframe(-30, duration: 0.25)
CubicKeyframe(30, duration: 0.5)
LinearKeyframe(0, duration: 0.25)keyframeAnimator는 값에 어떤 제약도 요구하지 않지만, 개별 keyframe인 LinearKeyframe, CubicKeyframe, MoveKeyframe은 목표 값이 Animatable을 준수해야 합니다.
여러 Track
앞에서는 하나의 CGFloat에만 animation을 적용했습니다. 하나의 값에 animation을 적용하는 것은 특별한 경우입니다. 일반적으로 keyframe animation은 동시에 실행되는 여러 track을 제공할 수 있습니다. 예를 들어 rotation effect를 통해 약간의 회전 흔들림도 포함하도록 shake animation을 확장할 수 있습니다. 먼저 animation을 적용할 두 값의 property를 가진 struct를 정의합니다.
struct ShakeData {
var offset: CGFloat = 0
var rotation: Angle = .zero
}Keyframe 정의를 제외하면 코드는 크게 달라지지 않습니다. 이제 다른 type의 초기 값으로 시작하고 content closure 안에 rotation effect를 추가합니다.
struct ShakeSample2: View {
@State private var trigger = 0
var body: some View {
Button("Shake") {
trigger += 1
}
.keyframeAnimator(initialValue: ShakeData(), trigger: trigger) { content, data in
content
.offset(x: data.offset)
.rotationEffect(data.rotation)
} keyframes: { value in ... }
}
}
원문 128쪽과 도표

하지만 keyframe의 정의는 조금 더 복잡해집니다. 이제 하나의 값에 animation을 적용하는 대신 KeyframeTrack으로 두 개의 별도 track을 만듭니다. SwiftUI는 여전히 하나의 ShakeData 값에 animation을 적용하지만, 첫 번째 track으로 offset에 animation을 적용하고 두 번째 track으로 rotation에 animation을 적용합니다. 하나의 track 안에 있는 keyframe은 순서대로 실행되지만 track 자체는 병렬로 실행됩니다. 다음 도표에서 이를 확인할 수 있습니다. Offset과 rotation에 각각 animation을 적용한 뒤 하나의 값으로 조합합니다.
KeyframeTrack(\.offset) {
CubicKeyframe(-30, duration: 0.25)
CubicKeyframe(30, duration: 0.5)
CubicKeyframe(0, duration: 0.25)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(20), duration: 0.1)
LinearKeyframe(.degrees(-20), duration: 0.2)
LinearKeyframe(.degrees(20), duration: 0.2)
LinearKeyframe(.degrees(-20), duration: 0.2)
LinearKeyframe(.degrees(20), duration: 0.2)
LinearKeyframe(.degrees(0), duration: 0.1)
}일반적으로 모든 track의 duration이 같을 필요는 없습니다. 다른 track이 아직 실행 중일 때 어느 track이 끝나면, 그 track은 나머지 animation 시간 동안 자신의 최종 값을 반환합니다. keyframeAnimator method에서는 현재 값도 전달받는다는 점에 유의해야 합니다. 처음에는 initialValue이지만 다시 탭하면 이전 animation의 최종 값이 됩니다. 이전 animation이 끝나기 전에 animation을 발생시키면 closure의 initialValue는 현재의 보간된 값이 됩니다.
지금까지는 keyframeAnimator modifier를 사용했지만 KeyframeAnimator View도 있습니다. View를 수정하는 대신 animation을 적용할 View를 구성할 수 있습니다.

원문 129쪽과 도표

Keyframe modifier와 View 모두 trigger 값이 바뀔 때마다 실행되는 형태와 무한히 실행되는 형태를 제공합니다.
내장 keyframe animator만으로 충분하지 않다면 KeyframeTimeline struct를 사용할 수도 있습니다. 이는 초기 값과 keyframe 설명을 받지만, View에 animation을 적용하는 데 사용하는 대신 특정 시점의 값을 조회할 수 있습니다. SwiftUI View 이외의 것에 animation을 적용하려 할 때 유용합니다. 예를 들어 snapshot test에서 animation의 여러 단계를 render하거나, TimelineView 안에서 animation을 적용하거나, animation 중인 값을 기록하는 데 사용할 수 있습니다. 저희도 앞의 도표를 생성할 때 이 방법으로 값을 기록했습니다. 하지만 keyframe animation에는 몇 가지 한계가 있습니다.
- 특성상 implicit animation으로만 사용할 수 있습니다.
withKeyframeAnimation은 없습니다. - 개별 keyframe은
Animatable값에만 animation을 적용할 수 있습니다. 예를 들어Color는Animatable을 준수하지 않으므로 keyframe으로Color값에 직접 animation을 적용할 수 없습니다. 하지만Color.Resolved값에는 animation을 적용할 수 있습니다. - 마지막으로 원서 집필 시점에는 trigger 기반 animation에 여전히 몇 가지 bug가 있습니다.
원문 130쪽과 도표
