HP Trivia (9)
이번글은 좀 중간에 끊기가 애매해서 내용이 좀 길어질 것 같다.
GamePlayView
위의 사진과 같이 디자인을 해보도록 한다.
지난글에서 틀을 짠걸 기반으로 진행한다.
Controls
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Environment(Game.self) private var game
@Environment(\.dismiss) private var dismiss
@State private var animateViewsIn = false
var body: some View {
GeometryReader { geo in
ZStack {
// 생략
VStack {
// MARK: - Controls
HStack {
Button("End Game") {
game.endGame()
dismiss()
}
.buttonStyle(.borderedProminent)
.tint(.red.opacity(0.5))
Spacer()
Text("Score: \(game.gameScore)")
}
.padding()
.padding(.vertical, 30)
// MARK: - Question
VStack {
if animateViewsIn {
Text(game.currentQuestion.question)
.font(.custom("PartyLetPlain", size: 50))
.multilineTextAlignment(.center)
.padding()
.transition(.scale)
}
}
.animation(.easeInOut(duration: 2), value: animateViewsIn)
Spacer()
// MARK: - Hints
// MARK: - Answers
}
.frame(width: geo.size.width, height: geo.size.height)
}
.foregroundStyle(.white)
.frame(width: geo.size.width, height: geo.size.height)
// MARK: - Celebration
}
.ignoresSafeArea()
.onAppear {
game.startGame()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
animateViewsIn = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
playMusic()
}
}
}
이렇게 코드를 작성했다. 딱히 크게 언급할 부분은 없다.
굳이 하나를 한다면
1
2
3
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
animateViewsIn = true
}
여기도 0.5초뒤에 true로 하면서 0.5초뒤에 문제가 보이게 한다.
실행하면 이렇게 된다.
그리고 RecentScoresView에서 이전에는 하드코딩했던 최근 점수 UI를 게임 상태에서 직접 가져오도록 개선하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct RecentScoresView: View {
@Environment(Game.self) private var game // new
@Binding var animateViewsIn: Bool
var body: some View {
VStack {
if animateViewsIn {
VStack {
Text("Recent Scores")
.font(.title2)
Text("\(game.recentScores[0])")
Text("\(game.recentScores[1])")
Text("\(game.recentScores[2])")
}
// 생략
}
}
.animation(.linear(duration: 1).delay(4), value: animateViewsIn)
}
}
Hint
터치시, 힌트를 보여주는 기능을 구현한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@State private var revealHint = false
HStack {
VStack {
if animateViewsIn {
Image(systemName: "questionmark.app.fill")
.resizable()
.scaledToFit()
.frame(width: 100)
.foregroundStyle(.cyan)
.padding()
.transition(.offset(x: -geo.size.width / 2))
.phaseAnimator([false, true]) { content, phase in
content
.rotationEffect(.degrees(phase ? -13 : -17))
} animation: { _ in
.easeInOut(duration: 0.7)
}
.onTapGesture {
withAnimation(.easeOut(duration: 1)) {
revealHint = true
}
playFilpSound()
game.questionScore -= 1
}
.rotation3DEffect(.degrees(revealHint ? 1440 : 0), axis: (x: 0, y: 1, z: 0))
.scaleEffect(revealHint ? 5 : 1)
.offset(x: revealHint ? geo.size.width / 2 : 0)
.opacity(revealHint ? 0 : 1)
.overlay {
Text(game.currentQuestion.hint)
.padding(.leading, 20)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.opacity(revealHint ? 1 : 0)
.scaleEffect(revealHint ? 1.33 : 1)
}
}
}
.animation(.easeOut(duration: 1.5).delay(2), value: animateViewsIn)
Spacer()
}
.padding()
이건 완성된 코드부터 언급을 하고 하나하나 알아두면 좋을 부분에 대해서 짚고 넘어가려한다.
여기는 삼항연산자를 활용한 Modifier가 핵심이다.
onTapGesture
를 기준으로 정리를 하면 될듯하다.
윗부분은 크게 언급할게 없다,
이렇게 왼쪽에서 나와서 흔들흔들 거리는게 전부, 그리고 지난번에 다 했던내용이라 크게 언급할게 없다.
onTapGesture
의 아랫부분은 이미지를 탭한 이후에 대한 효과가 중심이다.
그중에서 rotation3DEffect
만 알아보면 될 것같다.
rotation3DEffect
- 뷰를 3D 공간에서 회전시키는 모디파이어이다.
- axis: (x: 0, y: 1, z: 0)은 Y축을 기준으로 회전함을 의미한다.
- axis는 방향만 중요하고, 크기는 무시된다.
- 예를 들어 (x: 0, y: 1, z: 0)과 (x: 0, y: 1000, z: 0)은 동일하게 Y축을 기준으로 회전한다.
- revealHint가 true가 되면 뷰가 Y축을 중심으로 1440도 회전하게 된다.
그리고 여기서 또 하나 포인트라면
Image 위에 overlay로 힌트 텍스트를 얹고, revealHint 값에 따라 투명도와 크기를 조절하여 자연스럽게 전환되도록 만들었다.
실행하면 다음과 같다.
Reveal Book
위의 Hint와 대부분 유사하다.
Hint는 텍스트를, Book은 이미지(책 표지)를 overlay로 보여주는 구조라는 점만 다르다.
단지 overlay 같은 부분이 좀 바뀌어서 그부분만 언급하고 패스
1
2
3
4
5
6
7
8
9
10
11
12
13
Image(systemName: "app.fill")
.resizable()
.scaledToFit()
.frame(width: 100)
.foregroundStyle(.cyan)
.overlay {
Image(systemName: "book.closed")
.resizable()
.scaledToFit()
.frame(width: 50)
.foregroundStyle(.black)
}
이렇게 책이미지를 겹치게 했다.
또한 삼항연산자에 의해 보이지는 않지만 책이미지 역시 Hint와 마찬가지로 투명도가 0이었다가 1로 바뀌게한다.
1
2
3
4
5
6
7
8
.overlay {
Image("hp\(game.currentQuestion.book)")
.resizable()
.scaledToFit()
.padding(.trailing, 20)
.opacity(revealBook ? 1 : 0)
.scaleEffect(revealBook ? 1.33 : 1)
}
그리고 요근래 한번씩 실수를 하는게 있는데 Image를 자동완성으로 하다보니 한번씩
Image(Image(systemName:))
이런식으로 이미지가 중복으로 들어가면서 빌드가 안되는 경우가 있는데, 이게 에러로 안잡힐때가 있다.
이미지 만들때 잘 확인을 해두도록 하자.
실행하면 이렇게된다.
확실히 transition을 공부하면서도 언급했지만, 확실하게 이해를 하고 넘어가니 이렇게 modifier가 많아져도 크게 어려운 부분이 없었다.
Answers
구조는 정답과 오답을 나누어 각기 다른 동작을 실행하도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@State private var tappedCorrectAnswer = false
LazyVGrid(columns: [GridItem(), GridItem()]) {
ForEach(game.answers, id: \.self) { answer in
if answer == game.currentQuestion.answer {
VStack {
if animateViewsIn {
Button {
tappedCorrectAnswer = true
playCorrectSound()
game.correct()
} label: {
Text(answer)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.padding(10)
.frame(width: geo.size.width / 2.15, height: 80)
.background(.green.opacity(0.5))
.clipShape(.rect(cornerRadius: 25))
}
.transition(.scale)
}
}
.animation(.easeOut(duration: 1).delay(1.5), value: animateViewsIn)
} else {
VStack {
if animateViewsIn {
Button {
playWrongSound()
game.questionScore -= 1
} label: {
// 생략
}
.transition(.scale)
}
}
.animation(.easeOut(duration: 1).delay(1.5), value: animateViewsIn)
}
}
}
다 이전에 했던 부분이라 크게 언급할 부분은 없어서 그냥 간단하게 정리만 하고 넘어간다.
- LazyVGrid를 사용해 2열로 버튼을 배치
- 정답과 오답을 조건문(if/else)으로 나누었다.
.transition(.scale) 활용
하여 밑에서 위로 날아오는 듯한 효과를 줌
Sensory Feedback
현재 Answer의 경우 소리로만 정답인지, 오답인지 알 수 있다.
이제는 이게 아니라 시각적으로도 보여지게 하자.
이것도 크게 언급할게 없어서 전체코드를 보고 밑에 간단하게 정리를 하는걸로.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@State private var wrongAnswersTapped: [String] = []
Button {
withAnimation(.easeOut(duration: 1)) {
wrongAnswersTapped.append(answer)
}
playWrongSound()
game.questionScore -= 1
} label: {
Text(answer)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.padding(10)
.frame(width: geo.size.width / 2.15, height: 80)
.background(wrongAnswersTapped.contains(answer) ? .red.opacity(0.5) : .green.opacity(0.5))
.clipShape(.rect(cornerRadius: 25))
.scaleEffect(wrongAnswersTapped.contains(answer) ? 0.8 : 1)
}
wrongAnswersTapped
오답을 담을 배열을 하나 만들어준다.- 오답을 다루는 if의 else에서 오답을 탭했을때 해당 배열에 선택한 오답을 담아준다.
- 이때 withAnimation을 통해 오답을 택했을때 애니메이션 효과를 준다.
- 삼항연산자를 통해 현재 보기들중 이미 탭을해서 배열에 담겨있다면
- 배경을 빨갛게 처리한다.
- 사이즈를 80%로 축소한다.
아래와 같이 오답을 탭하면 빨갛게 변하면서 사이즈가 축소된다.
그리고 여기에 위의 헤더처럼 Sensor 피드백을 주기위해
1
2
3
.transition(.scale)
.sensoryFeedback(.error, trigger: wrongAnswersTapped)
.disabled(wrongAnswersTapped.contains(answer))
sensoryFeedback
Modifier를 사용한다. (다만 시뮬레이터에서는 당연히 확인불가, 실기기에서 테스트를 해야한다!)
HealthKit을 공부하면서 해본적이 있으므로 이전글 참고
또한 중복 클릭 방지를 위해 한번 누르고 배열에 담긴이후에는 disabled를 통해 추가 클릭을 못하게 한다.
Celebration
1
2
3
4
5
6
7
8
9
10
11
VStack {
if tappedCorrectAnswer {
Text(game.currentQuestion.answer)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.padding(10)
.frame(width: geo.size.width / 2.15, height: 80)
.background(.green.opacity(0.5))
.clipShape(.rect(cornerRadius: 25))
}
}
이렇게 해주었다, 사실 Text 부분은 정답의 코드를 그냥 그대로 가져오고 Text 내부만 바꿔 주었다.
그럼 이렇게 나온다.
이제 조금 더 효과를 주도록 한다.
Namespace를 활용한 애니메이션 효과 처리
여기서부터는 하나하나 좀 디테일하게 보면서 넘어간다.
우선 이전에도 사용했던 namespace를 활용한다.
사용 이유는 사용자에게 마치 하나의 뷰가 이동하는 것처럼 자연스러운 애니메이션을 보여주기 위해, namespace를 사용해 두 뷰를 연결한다.
@Namespace private var namespace
우선 만들어주고
먼저 celebration 쪽에
1
2
3
4
5
6
7
8
9
10
11
12
13
VStack {
if tappedCorrectAnswer {
Text(game.currentQuestion.answer)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.padding(10)
.frame(width: geo.size.width / 2.15, height: 80)
.background(.green.opacity(0.5))
.clipShape(.rect(cornerRadius: 25))
.scaleEffect(2) // new
.matchedGeometryEffect(id: 1, in: namespace) // new
}
}
두개의 모디파이어를 추가해주었다. 이러면 아까처럼 위로 정답이 보이지만, 차이점이라면 이전과 달리 확대되면서 보이게 된다.
여기서 우리가 처음 보는 Modifier가 있는데 바로 matchedGeometryEffect
이다.
Docs에서는 같은 ID와 namespace를 공유하는 두 개 이상의 뷰 사이에서 위치, 크기 등의 속성을 부드럽게 애니메이션으로 전환시켜주는 효과라고 적혀있다.
matchedGeometryEffect Docs는 여기
이제 matchedGeometryEffect
Modifier를 정답쪽에도 해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if answer == game.currentQuestion.answer {
VStack {
if animateViewsIn {
Button {
// 생략
} label: {
Text(answer)
// 생략
.matchedGeometryEffect(id: 1, in: namespace) // new
}
.transition(.scale)
}
}
.animation(.easeOut(duration: 1).delay(1.5), value: animateViewsIn)
}
이제는 정답을 눌렀을때 보기에 있는 정답도 중앙으로 이동하는걸 볼 수 있다.
그렇다면 왜 이렇게 중첩이 되어서 보여질까?
그 이유는
- 기존의 정답 버튼은 animateViewsIn == true일 때 화면에 보여지고 있음.
- 새로운 정답 텍스트는 tappedCorrectAnswer == true일 때 화면에 나타남.
- 하지만 정답을 눌렀을 때 tappedCorrectAnswer만 true로 바뀌고, animateViewsIn은 여전히 true이기 때문에 두 조건이 모두 만족하여 버튼과 텍스트가 동시에 화면에 존재하게 됨.
- 이로 인해 정답을 눌렀을 때 버튼이 사라지지 않고, 새로운 텍스트와 중첩되어 보이는 현상이 발생함.
정답 중첩 문제 해결
중첩 문제를 해결하고, 애니메이션 전환을 보다 자연스럽게 보이도록 하기 위해 transition
과 조건을 함께 사용하도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if animateViewsIn {
if !tappedCorrectAnswer { // new
Button {
withAnimation(.easeOut(duration: 1)) { // new
tappedCorrectAnswer = true
}
playCorrectSound()
game.correct()
} label: {
// 생략
}
.transition(.scale)
}
}
이렇게 정답을 탭하지 않았을때의 조건을 하나 더 만들어 준다.
그럼 이렇게 탭을 했기에 tappedCorrectAnswer가 true가 되면서 버튼이 사라지게 된다.
지금도 보면 사라질때 점점 작아지면서 사라지는걸 알 수 있는데, 사라지는 것을 조금더 깔끔하게 하기 위해서 transtion을 바꾼다.
.transition(.asymmetric(insertion: .scale, removal: .scale(scale: 15)))
- insertion: 정답이 중앙에 나타날 때는 기본적인 scale 사용
- removal: 기존 버튼이 사라질 때는 점점 커짐
이제 실행해보면
점점 커지면서 사라지는걸 알 수 있다. 하지만 초록색이 화면에 꽉차다가 갑자기 사라지는 효과가 별로다.
그래서 .transition(.asymmetric(insertion: .scale, removal: .scale(scale: 15).combined(with: .opacity)))
combine을 사용해준다.
이젠 점점 투명해지면서 사라지는 걸 알 수 있다.
Celebration Screen
글이 상당히 길어졌지만, 통일성을 위해 여기에 작성한다.
투명도 조정 및 비활성화
우선 VStack을 또 만들어주는데
Question ~ Answers 까지 감싸준다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
VStack { // new
// MARK: - Question
VStack {
// 생략
}
.animation(.easeInOut(duration: 2), value: animateViewsIn)
Spacer()
// MARK: - Hints
HStack {
// 생략
}
.padding()
// MARK: - Answers
LazyVGrid(columns: [GridItem(), GridItem()]) {
// 생략
}
Spacer()
}
.disabled(tappedCorrectAnswer) // new
.opacity(tappedCorrectAnswer ? 0.1 : 1) // new
해당 범위가 정답을 눌렀을때 더이상 활성화되지 않게 disabled
Modifier를 붙여주고 이때 투명도도 바꿔 준다.
이제 이렇게 투명해지고 클릭을 해도 반응이 없다.
Text 추가
Celebration 화면에 맞췄을때의 축하 문구가 있으면 좋기에 Text를 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
VStack {
VStack {
if tappedCorrectAnswer {
Text("Brilliant")
.font(.custom("PartyLetPlain", size: 100))
.transition(.scale.combined(with: .offset(y: -geo.size.height / 2)))
}
}
.animation(.easeInOut(duration: 1).delay(1), value: tappedCorrectAnswer)
if tappedCorrectAnswer {
// 생략
}
}
이전에 다 언급했기에 크게 언급할건 없다.
위에서 내려오며 scale효과에 따라 점점 커지는데 combine을 통해 위에서 내려오면서 점점 커지게하도록 하였다.
여기에 현재 스코어도 보이게끔 추가한다. -> 물론 우측상단에 보이지만, 그래도 맞췄을때 점수도 같이 보이게하면 더 좋기에 해본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// MARK: - Celebration
VStack {
VStack {
if tappedCorrectAnswer {
Text("\(game.questionScore)")
.font(.largeTitle)
.padding(.top, 50)
.transition(.offset(y: -geo.size.height / 4))
}
}
.animation(.easeInOut(duration: 1).delay(2), value: tappedCorrectAnswer)
//생략
}
“brilliant” 뜨는 문구의 코드와 별반 차이가 없다.
실행하면 위에서 스코어가 내려온다.
버튼 추가
이제 다음 문제를 풀어야하기에 다음문제로 넘어가는 버튼을 만들도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
VStack {
if tappedCorrectAnswer {
Button("Next Level>") {
// action
}
.font(.largeTitle)
.buttonStyle(.borderedProminent)
.tint(.blue.opacity(0.5))
.transition(.offset(y: geo.size.height / 3))
}
}
.animation(.easeInOut(duration: 2.7).delay(2.7), value: tappedCorrectAnswer)
그리고 위치 조정을 위해 Spacer()를 군데군데 넣어준다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
VStack {
Spacer()
VStack {
// 생략
}
.animation(.easeInOut(duration: 1).delay(2), value: tappedCorrectAnswer)
Spacer()
VStack {
// 생략
}
.animation(.easeInOut(duration: 1).delay(1), value: tappedCorrectAnswer)
Spacer()
if tappedCorrectAnswer {
// 생략
}
Spacer()
Spacer()
VStack {
// 생략
}
.animation(.easeInOut(duration: 2.7).delay(2.7), value: tappedCorrectAnswer)
Spacer()
Spacer()
}
그럼 아래와 같은 결과가 나온다.