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강은 결국 다시 들었다... 다시 들으니까 전체적으로 애니메이션이 작동하는 과정이랑 왜 이렇게 코드를 구성했는지, 왜 이 부분에서 애니메이션 효과를 줬는지 더 잘 이해가 가긴 하는데 나중에 내가 혼자 코드를 짤 때도 잘 할 수 있을지는...맨날 모르겠대... 나약한 내 자신....