개발을 시작하는 이야기

[SwiftUI] Lecture 8: Animation Demonstration 본문

개발 이야기/Swift

[SwiftUI] Lecture 8: Animation Demonstration

Teiresias 2022. 4. 21. 18:29

강의 보기 : Youtube :: Stanford 

rotation3DEffect

카드를 선택했을 때 fade 효과나 scale 같은 효과가 아닌, 카드를 뒤집는 효과를 주기 위해 Cardify의 ZStack에 rotation3DEffect 효과를 주었다. 뒤집는 효과를 주기 위해 axis의 y축에만 효과를 주었다. 카드는 정상적으로 뒤집을 수 있지만 다른 곳에서 문제가 발생했다. 카드를 뒤집기 시작하는 순간부터 카드가 미처 다 돌아가지 않았음에도  이모지가 서서히 나타나기 시작하는 문제가 발생했다. 이는 이모지를 보여주는 content를 opacity효과를 주면서 (isFaceUp ? 1:0)의 변화에 의존하기 때문이다.

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)
        }
        .rotation3DEffect(Angle.degrees(isFaceUp ? 0 : 180), axis: (0, 1, 0))
    }
    
    private struct DrawingConstants {
        static let cornerRadius: CGFloat = 10
        static let lineWidth: CGFloat = 2.5
    }
}

이를 해결하기 위해서 단순히 isFaceUp 변수에 의존하는 것이 아닌, 회전하는 각도를 받아 90도가 넘어갔을 때 보여주도록 하기 위해 rotation 변수를 선언했다. rotation 변수가 0 에서 180까지 변화되는 각도를 순차적으로 가져와야 하기 때문에 Cardify가 AnimatableModifier 프로토콜에 순응해서 animatableData 변수로 rotation을 애니메이션화 가능한 변수로 만들어주었다.

struct Cardify: AnimatableModifier {
    init(isFaceUp: Bool) {
        rotation = isFaceUp ? 0 : 180
    }
    
    var animatableData: Double {
        get { rotation }
        set { rotation = newValue }
    }
    
    var rotation: Double // in Degress
    
    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))
    }
    
    private struct DrawingConstants {
        static let cornerRadius: CGFloat = 10
        static let lineWidth: CGFloat = 3
        static let fontScale: CGFloat = 0.7
    }
}

AnimatableModifier

will be deprecated in a future version iOS: use animatable directly

AnimatableModifier는 더 이상 사용되지 않을 것으로, stackOverflow에 따르면 Animatablr, ViewModifier를 사용해야 한다고 함.

참조 :  [StackOverflow]

onAppear

카드가 처음 화면에 나타날 때와 짝을 찾아서 카드가 사라질 때 transition이 발생하는 경우 애니메이션 효과를 주기 위해 다음과 같이 설정을 했지만, 카드가 사라질 때는 의도대로 애니메이션 효과가 나타나지만, 게임을 시작하는 경우에는 효과가 등장하지 않는다. 이유는 cardView를 담고 있는 AspectVGrid가 container와 동시에 UI상에 나타나기 때문에 애니메이션 효과가 적용되지 않았던 것이다.

var gameBody: some View {
    AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
        if card.isMatched && !card.isFaceUp {
            Color.clear
        } else {
            CardView(card: card)
                .padding(4)
                .transition(AnyTransition.asymmetric(insertion: .scale, removal: .scale).animation(.easeIn(duration: 3)))  // 애니메이션 부분!
                .onTapGesture {
                    withAnimation {
                        game.choose(card)
                    }
                }
        }
    }
    .foregroundColor(CardConstants.color)
}

이를 해결하기 위해서는 container가 UI상에 나타난 뒤 각각의 cardView가 나타나야 한다. 그래서 이때 사용되는 것이 .onAppear이다. 변수 dealt와 메서드 deal을 선언해서 container가 등장한 후 추가해주게 되면 애니메이션 효과 적용이 가능하다. 이때, dealt 변수는 처음 카드가 등장한 후에는 불필요한 변수이기 때문에 @State로 선언한다.

@State private var dealt = Set<Int>()

private func deal(_ card: EmojiMemoryGame.Card) {
    dealt.insert(card.id)
}

private func isUndealt(_ card: EmojiMemoryGame.Card) -> Bool {
    !dealt.contains(card.id)
}

private func dealAnimation(for card: EmojiMemoryGame.Card) -> Animation {
    var delay = 0.0
    if let index = game.cards.firstIndex(where: { $0.id == card.id }) {
        delay = Double(index) * (CardConstants.totalDealDurtation / Double(game.cards.count))
    }
    return Animation.easeInOut(duration: CardConstants.dealDuration).delay(delay)
}

private func zIndex(of card: EmojiMemoryGame.Card) -> Double {
    -Double(game.cards.firstIndex(where: { $0.id == card.id }) ?? 0)
}

var gameBody: some View {
    AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
        if isUndealt(card) || card.isMatched && !card.isFaceUp {
            Color.clear
        } else {
            CardView(card: card)
                .matchedGeometryEffect(id: card.id, in: dealingNamespace)
                .padding(4)
                .transition(AnyTransition.asymmetric(insertion: .identity, removal: .scale))
                .zIndex(zIndex(of: card))
                .onTapGesture {
                    withAnimation {
                        game.choose(card)
                    }
                }
        }
    }
    .foregroundColor(CardConstants.color)
}

matchedGeometryEffect

카드를 하나의 덱처럼 표시해주고 tap 하게 되면 상단에 나타나게끔 해주는 효과를 주기 위해 .matchedGeometryEffect를 사용했다. 출발과 도착 위치에 있는 동일한 View에 효과를 주면 출발지에서 도착지로 이동한 것처럼 보이는 효과를 줄 수 있다. 이때 효과를 주는 변수에 @Namespace를 선언해주는데 공식 문서에 따르면 다음과 같이 설명하고 있다.

A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).
속성을 포함하는 개체의 영구 ID로 정의된 네임스페이스에 대한 액세스를 허용하는 동적 속성 유형이다.

간단히 말하면 객체의 정보를 ID와 함께 기억하는 래퍼로, 이 ID를 통해서 정보를 다른 개체와 공유할 수 있으며, 이를 통해 애니메이션 효과를 보다 자연스러운 효과를 줄 수 있게 된다.

[공식문서 @Namespace]

@Namespace private var dealingNamespace

var gameBody: some View {
    AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
        if isUndealt(card) || card.isMatched && !card.isFaceUp {
            Color.clear
        } else {
            CardView(card: card)
                .matchedGeometryEffect(id: card.id, in: dealingNamespace)
                .padding(4)
                .transition(AnyTransition.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)) { card in
            CardView(card: card)
                .matchedGeometryEffect(id: card.id, in: dealingNamespace)
                .transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
                .zIndex(zIndex(of: card))
        }
    }
    .frame(width: CardConstants.undealtWidth, height: CardConstants.undealtHeight)
    .foregroundColor(CardConstants.color)
    .onTapGesture {
        //"deal" cards
        for card in game.cards {
            withAnimation(dealAnimation(for: card)) {
                deal(card)
            }
        }
    }
}

restart

Restart 버튼을 추가해서 게임을 재시작을 하는 버튼을 만들면서 @State로 선언된 dealt 변수를 초기화해주어 애니메이션 효과를 나타낸다.

var restart: some View {
    Button("Restart") {
        withAnimation {
            dealt = []
            game.restart()
        }
    }
}

AnimatablePair

A pair of animatable values, which is itself animatable.
자체적으로 애니메이션 가능한 한 쌍의 애니메이션 가능한 값이다.

배경의 원이 시간에 따라 감소되는 애니메이션을 주기 위해서 시작 각도와 끝 각도를 animatableData로 선언해서 추가해준다.

[공식문서 AnimatablePair]

var animatableData: AnimatablePair<Double, Double> {
    get{
        AnimatablePair(startAngle.radians, endAngle.radians)
    }
    set{
        startAngle = Angle.radians(newValue.first)
        endAngle = Angle.radians(newValue.second)
    }
}

그리고 CardView로 돌아와 Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: (1-animateBonusRemaining)*360-90))을 통해 시작 위치와 끝 위치를 정해주면 애니메이션이 생성되게 된다.

 

그런데, 빌드해서 살펴보게 되면 모양이 줄어들긴 하지만 변화가 실시간 이루어지는 것이 아닌 카드를 뒤집을 때에 변하게 된다. 이는 Model에서 BonusRemaining을 실시간 관리가 되는 것이 아니라 호출될 때 해당 시점에 전체 시간 중 남은 시간의 비율을 알려주기 때문이다. 이를 해결하기 위해서 위에서 카드를 뒤집는 효과에서 각도를 추적하는 것과 비슷한 패턴을 적용하게 되는데, animateBonusRemaining 변수를 @State로 선언해서 실시간 변화를 추적하게 된다. 

 

하지만 카드가 뒤집히는 것과는 다르게 남은 시간이 있는 경우에만 줄어드는 원이 필요하기 때문에 If - else 문을 활용하여 각각의 Pie를 보여주게 되고, 우리는 시간이 남아있는 경우에만 효과를 주면 되기 때문에 시간이 남아있는 경우에 .onAppear 효과를 적용해준다. 하지만 두 개의 Pie에서 공통으로 적용해야 하는 .padding과 .opacity를 적용하기 위해서 if - else 조건문을 Group으로 묶어서 적용해주어 중복을 줄이게 된다.

 

 Group{
    if card.isConsumingBonusTime {
        Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: (1-animateBonusRemaining)*360-90))
            .onAppear{
                animateBonusRemaining = card.bonusRemaining
                withAnimation(.linear(duration: card.bonusTimeRemaining)) {
                    animateBonusRemaining = 0
                }
            }
    } else {
        Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: (1-card.bonusRemaining)*360-90))
    }
}
    .padding(5)
    .opacity(0.5)

이번 강의는 집중이 되지 않아 두 번 반복해서 듣느라 조금 오래 걸렸다.

두 번을 보면서 애니메이션 효과를 적용하는 과정과, 애니메이션 효과를 원하는 방식으로 적용하기 위해 View를 분리하는 과정들을 좀 더 알 수 있었는데.... 이게 내가 혼자 할 때도 적용을 할 수 있겠는지는 아직 모르겠다. SwiftUI로 시작한 ColorOfDay를 만들어가며 적용해보도록 노력해봐야 알 수 있을 것 같다.

 

요즘 Clean Code를 읽고 있다. 머리가 아프거나 집중이 안될 때 한 번씩 꺼내 읽고 있는데 아직 3장을 읽고 있지만 읽고 나서 내 코드들을 개선해 보일 수 있으면 좋겠다.