intent function
에 주로 적용!isFaceUp
이 바뀌는 것을 포착할 수 있고 카드 형태가 구현된 곳에서 카드를 뒤집는 애니메이션을 추가해야한다고 생각해서 Cardify
의 ZStack
에 카드를 3d로 뒤집어 주는 애니메이션을 추가했는데 문제가 많았다..content.opacity(isFaceUp ? 1 : 0)
부분에 의해 이모지 자체는 언제나 존재하고 있으므로 isFaceUp
변수의 변화에 애니메이션 효과가 적용되기 때문이다isFaceUp
변수가 변화하면서 if isFaceUp - else
에 따라 UI 상의 View
가 나타났다가 사라지면서 이러한 appearance and disappearance
에 애니메이션 효과가 적용되기 때문struct Cardify: ViewModifier {
var isFaceUp: Bool
func body(content: Content) -> some View {
ZStack {
let shape = RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
// if-else -> 색깔 문제의 원인
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
변수의 변화에 따라 View
를 변화시키는 게 아니라 회전 각도 에 따라 View
가 바뀌도록 해야하므로 rotation 변수
를 선언했다!rotation 변수
가 단순히 0 혹은 180 이라는 양분된 상태로 스위칭하는 것이 아니라 단계적으로 0에서 180까지 변화되도록 해야 실제로 각도가 순차적으로 변화함에 따라 해당 상태에 맞는 View
가 렌더링 될 수 있기 때문에 Cardify
가 AnimatableModifier
프로토콜에 순응하도록 해서 animatableData
변수로 rotation
을 애니메이션화 가능한 변수로 만들어주어야 한다!struct Cardify: AnimatableModifier {
init(isFaceUp: Bool) {
rotation = isFaceUp ? 0 : 180
}
var rotation: Double = 0
var animatableData: Double {
get { rotation }
set { rotation = newValue }
}
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))
}
게임 시작 시 카드가 처음 화면에 나타날 때와 짝을 찾아서 카드가 사라질 때, 즉 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)
}
이를 해결하기 위해서는 컨테이너가 먼저 UI 상에 나타난 뒤에 각각의 카드뷰가 등장해야하는데 이때 .onAppear
를 활용할 수 있다!
즉, dealt 변수
와 deal 메서드
를 선언해서 컨테이너가 등장한 뒤에 카드를 나눠주도록 하면 골든룰을 충족시킬 수 있는 것
dealt 변수
는 처음에 카드를 등장시킨 뒤에는 불필요한 일시적인 변화를 나타내는 변수이므로 @State
로 선언한다struct EmojiMemoryGameView: View {
@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)
}
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)
.padding(4)
.transition(AnyTransition.asymmetric(insertion: .scale, removal: .scale).animation(.easeIn(duration: 3)))
.onTapGesture {
withAnimation {
game.choose(card)
}
}
}
}
.onAppear {
withAnimation { // 굳이 필요없는 것 같기도..?
for card in game.cards {
deal(card)
}
}
}
.foregroundColor(CardConstants.color)
}
}
container
에서 다른 container
로 특정 View
가 이동하는 것처럼 보이게 하고 싶을 때 .matchedGeometryEffect(id: card.id, in: someNameSpace)
를 출발과 도착 위치에 있는 동일한 View
에 붙여주면, 출발지에서 도착지로 이동한 것처럼 보이게 할 수 있다!struct EmojiMemoryGameView: View {
...
@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)
...
}
}
}
var deckBody: some View {
ZStack {
ForEach(game.cards.filter(isUndealt)) { card in
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace) // 출발
...
}
}
...
}
}
Cardview
의 body 변수
에 있는 이모지 텍스트에 패딩을 빼먹은 걸 발견했고...설마했는데 걔가 원인이 맞았다... struct CardView: View {
...
var body: some View {
GeometryReader { geometry in
ZStack {
Pie(startAngle: Angle(degrees: 0 - 90), endAngle: Angle(degrees: 110 - 90))
.padding(DrawingConstants.contentPadding)
.opacity(0.4)
Text(card.content)
.padding(DrawingConstants.contentPadding) // 범인...
...
}
.cardify(isFaceUp: card.isFaceUp)
}
}
}
struct EmojiMemoryGameView: View {
...
var body: some View {
VStack {
gameBody
deckBody
HStack {
restar
Spacer()
shuffle
}
.padding(.horizontal)
}
}
}
ZStack
에 넣어주면 불필요한 공간 차지를 막을 수 있다 var body: some View {
ZStack(alignment: .bottom) {
VStack {
gameBody
HStack {
restart
Spacer()
shuffle
}
.padding(.horizontal)
}
deckBody // ZStack 으로 관리
}
.padding()
}
restart
버튼을 누를 때마다 @State
변수인 dealt
를 초기화해줘야 애니메이션 효과를 나타낼 수 있다 var restart: some View {
Button("Restart") {
withAnimation {
dealt = [] // 비워주기!
game.restart()
}
}
}
Pie 구조체
에 시작 각도와 끝 각도를 animatableData
로 선언하는 부분을 추가한다struct Pie: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise = false
// 요기 추가
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-card.bonusRemaining)*360-90))
와 같이 bonusRemaining
에 따라 파이 모양이 줄어들게 만들었지만... Model
에서 bonusRemaining
변수를 실시간으로 관리하는 게 아니라 단지 호출하면 해당 시점에서 전체 보너스 시간 중 남은 시간의 비율을 알려주는 것이기 때문이다..! 따라서 애니메이션화할 변화가 없다@State private var animatedBonusRemaining
변수를 선언해서 실시간으로 남은 시간의 변화를 추적한다animatedBonusRemaining
을 이용해 각도를 계산하고 싶으므로 if-else
로 분기해준다. 이렇게 하면 조건에 따라 if문
의 Pie
와 else문
의 Pie
가 번갈아 나타나고 사라진다if 문
의 Pie
에 .onAppear{ }
를 붙여준다!animatedBonusRemaining = card.bonusRemaining
에서 시작해서 0(즉, 보너스 시간이 남지 않을 때)으로 변하는 과정을 애니메이션화 하고 싶은 것이므로 아래와 같이 코드를 작성할 수 있다duration: card.bonusTimeRemaining
으로 기간을 설정해준다...!.onAppear { animatedBonusRemaining = card.bonusRemaining withAnimation(.linear(duration: card.bonusTimeRemaining)) { animatedBonusRemaining = 0 } }
struct CardView: View {
let card: EmojiMemoryGame.Card
@State private var animatedBonusRemaining: Double = 0
var body: some View {
GeometryReader { geometry in
ZStack {
Group {
if card.isConsumingBonusTime {
Pie(startAngle: Angle(degrees: 0-90), endAngle:
Angle(degrees: (1-animatedBonusRemaining)*360-90))
// 애니메이션!!!
.onAppear {
animatedBonusRemaining = card.bonusRemaining
withAnimation(.linear(duration: card.bonusTimeRemaining)) {
animatedBonusRemaining = 0
}
}
} else {
Pie(startAngle: Angle(degrees: 0-90), endAngle: Angle(degrees: 1-card.bonusRemaining*360-90))
}
}
.padding(5)
.opacity(0.5)
}
}
}
}
withAnimation{ }
으로 감싸면 size
와 frame
에 의해 containter
내부의 View
들의 포지션 변화에도 애니메이션 효과가 나타나는 게 신기했다
강의 다시 듣는 거 정말 * 100 싫어하는데 7, 8강은 결국 다시 들었다... 다시 들으니까 전체적으로 애니메이션이 작동하는 과정이랑 왜 이렇게 코드를 구성했는지, 왜 이 부분에서 애니메이션 효과를 줬는지 더 잘 이해가 가긴 하는데 나중에 내가 혼자 코드를 짤 때도 잘 할 수 있을지는...맨날 모르겠대... 나약한 내 자신....