SwiftUI Lecture7-8 정리
08 Jul 2021이번 강의에서는 Animation에 대해서 공부를 진행했다. 실제 앱을 이용하다보면 카드가 이동한다던지 카드의 그림이 회전한다던지 하는 경우가 있다. 이는 모두 Animation 효과를 사용해서 진행하는 것으로 iOS에서는 자체적으로 animation 기능을 제공한다.
1. ViewModifier
Animation에 대해 학습하기 전에 ViewModifier에 대해 먼저 알아보고 넘어가자. .font, .backGroundColor 와 같이 View의 속성을 조정해주는 함수들을 기억할 것이다. ViewModifier는 이러한 행위를 하게 해주는 프로토콜의 일종으로 View에게 특정한 행위를 해주고자 할 때 사용한다.
struct Cardify: ViewModifier {
var isFaceUp: Bool
func body(content: Content) -> some View {
if isFaceUp {
content.font(.largeTitle)
} else {
content.font(.title)
}
}
}
Text("👻").modifier(Cardify(isFaceUp: true))
View struct를 만드는 것과 비슷하게 작성하면 되며 var body가 아닌 func body를 만드는 것을 유의하자. 이 때 content라는 변수가 사용되는데 이는 input으로 받는 view를 의미하며 그 view가 어떤 것이던지 body 함수 내부의 행위를 적용시켜준다. ViewModifier에서는 isFaceUp과 같은 argument가 매우 중요한데 왜냐하면 이 값이 변할때마다 애니메이션을 작동시킬 수 있기 때문이다.
만약 .modifier() 와 같은 형태로 사용하고 싶지 않고 다른 font와 같은 modifier 처럼 사용하고 싶다면 다음과 같이 정의를 해주면 된다.
extension View {
func cardify(isFaceUp: Bool) -> some View {
return self.modifier(Cardify(isFaceUp: isFaceUp))
}
}
Text("👻").cardify(isFaceUp: true)
참고로 모든 ViewModifier가 animatable 하지는 않다. 예를들어 font는 animatable하지 않다. ViewModifier가 animatable 하다는 것을 나타내고 싶을 때는 var animatableData: Type(Dont care) 를 사용해줘야한다. 이것은 해당 변수가 변할 때 선형적으로 변하도록 하겠다는 의미이다. animatableData는 VectoArithmetic 해야하며 AnimatablePair를 가질수도있다. 이를 적용해서 Cardify ViewModifier Struct를 다시한번 보면 다음과 같다. 만약 변화하는 animatableData가 한개라면 AnimatableModifier로 선언해주고 두개라면 AnimatablePair<Type, Type>로 선언해주자.
struct Cardify: AnimatableModifier {
init(isFaceUp: Bool) {
rotation = isFaceUp ? 0 : 180
}
var animatableData: Double {
get { rotation }
set { rotation = newValue }
}
var rotation: Double // in degrees
func body(content: Content) -> some View {
ZStack {
let shape = RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
if rotation < 90 {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: DrawingConstants.lineWidth)
} else {
shape.fill()
}
content
.opacity(rotation < 90 ? 1 : 0)
}
.rotation3DEffect(Angle.degrees(rotation), axis: (0, 1, 0))
}
}
2. Animation
자 이제 애니메이션에 대해서 알아보자. 애니메이션은 ViewModifier나 Shape를 이용해서 적용이 가능하고 무언가 변수가 변하거나 View의 등장과 소멸에 대해서 애니메이션이 동작한다. 이 때 View에 대한 애니메이션은 해당 View가 UI에 들어간 후에만 일어날 수 있다는 것을 명심하자. 특정 View의 등장과 소멸에 대한 애니메이션을 사용하고 싶다면 해당 View를 감싸는 Container가 이미 UI에 존재해 있어야 한다.
애니메이션은 크게 3가지의 애니메이션으로 구분된다. Implicitly, explicitly, transition이 그에 해당한다.
-
Implicitly
일명 automatical animation으로도 불리는 이 녀석은 설정을 해주면 해당 view에 대해 어떤 argument가 변하면 자동적으로 애니메이션이 적용되는 형태이다. .animation(Animation)의 형태로 사용할 수 있다.
Text("👻") .opacity(scary ? 1 : 0) .rotationEffect(Angle.degrees(upsideDown ? 180 : 0)) .animation(Animation.eraseInOut)위와 같이 설정을 해주면 scary 변수와 upsideDown 변수가 변할때마다 투명도가 변한다던지 빙글빙글 도는 애니메이션이 자동으로 설정되는 형태이다. 이 때 .animation은 본인 이전에 선언된 ViewModifier에 대해서만 해당 animation 속성을 적용시킨다.
Animation struct는 여러가지 animation의 속성을 설정할 수 있다. duration, delay, repeat등이 이에 해당한다. 또한 animation curve 속성을 설정할 수 있는데 이는 애니메이션이 동작하는 속도 템포를 조절하는 역할을 한다. .linear 는 균일하게, .eraseInOut은 처음과 끝을 느리게, .spring은 끝날때 살짝 스프링처럼 흔들리도록 하는 역할을 한다.
-
Explicitly
Implicitly는 View에 선언된 모든 argument가 변할때마다 자동으로 적용이 되었다면 Explicitly는 내가 원하는 동작을 수행할 때 변하는 argument에게만 animation을 주겠다는 것이다. 사용법으로는 withAnimation(Animation) { } 의 형태를 사용하며 대괄호 내부에 원하는 함수들을 정의해주면 된다. 마찬가지로 Animation struct에는 여러가지 속성을 설정할 수 있다.
withAnimation(.linear(duration: 2)) { //do somthing that will cause ViewModifier/Shape arguments to change somewhere } -
Transition
View가 생성되고 삭제될때 수행할 애니메이션에 대해 설정해 줄 수 있다. 예를들어 등장할때는 점점 투명도가 변해서 등장하고 삭제될 때는 크기가 점점 줄어들어 삭제되게 한다던지 설정을 해줄 수 있다. 이 때 Transition은 CTAAOS (Containers That Are Already On-Screen) 안에 있는 View에 대해서만 동작한다. 형태로는 .transition(AnyTransition) 의 형태로 AnyTransition struct 내부에 pre-canned transition 목록들이 정의되어 있다.
CardView(card: $0) .matchedGeometryEffect(id: $0.id, in: dealingNamespace) .transition(.asymmetric(insertion: .identity, removal: .scale)) .zIndex(zIndex(of: $0)) // zIndex(Double) 속성을 설정해주어 겹쳤을 떄 어떤 View가 위로갈지 정해줌유의할 점으로는 transition은 단순히 등장과 삭제할때 발생할 애니메이션을 정의해주었을 뿐 실제로 애니메이션이 작동하지는 않는다. 따라서 Implicitly Animation이나 Explicitly Animation을 이용해서 발동시켜야한다.
Transition은 외부 Container에 적용하면 Container 전체가 생성되고 삭제될 때 적용이 되는 형태이다. 내부의 View들에게 그 정보를 넘겨주지 않는다. 약간 Padding과 비슷하다고 생각하면 된다. 하지만 Implicitly Animation은 외부 Container에 선언을 하면 내부의 모든 View들에게 각각 적용이 된다. 마치 backGroundColor를 외부 컨테이너에 선언하면 내부 모든 View들에게 적용되는 것처럼 말이다.
3. matchedGeometyEffect
만약 특정 container에서 다른 container로 View를 옮겨가고 싶다면 어떻게 해야할까? 아쉽게도 애니메이션 자체적으로는 해당 기능을 수행할 수 없다. 이를 위해 존재하는 기능이 바로 .matchedGeometryEffect(id: ID, in: Namespace)와 같은 형태로 사용한다. 사용하고 싶은 두 container에 대해서 모두 선언을 해주면 하나의 container에서 다른 container로 이동하는 것과 같은 animation 효과를 얻을 수 있다.
여기서 id는 우리가 view에 사용하는 id에 해당하고 namespace는 동일한 id를 갖는다해도 여러 컨테이너에 존재할 수 있기 때문에 어떤 컨테이너에서 어떤 컨테이너로 갈 것인지를 매칭시켜주기 위해 사용한다. @Namespace private var dealingNamespace 와 같이 상위 view struct에 선언해주고 사용하면 된다.
var gameBody: some View {
AspectVGrid(items: game.cards, aspectRatio: CardConstants.aspectRatio) { card in
if isUndealt(card) || (card.isMatched && !card.isFaceUp) {
Color.clear
} else {
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.padding(4)
.transition(.asymmetric(insertion: .identity, removal: .scale))
.zIndex(zIndex(of: card))
.onTapGesture {
withAnimation {
game.choose(card)
}
}
}
}
.foregroundColor(CardConstants.color)
}
var deckBody: some View {
ZStack {
ForEach(game.cards.filter(isUndealt)) {
CardView(card: $0)
.matchedGeometryEffect(id: $0.id, in: dealingNamespace)
.transition(.asymmetric(insertion: .identity, removal: .scale))
.zIndex(zIndex(of: $0))
}
}
.frame(width: CardConstants.undealtWidth, height: CardConstants.undealtHeight)
.foregroundColor(CardConstants.color)
.onTapGesture {
for card in game.cards {
withAnimation(dealAnimation(for: card)) {
deal(card)
}
}
}
}
위의 코드를 보면 deckBody에서 사라지고 gameBody로 Card가 생성될 때 마치 카드가 이동하는 것처럼 효과를 줄 수 있다.