HP Trivia (3)
Custom Animations
지난 글에서는 AVAudioPlayer를 활용해 사운드를 재생하고, transition 애니메이션과 함께 청각적 몰입감을 더하는 방법을 구현했다.
이번 글에서는 SwiftUI에서 사용자 정의 애니메이션(Custom Animations)을 통해 보다 세밀하고 생동감 있는 시각 효과를 만들어보려 한다.
참고로 이전에 재생되던 음악은 꺼주는 게 좋으므로
playAudio()
호출은 주석 처리해두자.
버튼에 생명 불어넣기
게임 시작 버튼을 추가하고, Spacer()
2개를 추가해 수직 위치를 조절한다.
버튼은 scaleEffect
와 withAnimation(...repeatForever())
을 조합해 커졌다 작아지는 애니메이션 효과를 부여한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@State private var scalePlayButton = false
Spacer() // new
Spacer() // new
Button {
// Play a game
} label: {
Text("Play")
.font(.largeTitle)
.foregroundStyle(.white)
.padding(.vertical, 7)
.padding(.horizontal, 50)
.background(.brown)
.clipShape(.rect(cornerRadius: 7))
.shadow(radius: 5)
.scaleEffect(scalePlayButton ? 1.2 : 1)
.onAppear {
withAnimation(.easeInOut(duration: 1.3).repeatForever()) {
scalePlayButton.toggle()
}
}
}
핵심 정리
scaleEffect(...)
: 버튼의 크기를 확대/축소할 수 있음repeatForever()
: 애니메이션을 무한 반복@State
: 버튼 상태 변화 감지를 위해 사용
결과적으로 버튼은 지속적으로 1.2배 확대되었다가 원래 크기로 돌아가는 애니메이션을 반복하며 생동감 있는 UI를 제공한다.
버튼 이동 애니메이션
이전 글에서 구현한 텍스트 애니메이션처럼, 버튼도 위에서 아래로 등장하는 효과를 준다.
이를 위해 if animateViewsIn
조건 아래에 버튼을 넣고, transition(.offset(...))
을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
VStack {
if animateViewsIn {
Button {
// Play a game
} label: {
// 생략
}
.transition(.offset(y: geo.size.height / 3))
}
}
.animation(.easeInOut(duration: 0.7).delay(2), value: animateViewsIn)
핵심 정리
transition(.offset(...))
: 해당 뷰가 특정 위치에서 이동하며 나타나게 함.animation(..., value:)
: 값이 바뀔 때 지정된 애니메이션을 적용
버튼은 화면 하단 1/3 위치에서 위로 자연스럽게 올라오며 시각적인 등장 효과를 만든다.
transition ??
애니메이션 코드를 작성하다 보면 이런 의문이 생긴다.
위 두 뷰는 거의 동일한 View 계층 구조와 동일한 animation modifier를 사용하고 있다.
하지만 transition
의 방식은 서로 다르다.
- HP Trivia
- .transition(.move(edge: .top))
- 뷰가 상단에서부터 내려오는 방식
- Play 버튼
- .transition(.offset(y: geo.size.height / 3))
- 화면 아래 1/3 지점에서 올라오듯 등장
그렇다면 .move(edge: .bottom)
을 사용하지 않은 이유는 무엇일까?
.move(edge: .bottom)
의 문제점
아래 이미지를 보면 그 이유가 명확해진다.
버튼이 이미 화면에 노출된 상태에서 애니메이션이 시작된다.
즉, 전혀 자연스럽지 않다.
그래서 bottom이 어딘가에 대해 확인을 해보려고 마우스 포인터를 frame의 하단 부분에 고정하고 테스트를 진행 해본결과
여기서 edge: .bottom
은 버튼 프레임의 하단을 가리켰다.
Borderline으로 시각화
이전 글처럼
.border()
를 사용해 뷰의 경계를 확인해보자.
1
2
3
4
5
VStack {
// 생략
}
.border(.blue, width: 7) // new
.animation(.easeInOut(duration: 0.7).delay(2), value: animateViewsIn)
결과는 다음과 같다:
버튼의 경계는 매우 작으며, transition도 이 경계 기준으로 작동한다.
.transition(.offset(y: geo.size.height / 3))
이 코드는 뷰가 기기 화면 아래 1/3 지점에서 위로 이동하며 등장하는 효과를 낸다.
즉, 초기에는 화면 밖에 위치하다가 등장하는 방식이다.
더 명확하게 보기 위해 .offset(y: 180)
같은 고정값으로 실험해보면,
버튼이 화면 아래에서부터 올라오는 애니메이션이라는 것이 드러난다.
즉, offset(y:)은 뷰의 기준 위치에서 y방향으로 얼마나 벗어난 곳에서 시작할지를 지정한다.
이런 방식은 뷰가 자연스럽게 등장할 때 특히 유용하다.
결론
.move(edge:)
는 뷰 자체의 프레임 기준으로 transition이 작동한다.
따라서 뷰가 작을 경우 이미 보이는 상태에서 transition이 시작될 수 있어 부자연스럽다..offset(y:)
는 뷰의 시작 위치를 명확히 제어할 수 있기 때문에,
화면 밖에서 등장하는 자연스러운 애니메이션 구현이 가능하다.
Transition Challenge
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
struct ContentView: View {
// 생략
@State private var showInstructions = false // new
@State private var showSettings = false // new
@State private var playGame = false // new
var body: some View {
GeometryReader { geo in
ZStack {
//생략
VStack {
VStack {
//생략
}
.animation(.easeInOut(duration: 0.7).delay(2), value: animateViewsIn)
Spacer()
Spacer()
Spacer()
HStack { // new
Spacer() // new
Button { // new
showInstructions.toggle()
} label: {
Image(systemName: "info.circle.fill")
.font(.largeTitle)
.foregroundStyle(.white)
.shadow(radius: 5)
}
Spacer()
VStack {
// 생략
}
.animation(.easeInOut(duration: 0.7).delay(2), value: animateViewsIn)
Spacer() // new
Button { // new
showSettings.toggle()
} label: {
Image(systemName: "gearshape.fill")
.font(.largeTitle)
.foregroundStyle(.white)
.shadow(radius: 5)
}
Spacer()
}
.frame(width: geo.size.width)
Spacer()
}
}
.frame(width: geo.size.width, height: geo.size.height)
}
.ignoresSafeArea()
.onAppear {
animateViewsIn = true
//playAudio()
}
.sheet(isPresented: $showInstructions) { // new
InstructionsView()
}
}
private func playAudio() {
let sound = Bundle.main.path(forResource: "magic-in-the-air", ofType: "mp3")
audioPlayer = try! AVAudioPlayer(contentsOf: URL(filePath: sound!))
audioPlayer.numberOfLoops = -1
audioPlayer.play()
}
}
이렇게 양사이드에 instruction, settings 버튼을 만들었다.
물론 sheet
Modifier를 통해 instruction을 누르면 View가 올라온다.
이제 이 두개의 버튼들이 양쪽에서 튀어나오도록 transition을 사용하여 만들어 보자.
어차피 양쪽에서 나오는 매커니즘은 offset의 차이라 글이 길어지지 않게 하나만 예를 들어 작성한다.
- 양쪽에 If문과 그 IF를 감쌀 StackView 만들기
1
2
3
4
5
6
7
8
9
10
11
12
VStack {
if animateViewsIn {
Button {
showSettings.toggle()
} label: {
Image(systemName: "gearshape.fill")
.font(.largeTitle)
.foregroundStyle(.white)
.shadow(radius: 5)
}
}
}
- Modifier 달아주기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
VStack {
if animateViewsIn {
Button {
showSettings.toggle()
} label: {
Image(systemName: "gearshape.fill")
.font(.largeTitle)
.foregroundStyle(.white)
.shadow(radius: 5)
}
.transition(.offset(x: geo.size.width))
}
}
.animation(.easeInOut(duration: 0.7).delay(2), value: animateViewsIn)
이때 "info.circle.fill"
버튼은 .transition(.offset(x: -geo.size.width))
이렇게 -로 해주어야 반대방향에서 나온다.
실행하면 이렇게된다.
강의에선 geo.size.width / 4
이렇게 4로 나누어주었다.
그리고 .animation(.easeInOut(duration: 0.7).delay(2.7), value: animateViewsIn)
Delay 값도 2.7 로 해주었다.
수치의 차이지 메커니즘은 같다.
확실히 위에 개념정리를하고 해보니 혼자서도 별 문제없이 만들었다.
이런 흐름을 먼저 이해하고 적용해보면, 강의의 수치는 단순한 디테일 조정일 뿐이라는 걸 알 수 있다. 결국, 개념 정리가 실전에서 가장 강력한 무기다.
Opacity Transition
1
2
3
4
5
Spacer()
Spacer()
Spacer()
코드중에 버튼의 위치 조정을 위해 Spacer()를 3개를 채웠던 부분에서 중간 Spacer()를 지우고 Vstack으로 바꾸고 UI Component를 추가해준다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
VStack {
Text("Recent Scores")
.font(.title2)
Text("33")
Text("27")
Text("15")
}
.font(.title3)
.foregroundStyle(.white)
.padding(.horizontal)
.background(.black.opacity(0.7))
.clipShape(.rect(cornerRadius: 15))
그럼 이렇게 가운데에 Score board가 생긴다. 지금의 점수는 ui를 위해 mock data를 넣어둔 상태.
하지만 여기서 transition(.opacity)
사용하여 다른 효과를 주려고 한다.
1
2
3
4
5
6
7
8
9
10
11
VStack { // new
if animateViewsIn { // new
VStack {
//생략
}
// 생략
.transition(.opacity) // new
}
}
.animation(.linear(duration: 1).delay(4), value: animateViewsIn) // new
이젠 익숙한 추가방식
이렇게 해주면 opacity에 대해서도 아래 사진처럼 효과가 생긴다.
이렇듯 transition, animation을 함께 사용하면 조금더 풍성한 효과를 사용 할 수 있으니, 알아두도록 하자.