포스트

HP Trivia (10)

Next Level 버튼 애니메이션 추가

시작하기 앞서 Next Level 버튼에 phaeAnimator를 사용하여 약간의 생동감을 부여해주었다.

1
2
3
4
.phaseAnimator([false, true]) { content, phase in
    content
        .scaleEffect(phase ? 1.2 : 1)
}

Image

이렇게 버튼이 바운스바운스 하게된다.

1
2
3
4
5
6
.phaseAnimator([false, true]) { content, phase in
    content
        .scaleEffect(phase ? 1.2 : 1)
} animation: { _ in
        .easeInOut(duration: 1.3)
}

easeInOut을 추가해주었지만 크게 변화를 잘 모르겠다. 올라오는 속도의 차이는 있긴하다.

Image

Score

@State private var movePointsToScore = false 우선 변수를 하나 만들어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
if tappedCorrectAnswer {
    Text("\(game.questionScore)")
        .font(.largeTitle)
        .padding(.top, 50)
        .transition(.offset(y: -geo.size.height / 4))
        .offset( // new 
            x: movePointsToScore ? geo.size.width / 2.3 : 0,
            y: movePointsToScore ? -geo.size.height / 13 : 0)
        .opacity(movePointsToScore ? 0 : 1) // new
        .onAppear { // new
            movePointsToScore = true
        }
}

true일 때 나타나는 위치를 설정해준다.

Image

실행해보니 보이지 않는다? 이유는 opacity를 0으로 해두었기에 보이지 않는것

그래서 withAnimation을 추가해준다.

1
2
3
4
5
.onAppear {
    withAnimation(.easeInOut(duration: 1).delay(3)) {
        movePointsToScore = true                                   
    }
}

Image

그러면 이제 3초 동안 Score:가 있는곳으로 숫자가 움직이면서 3초 뒤에 완전히 투명해지면서 사라지는걸 알 수 있다.

지금은 점수가 미리 업데이트가 되면서 애니메이션이 작동한다.

이걸 점수 업데이트를 늦추면서 애니메이션으로 점수쪽으로 간 이후에 스코어가 변하도록 조정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Game
func correct() {
    answeredQuestions.append(currentQuestion.id)
    
    withAnimation {
        gameScore += questionScore            
    }
}

// MARK: - Answers
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
    game.correct()
}

이렇게 correct 함수에도 애니메이션효과를 주도록 한다. (아무것도 없으면 .default가 작동)

Default는 부드럽고 반동감 있는 스프링 효과를 자동으로 적용한다. Default Docs 참고

Image

이젠 숫자가 애니메이션효과랑 겹치면서 바뀌기에 마치 해당 숫자가 이동한것과 같은 효과를 준다.

Next Level

정답을 맞췃을때 다음 문제로 넘어가기 위한 작업을 한다.

1
2
3
Button("Next Level>") {
    game.newQuestion()
}

단순히 이렇게만 작성하면 되지않나? 라는 생각이 들 수 있기에 위와 같이 코드를 한줄 추가하고 실행을 해보면 왜 위의 코드 한줄만 추가해서는 안되는지 알 수 있다.

Image

이렇게 현재 상황이 그대로 유지된 상태에서 정답과 문제가 리셋이 되어버린다.

초기화면으로 돌아가지않고 현재 UI에서 바뀌어 버리는 문제가 발생

1
2
3
4
5
6
7
8
9
Button("Next Level>") {
    animateViewsIn = false
    revealHint = false
    revealBook = false
    tappedCorrectAnswer = false
    wrongAnswersTapped = []
    movePointsToScore = false
    game.newQuestion()
}

다시 true가 되었던 값들을 false로 바꿔준다.

이제 다시 실행해보면?

Image

우리 예상과는 반대로 오히려 화면이 역으로 사라져버린다?

그이유는 바로 animateViewsIn에 있다.

처음 GamePlayView가 실행되면 onAppear를 통해 animateViewsIn = true가 되면서 우리가 구현한 UI가 나타난다.

하지만 위의 수정한 코드대로라면 false 이후 다시 true로 돌아올수가 없다. 왜냐면 onAppear는 처음에 View가 렌더링될때 트리거되기 때문이다.

즉 우리는 다시 animateViewsIn = true를 명시해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
Button("Next Level>") {
    animateViewsIn = false
    revealHint = false
    revealBook = false
    tappedCorrectAnswer = false
    wrongAnswersTapped = []
    movePointsToScore = false
    game.newQuestion()
    
    animateViewsIn = true
}

문제가 새롭게 생성되고 바로 true를 통해 다시 UI가 보여지도록 한다.

Image

작동이 잘 되는것 처럼 보이지만? 바로 Celebration과 관련된 UI가 늦게 사라지는 문제가 있다.

그래서 약간의 딜레이를 주기로 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
Button("Next Level>") {
    animateViewsIn = false
    revealHint = false
    revealBook = false
    tappedCorrectAnswer = false
    wrongAnswersTapped = []
    movePointsToScore = false
    game.newQuestion()
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        animateViewsIn = true
    }
}

Image

아까보다는 훨씬 나아지긴 했다.

animation Modifier가 있는 부분을 삼항연산자로 하여 애니메이션 시간을 다르게 설정한다. .animation(.easeOut(duration: animateViewsIn ? 1.5 : 0).delay(animateViewsIn ? 2 : 0), value: animateViewsIn)

그리고 Celebraion 쪽은 조건이 다르기에 Brilliant와 버튼쪽에 .animation(.easeInOut(duration: tappedCorrectAnswer ? 1 : 0).delay(tappedCorrectAnswer ? 1 : 0), value: tappedCorrectAnswer) 이런식으로 해준다.

그러면 이제 다음 화면을 하더라도 첫화면의 느낌 그대로 보여지게 된다.

Image

Persist Scores

이제 게임을한 스코어를 저장해보려한다.

우선 저장경로를 만들어 준다. let savePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appending(path: "RecentScores")

이것만 간단하게 정리를 해보았다.

savePath 경로 설정 설명

let savePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appending(path: "RecentScores")

개념 요약

  • 이 코드는 앱의 도큐먼트 디렉토리에 "RecentScores"라는 파일 경로를 만들어 저장용으로 사용하기 위한 목적이다.
  • 즉, 사용자의 데이터를 안전하게 저장할 수 있는 앱 전용 경로를 생성하는 것이다.

구성 요소 설명

  • FileManager.default: 파일 시스템 접근을 위한 기본 인터페이스
  • .urls(for: .documentDirectory, in: .userDomainMask): 현재 사용자의 도큐먼트 폴더 경로를 배열로 반환
  • [0]: 일반적으로 하나만 반환되기 때문에 첫 번째 항목 사용
  • .appending(path: "RecentScores"): 해당 경로에 "RecentScores"라는 파일명을 덧붙임

사용 이유

  • iOS는 샌드박스 구조이므로 임의의 디렉토리에 접근할 수 없다.
  • Apple은 사용자 데이터를 저장할 때 정해진 영역을 사용하라고 권장하며, 그 중 하나가 .documentDirectory이다.
  • 이 디렉토리는 앱이 삭제되면 함께 삭제되며, 사용자 데이터를 보관하기 적절한 위치이다.

파일명 주의 사항

  • "RecentScores"는 디렉토리가 아닌 파일 이름이다.
  • 확장자가 생략되어 있지만 내부적으로는 JSON이나 Data 형태로 저장이 가능하다.

정리

이 경로는 앱 내에서 영구적으로 데이터를 저장하고 불러오기 위한 표준적인 방법이다.
특히 사용자 맞춤 정보나 게임 점수, 설정 값 등을 로컬에 저장할 때 자주 사용되는 패턴이다.


다시 돌아와서 그리고 저장, 로드 하는 함수를 구현해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func saveScores() {
    do {
        let data = try JSONEncoder().encode(recentScores)
        try data.write(to: savePath)
    } catch {
        print("Unable to save data: \(error)")
    }
}

func loadScores() {
    do {
        let data = try Data(contentsOf: savePath)
        recentScores = try JSONDecoder().decode([Int].self, from: data)
    } catch {
        recentScores = [0, 0, 0]
    }
}

loadScores에서 catch에서 [0, 0, 0]을 넣어주는 이유는, 처음에 앱이 생성 되었을경우엔 savePath가 존재하지 않으므로 load시 바로 catch 구문이 실행된다. 그렇기에 초기값을 넣어주는것이다.

그리고 게임이 끝나면 스코어를 저장해줘야하므로 saveScores를 endgame()에 추가해준다.

1
2
3
4
5
6
7
8
9
10
    func endGame() {
        recentScores[2] = recentScores[1]
        recentScores[1] = recentScores[0]
        recentScores[0] = gameScore
        saveScores() // new
        
        gameScore = 0
        activeQuestions = []
        answeredQuestions = []
    }

현재 App을 보면

1
2
3
4
5
6
7
8
9
10
struct HPTriviaApp: App {
    private var game = Game()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(game)
        }
    }
}

App에서 Game을 Instance화 한다.

즉 그말은 앱이 실행되자마자 바로 객체를 만든다는것.

이걸 활용해서 객체가 만들어질때 바로 스코어를 로드하도록 한다.

init()을 사용하겠다는것

1
2
3
init() {
    loadScores()
}

그럼 이제 앱이 실행될때마다 자동으로 점수를 가져온다.

이후 강의는 인앱결제와 관련이 되어있기에 이후 Developer Membership을 다시 구독하면 작성하는걸로…

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.