-
Explore SwiftUI Animation (WWDC 2023)SwiftUI 2023. 6. 8. 21:42
안녕하세요. 그린입니다🍏
드디어 이번 WWDC 2023에서 소개한 SwiftUI 애니메이션 부분의 마지막 섹션을 볼 차례입니다 🙌
이번 주제는 SwiftUI의 애니메이션 기능을 살펴보고 어떻게 동작해 인상적인 시각 효과를 생성하는지 알아보겠습니다.
또한, SwiftUI가 뷰의 렌더링을 업데이트하고 무엇을 애니메이션할지 결정하고 시간에 따라 값을 보간하면서 현재 트랜잭션에 대한 컨텍스트를 전파하는 방법을 같이 살펴보죠!
애플에서는 앱에 애니메이션을 추가하는 것을 아주 간단하게 만드는것이 SwiftUI 개발을 시작할 때 핵심 동기중 하나였다고 합니다.
Anatomy of an update
SwiftUI가 뷰의 렌더링을 업데이트하는 방법을 알아보죠.
위 코드처럼 이미지가 탭이 되면 스케일 즉, 크기가 변하는 뷰가 있다고 가정합니다.
SwiftUI에서는 @State 변수를 가지고 뷰의 종속성을 추적합니다.
여기서 탭 이벤트가 발생하면 업데이트 트랜잭션이 열립니다.
이때 selected 값이 변경되죠.
즉 종속성이 변경되면 현재 뷰가 무효화되고 트랜잭션 종료 시 프레임워크는 렌더링을 새로 고치기 위해 새 값을 생성하기 위해 다시 바디를 호출합니다.
보시면 해당 body는 이렇게 구성되어 있죠?
value가 변경되게 되면 Attribute graph (종속성 그래프)에서 보이는것처럼 다운스트림으로 순차적으로 풀어서 새로고침됩니다.
즉 여기선 onTapGesture > scaleEffect > Image 순인거죠.
한 레이어씩 새로고침이 되죠.
해당 그래프 속성이 업데이트되면 뷰의 body 값은 삭제되죠.
마지막으로 그래프는 사용자를 대신해 그리기 명령을 내보내 렌더링을 업데이트 해줍니다.
즉 시간에 따라 그래프로 나타내보면 이런 흐름을 갖죠.
만약 여기 onTapGesture에 애니메이션이 들어가면 어떻게 될까요?
요렇게 animation이 존재하게 되고 기존 onTapGesture부터 순차적으로 풀어줍니다.
그런데 다른점은 scaleEffect는 애니메이션 속성인 특수 attribute입니다.
애니메이션 가능한 속성의 값이 변경되면 트랜잭션에 애니메이션이 설정되어 있는지 확인해주죠.
만약 설정되있다면 복사값을 만들고 애니메이션을 사용해 시간이 지남에 따라 이전 값에서 새 값으로 보간해주죠.
즉 이벤트와 시간 경과에 따라 이런 흐름을 가지게 됩니다.
트랜잭션이 열림과 동시에 애니메이션이 같이 들어오게 되는 구조죠.
여기서 살펴볼 수 있었던건 두가지입니다.
1️⃣ scaleEffect와 같은 애니메이션 가능한 속성은 애니메이션되는 데이터를 결정 (Animatable)
2️⃣ Animation은 시간 경과에 따라 데이터가 어떻게 변경되는지 결정
그럼 이어서 Animatable부터 봐볼까요?
Animatable
Animatable을 이용해 무엇을 애니메이션할지 결정하는지 보시죠.
SwiftUI는 Animatable 프로토콜을 준수하는 모든 뷰에 대해 애니메이션 가능한 속성을 빌드합니다.
유일한 요구 사항은 뷰가 애니메이트하려는 데이터의 읽기 쓰기 벡터를 지정하는 것입니다.
데이터는 VectorArithmetic을 준수해야 합니다.
VectorArithmetic은 아래와 같이 벡터의 정의와 일치합니다.
CGFloat와 Double은 1차원 벡터
CGPoint와 CGSize는 2차원 벡터
CGRect는 4차원 벡터를 나타냅니다.이로써 벡터를 처리함으로 SwiftUI는 일반적인 단일 구현으로 이러한 모든 유형과 그 이상을 애니메이션화 할 수 있습니다.
우리는 scaleEffect에 대해 1차원적인 CGFloat만 구성했지만 이니셜라이저를 보면 아래와 같이 2차원 구성도 할 수 있습니다.
@inlinable public func scaleEffect(_ scale: CGSize, anchor: UnitPoint = .center) -> some View
더 나아가 아래와 같이 두 벡터를 하나의 더 큰 벡터로 AnimatablePair로 융합할 수 있습니다.
위처럼 기본 제공하는 API를 사용할 수도 있지만 간혹 자신이 만든 뷰를 Animatable에 따라 구성해줘야할 때가 있습니다.
좀 더 원하는 애니메이션을 줄 수 있죠.
다만 커스텀한 Animatable은 애니메이션의 모든 프레임에 대해 해당 코드를 실행하기 때문에 내장된 Effect보다 애니메이션 하는데 훨씬 더 많은 비용이 들 수 있습니다😳
그럼으로 꼭 내장된 Effect를 사용하여 원하는 바를 이룰 수 없을때만 커스텀하게 해주는것을 권장합니다!
그럼 다음으로 Animation에 대해 알아볼까요?
Animation
Animation을 사용해 시간 경과에 따라 값을 보간하는 방법을 보시죠.
withAnimation을 통해 이미 구축된 애니메이션을 줄 수도 있지만 명시적으로 커스텀하게 만들어서 줄 수도 있습니다.
SwiftUI에선 애니메이션이 정말 많이 내장되어 있어요.
앞으로 볼 아래 세가지 범주가 그러합니다.
Timing curve
타이밍 커브 애니메이션은 가장 친숙한 애니메이션입니다.
모든 타이밍 커브 애니메이션은 애니메이션의 속도와 기간을 정의하는 곡선을 사용해서 구현해요.
여기서 UnitCurve에서는 베지어 제어점을 사용해 타이밍 곡선을 만들 수 있습니다.
시작과 끝 제어점을 조정해 애니메이션의 초기 및 최종 속도를 변경할 수 있죠.
요런 식으로 말이죠.
UnitCurve 타입은 0과 1 사이 상대 지점에서 값과 속도를 계산하기 위해 독립적으로 사용할 수 있습니다.
물론 위와 같이 직접 만들어도 되지만 타이밍 커브 애니메이션을 위해 SwiftUI에서는 linear부터 easeInOut까지 3가지의 내장된 애니메이션 설정을 제공합니다.
그 다음으로 스프링 애니메이션을 알아볼까요?
Spring
스프링 시뮬레이션을 실행해 주어진 시점의 값을 결정합니다.
통통 튀는 스프링을 생각하면 되는것처럼 스프링의 질량, 강성, 감쇠와 같은 옵션을 설정해줍니다.
인자로는 mass, stiffness, damping이 있어요.
그런데 이거 너무 용어도 어렵고 설정하기 어렵잖아요?🥲
그래서 간단하게 duration과 bounce 정도만으로도 가능합니다.
요렇게 편리하게 만들어 사용할 수있죠.
스프링 애니메이션도 물론 기본적으로 타이밍 애니메이션처럼 내장되어 있는 종류가 있습니다.
바운스가 전혀 없는 smooth
바운스가 적은 snappy
바운스가 많은 bouncy가 존재합니다.여기서 바운스 정도는 스프링처럼 통통 튀는 정도로 보면 됩니다.
물론 이것들을 사용할때도 지속시간인 duration과 bounce를 조정할 수 있습니다🫠
withAnimation을 사용하고 있었다면 기본값이 이미 스프링 애니메이션으로 되어 있어 자연스러움을 경험하셨을거에요.
속도를 유지하고 자연스럽게 정지하여 UI에 유기적인 느낌을 주기 위해서는 스프링 애니메이션을 사용하면 좋습니다🕺🏻
자 애니메이션의 마지막 카테고리인 기본 애니메이션을 좀 더 커스텀하고 고차원적으로 수정하는 방법을 보겠습니다!
Higher order
기본적으로 요렇게 세가지 인자를 조정하여 만들어줄 수 있죠.
1️⃣ speed로 애니메이션의 속도를 결정해줍니다.
2️⃣ delay로 애니메이션의 시작 타이밍의 딜레이를 줄 수 있죠.
3️⃣ repeatCount로 반복 및 반전 재생들을 지정할 수 있습니다.
자 여기까지 알아봤는데 사실 중요한게 하나 더 있습니다!
바로 WWDC 2023에서 새로 나온 애니메이션을 커스텀하게 만들 수 있도록 사용자 지정 애니메이션을 보겠습니다.
Custom
CustomAnimation 프로토콜은 SwiftUI에 내장된 모든 애니메이션을 구현하는데 사용하는 동일한 액세스를 제공해줍니다.
해당 프로토콜에는 아래와 같이 세가지 요구사항이 존재합니다.
souldMerge와 velocity는 선택사항이지만 animate는 필수 구현을 해줘야 합니다.
그렇기에 animate부터 중점적으로 봐볼까요?
animate
public protocol CustomAnimation : Hashable { func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V : VectorArithmetic ... }
1️⃣ value: 애니메이션을 적용할 벡터값, V는 VectorArithmetic 프로토콜을 따르는 타입니다.
2️⃣ time: 애니메이션 시작 후 경과된 시간
3️⃣ context: 추가 애니메이션 상태를 포함하는 컨텍스트
이렇게 가장 기본적인 이니셜라이저는 세가지 인자를 가집니다.
그럼으로 animate 메서드는 애니메이션의 현재 값을 반환하거나 애니메이션 완료된 경우 nil을 반환합니다.
자 그럼 여기서 value 즉 벡터값에는 어떤것이 해당될까요?
바로 맨처음 렌더링을 살펴봤을때 scaleEffect도 2차원 벡터라고 했습니다.
그것과 유사하게 scaled 메서드로 스칼라 곱셈을 사용해 경과된 기간의 비율로 벡터의 크기를 조정할 수 있습니다.
extension VectorArithmetic { public func scaled(by rhs: Double) -> Self }
즉 value에 사용할 수 있는것은 VectorArithmetic이 가지는 메서드입니다.
extension VectorArithmetic { /// Returns a value with each component of this value multiplied by the /// given value. public func scaled(by rhs: Double) -> Self /// Interpolates this value with `other` by the specified `amount`. /// /// This is equivalent to `self = self + (other - self) * amount`. public mutating func interpolate(towards other: Self, amount: Double) /// Returns this value interpolated with `other` by the specified `amount`. /// /// This result is equivalent to `self + (other - self) * amount`. public func interpolated(towards other: Self, amount: Double) -> Self }
요렇게 사실 scaled와 interpolate를 사용할 수 있죠.
한번 코드 예시로 보겠습니다.
struct ContentView: View { @State private var transitionBtnClicked: Bool = false var body: some View { // 애니메이션 VStack { if transitionBtnClicked { Rectangle() .fill(.green) .frame(width: 100, height: 100) } Button("Let's Animation") { withAnimation(.init(GreenAnimation(duration: 2.0))) { transitionBtnClicked.toggle() } } } } } struct GreenAnimation: CustomAnimation { var duration: CGFloat func animate<V>( value: V, time: TimeInterval, context: inout AnimationContext<V> ) -> V? where V : VectorArithmetic { if time <= duration { return value.scaled(by: time / duration) } else { return nil } } }
이렇게 time 즉 시간 경과와 지속 시간을 비교해주어 지속 시간이 경과하지 않았다면 그 시간만큼 계속 벡터를 변경해 애니메이션이 이뤄집니다.
그리고 지속 시간이 끝나면 nil로 반환하여 애니메이션을 종료하죠.
즉, 저 코드를 실행하면 초록색 사각형이 나타남에 따라 2초 동안 텍스트가 커스텀하게 지정한것과 같이 변경되는걸 볼 수 있습니다.
그럼 이제 animate 메서드를 보았으니 shouldMerge와 velocity도 어떤 기능을 하는지 볼까요?
souldMerge
만약 애니메이션 작동되고 있는 와중에 버튼을 눌러 다시 트리거를 발동시켜 애니메이션을 또 주면 어떻게 될까요?
애니메이션이 그냥 그대로 통합되어 가길 원하거나 아니면 다시 애니메이션을 동작시키길 원할거 같은데요.
func shouldMerge<V>( previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V> ) -> Bool where V : VectorArithmetic
보시면 머지를 할지 말지 Bool 값을 반환합니다.
타이밍 애니메이션에서는 기본적으로 false를 반환하며 두 애니메이션이 함께 실행되고 그 결과가 시스템에 의해 결합되는것도 해당 타이밍 애니메이션이 해주죠.
반면 스프링 애니메이션을 사용한다면 shouldMerge를 재정의하여 true를 반환하고 이전 애니메이션의 상태를 통합할 수 있습니다.
이로 인해 속도를 보존하고 새 값으로 대상을 변경할 수 있음으로 타이밍 애니메이션과 같이 추가로 결합하는것보다 더 자연스럽게 느껴질 수 있죠😃
마지막으로 velocity를 볼까요?
velocity
이 메서드를 통해 실행 중인 애니메이션과 새 애니메이션을 병합할 때 속도를 유지할 수 있습니다.
func velocity<V>( value: V, time: TimeInterval, context: AnimationContext<V> ) -> V? where V : VectorArithmetic
이렇게 value에 scaled를 통해 조정할 수 있죠.
func velocity<V>( value: V, time: TimeInterval, context: AnimationContext<V> ) -> V? where V : VectorArithmetic { value.scaled(by: 1.0 / duration) }
자 마지막으로 Transaction에 대해 알아볼까요?
Transaction
Transaction을 사용해 현재 업데이트에 대한 컨텍스트를 전파하는 방법을 다뤄보겠습니다.
즉, 트랜잭션도 현재 업데이트, 특히 애니메이션에 대한 모든 컨텍스트를 암시적으로 전파하는데 사용하는 딕셔너리 같은 것입니다.
위처럼 탭 제스쳐 클로저가 실행되면 withAnimation은 루트 트랜잭션 딕셔너리에 애니메이션을 설정합니다.
그 후 속성 값 업데이트를 위해 Body가 호출됩니다.
그럼 자연스레 트랜잭션 딕셔너리는 Attribute graph 전체에 전파가 됩니다.
이렇게 순차적으로 내려오다 애니메이션 가능한 속성에 도달하면 애니메이션이 설정되어 있는지 확인합니다.
설정되어 있다면 적용하고 Image로 넘어오게 되는것이죠.
여기서 적용된것은 복사를 가지게 됩니다.
트랜잭션은 특정 업데이트에만 관련되기에 오래된 attribute는 새로 업데이트되면 삭제됩니다.
트랜잭션 딕셔너리 내의 뷰 계층 아래로 애니메이션을 흐르게 하면 애니메이션이 뷰에 적용되는 시기와 방법을 제어하기 위한 여러 기능들이 가능해집니다😲
이렇게 트랜잭션 모디파이어를 사용하게되면 트랜잭션 딕셔너리에 애니메이션이 없거나 다른 애니메이션 있더라도 뷰가 호출될때마다 속성이 애니메이션을 재정의하게 됩니다.
그러다가 scaleEffect와 같은 효과에 도달하면 이 애니메이션이 scale factor를 보간하는데 사용하죠.
하지만 이러한 사용은 SwiftUI가 뷰를 업데이트 할때마다 모든 하위에 대한 애니메이션을 무차별적으로 재정의하면서 원치 않는 애니메이션을 발생시킬 수 있습니다.
그러므로 이러한 경우에는 SwiftUI의 animation 모디파이어를 사용해야 합니다.
아래와 같이 말이죠!
struct ContentView: View { @State private var selected: Bool = false var body: some View { Image(systemName: "pencil") .scaleEffect(selected ? 1.5 : 1.0) .animation(.bouncy, value: selected) .onTapGesture { withAnimation(.bouncy) { selected.toggle() } } } }
그런데 이렇게 되면 사실 withAnimation이 필요가 없습니다.
이미 animation에서 하고 있기에 사실상 저 코드는 아무 동작을 하지 않기에 제거해도 좋겠네요!
struct ContentView: View { @State private var selected: Bool = false var body: some View { Image(systemName: "pencil") .scaleEffect(selected ? 1.5 : 1.0) .animation(.bouncy, value: selected) .onTapGesture { selected.toggle() } } }
단일 말고도 여러개를 별도 애니메이션을 구성할 수 있습니다.
struct ContentView: View { @State private var selected: Bool = false var body: some View { Image(systemName: "pencil") .resizable() .frame(width: 100, height: 100) .shadow(radius: selected ? 12 : 8) .animation(.smooth, value: selected) .scaleEffect(selected ? 1.5 : 1.0) .animation(.bouncy, value: selected) .onTapGesture { selected.toggle() } } }
요런식으로 말이죠.
그럼 shadow에는 smooth 애니메이션이 적용되고 scaleEffect에는 bouncy가 적용됩니다.
즉 정리해보면 뷰 모디파이어는 전체 하위 계층 구조가 사용자의 제어 하에 있는 leaf components에 적합합니다.
그러나 임의의 자식 컨텐츠를 포함하는 즉 leaf components 구성 요소가 아닌 경우에는 예기치 못한 애니메이션이 발생할 가능성이 너무 높아 조심해야 합니다🥲
struct ContentView: View { @State private var selected: Bool = false var body: some View { Image(systemName: "pencil") .resizable() .frame(width: 100, height: 100) .animation(.smooth) { $0.shadow(radius: selected ? 12 : 8) } .animation(.smooth) { $0.scaleEffect(selected ? 1.5 : 1.0) } .onTapGesture { selected.toggle() } } }
방지하기 위해 이렇게 새로운 버전의 애니메이션 모디파이어가 나왔습니다.
이렇게 범위를 좁게 지정해줌으로 하위에서 영향을 최소화할 수 있습니다.
또한 커스텀한 트랜잭션 키를 선언하고 사용할 수 있습니다.
패턴은 TransactionKey 프로토콜을 준수하는 고유한 타입을 만드는것입니다.
유일한 요구사항으로는 defaultValue를 선언 해주는것 그거 외에 없습니다!
그리고 트랜잭션을 확장해 해당 키를 가지고 연산 프로퍼티를 만들어 활용할 수 있죠.
이제 실제 뷰에서 트랜잭션에 담을때 요렇게 사용할 수가 있게 되죠!
실제 탭될때는 해당 키 값을 사용하면서 withAnimation에서 withTransaction으로 변경합니다.
즉 탭이되면 해당 키 값이 true로 변하게 됩니다.
이것 또한 예기치 못한 효과를 가져올 수 있어 조금 더 안전하게 사용하는 방법이 있습니다.
import SwiftUI struct ContentView: View { @State private var selected: Bool = false var body: some View { Image(systemName: "pencil") .resizable() .frame(width: 100, height: 100) .transaction { $0.animation = $0.tapped ? .easeIn : .bouncy } body: { $0.scaleEffect(selected ? 2.0 : 1.0) } .onTapGesture { withTransaction(\.tapped, true) { selected.toggle() } } } } struct ImageKey: TransactionKey { static let defaultValue: Bool = false } extension Transaction { var tapped: Bool { get { self[ImageKey.self] } set { self[ImageKey.self] = newValue } } var tapped2: Bool { get { self[ImageKey.self] } set { self[ImageKey.self] = newValue } } }
이렇게 transaction 후행으로 body 클로저를 구성해줘서 범위를 좁힐 수 있죠.
자 어떻게 동작이 잘 되는지 볼까요?
나이스!
마무리
이렇게 SwiftUI의 애니메이션 렌더링과 기본적인 구성을 넘어 커스텀하게 만드는 방법까지 어느정도 본것 같네요!
마음은 알겠는데 머리로는 모르겠네요ㅋㅋㅋ
참고 자료
'SwiftUI' 카테고리의 다른 글
SwiftUI에서 Tooltip 구현하기 (9) 2023.06.22 Beyond Scroll Views (feat. WWDC 2023) (12) 2023.06.15 Advanced animations in SwiftUI (feat. WWDC 2023) (12) 2023.06.08 Discover Observation in SwiftUI (feat. WWDC 2023) (6) 2023.06.08 What's new in SwiftUI (feat. WWDC 2023) (6) 2023.06.07