개발을 시작하는 이야기

[SwuftUI] Lecture 7: ViewModifier Animation 본문

개발 이야기/Swift

[SwuftUI] Lecture 7: ViewModifier Animation

Teiresias 2022. 4. 19. 18:35

강의 보기 : Youtube :: Stanford 

 

Animation

애니메이션 효과는 변화가 발생했을 때만 나타난다.

1. View가 이미 UI에 들어가 있는 상태에서 ViewModifier의 인자가 바뀌었을 때

2. shape가 바뀌었을 때

3. UI 내부의 View가 생기거나 사라질 때

  • UI상의 View container에 추가되는 경우
  • UI상의 View container에 제거되는 경우
  • if-else, ForEach문

애니메이션을 실행하는 세 가지 방법

Implicit Animation

.animation(Animation) View Modifier를 이용한 방식

  • duration, delay, repear, curve 등 직접 지정할 수 있다. 
  • .animation 앞의 모든 View Modifier들에 대해 애니메이션이 적용됨
  • container에 적용 시 내부의 모든 View에 분배되기 때문에 최하단 View 혹은 독립적인 View에 적용한다.
Text(“👻”)
    .opacity(scary ? 1 : 0)
    .rotationEffect(Angle.degrees(upsideDown ? 180 : 0))
    .animation(Animation.easeInOut)

 

Explicit Animation

widthAnimation(Animation) { } 함수를 이용한 explicit 한 방식

  • 여러 개의 변화가 발생했을 때 전부에 대해 애니메이션 효과를 추가
  • explicit 애니메이션은 implicit 애니메이션을 오버라이딩 하지 않는다. 동시에 적용되었을 때 Implicit Animation이 우선
withAnimation(.linear(duration: 2)) { 
// do something that will cause ViewModifier/Shape arguments to change somewhere 
}

 

Transition

UI상의 Container에 View를 새로 추가/삭제하는 transition

  • 작동 원리는 한 쌍의 ViewModifier가 변화하는 것으로, 변화 이전 modifier와 변화 이후 modifier 한쌍의 인자가 변화하는 것을 나타내는 것이다.
  • ViewBuiler 내부의 ForEach 혹은 if-else문을 통해 가능하다.
  • Implicit 방식과 달리 container에 적용 시 분산되지 않고 해당 container 자체에 적용된다.

애니메이션 작동 원리

  1. 애니메이션 시스템이 애니메이션의 시작부터 끝까지의 과정을 작은 조각으로 분할한다.
  2. shape 혹은 ViewModifier가 시스템에게 자신의 내부에 어떤 콘텐츠가 애니메이션화 되어야 하는지 알려준다.
  3. 애니메이션 시스템이 애니메이션 구현을 위해 shape 혹은 ViewModifier를 다시 호출해 단계에 따라 적절한 명령을 내려준다.

var animatableData

shape/ViewModifier는 AnimatableModifier의 animatableData 변수를 통해 animation 시스템과 상호작용 한다.

상호작용을 하기 때문에 animatableData는 읽기와 쓰기가 모두 가능하다

  • get : 애니메이션 시스템이 애니메이션의 시작점과 끝점을 호출
  • set : shape/ViewModifier에게 애니메이션 시스템이 그려야 할 조각을 알려준다.

기존의 CardView에서 카드 객체를 전부 설정해주었지만 이것도 역할을 분리하여 나눠주었다.

struct CardView: View {
    let card: EmojiMemoryGame.Card
        
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                let shape = RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
                if card.isFaceUp {
                    shape.fill().foregroundColor(.white)
                    shape.strokeBorder(lineWidth: DrawingConstants.lineWidth)
                    Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: 110-90))
                        .padding(5)
                        .opacity(0.5)
                    Text(card.content)
                        .font(font(in: geometry.size))
                } else if card.isMatched {
                    shape.opacity(0)
                } else {
                    shape.fill()
                }
            }
        }
    }
    
    private func font(in size: CGSize) -> Font {
        Font.system(size: min(size.width, size.height) * DrawingConstants.fontScale )
    }
    
    private struct DrawingConstants {
        static let cornerRadius: CGFloat = 10
        static let lineWidth: CGFloat = 3
        static let fontScale: CGFloat = 0.7
    }
}

변경 후

struct CardView: View {
    let card: EmojiMemoryGame.Card
        
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: 110-90))
                    .padding(5)
                    .opacity(0.5)
                Text(card.content)
                    .rotationEffect(Angle.degrees(card.isMatched ? 360 : 0))
                    .animation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: false))
                    .font(Font.system(size: DrawingConstants.fontSize))
                    .scaleEffect(scale(thatFits: geometry.size))
            }
            .cardify(isFaceUp: card.isFaceUp)
        }
    }
    
    private func scale(thatFits size: CGSize) -> CGFloat {
        min(size.width, size.height) / (DrawingConstants.fontSize / DrawingConstants.fontScale)
    }
    
    private func font(in size: CGSize) -> Font {
        Font.system(size: min(size.width, size.height) * DrawingConstants.fontScale )
    }
    
    private struct DrawingConstants {
        static let fontScale: CGFloat = 0.7
        static let fontSize: CGFloat = 32
    }
}

기존의 CardView에서 Card에 대한 모든 것을 구현했지만, 이것 역시 Cardify를 만들어 별도로 분리해주었다.

기존의 CardView에는 Pie와 Text만을 남겨주고, shape와 isFaceUp을 별도로 분리해 content를 받아서 카드 화한 View를 반환하는 방식이다. 

import SwiftUI

struct Cardify: ViewModifier {
    var isFaceUp: Bool
    
    func body(content: Content) -> some View {
        ZStack {
            let shape = RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
            if isFaceUp {
                shape.fill().foregroundColor(.white)
                shape.strokeBorder(lineWidth: DrawingConstants.lineWidth)
            } else {
                shape.fill()
            }
            content
                .opacity(isFaceUp ? 1 : 0)
        }
    }
    private struct DrawingConstants {
        static let cornerRadius: CGFloat = 10
        static let lineWidth: CGFloat = 3
        static let fontScale: CGFloat = 0.7
    }
}

extension View {
    func cardify(isFaceUp: Bool) -> some View {
        return self.modifier(Cardify(isFaceUp: isFaceUp))
    }
}

Text의 .font()는 애니메이션 적용이 불가한 ViewModifier로 Font.system()으로 변경하여 적용했다.

 

두 번째 카드를 매치시키면 처음 카드의 이모지만 회전하게 된다. 이는 두 번째 카드의 content가 카드를 뒤집음과 동시에 UI상에 처음 나타나게 되므로 이미 isMatched인 상태로 등장하기 때문에 애니메이션 효과가 적용되지 않는다.

따라서 content를 항상 존재하게 조건문의 밖으로 배치하고, opacity를 조절하는 방식을 사용해서 애니메이션 효과를 주게 되었다.


객체지향에 익숙해지는 것보단 애니메이션이 더 쉬운 것 같지만 막상 프로젝트에서 사용하는 것은 별개의 문제.

마크업에서 애니메이션 효과를 주는 것과 크게 다르진 않은 것 같지만 일단은 적용을 해보면서 익숙해져 보는 게 좋을 것 같다.