포스트

HP Trivia (5)

Books & Questions

이번 강의에서는 HP Trivia 앱의 Settings 화면(도서 선택 화면) 구성을 준비하기 위한 기초 작업을 시작한다.

사용자가 퀴즈에서 출제될 도서를 선택할 수 있도록 UI를 만들 예정인데, 실제 뷰(View)를 구현하기 전에 이를 뒷받침할 데이터 모델(Book)과 관련 클래스부터 정리할 필요가 있다.

Book Modeling

이제 Book 모델링 부터 시작한다.

하도 많이 해봐서 크게 언급할만한건 없을듯하다.

1
2
3
4
5
6
7
8
9
struct Book: Identifiable {
    let id: Int
    let image: String
    let questions: [Question]
    var status: BookStatus
}
enum BookStatus {
        case active, inactive, locked
    }

이렇게 모델링을 해주었다.

문제관련 클래스 만들기

BookQuestions라는 파일을 만들고, 이곳에 JSON 디코딩과 Book 데이터 구성을 담당하는 클래스를 작성한다.

BookQuestions 클래스는 @Observable로 선언되어 있어 뷰에서 상태 변화를 추적할 수 있게 설계되었다.

현재 강의에서는 상태 변경이 일어나지 않지만, 인앱 결제를 통해 잠금 상태를 해제했을 경우엔 books 배열이 동적으로 변경될 가능성이 있다.

이러한 흐름을 고려했을 때, @Observable 선언은 향후 기능 확장—특히 인앱 결제 기능을 염두에 둔 설계로 볼 수 있을것 같다.

JSON 로드 및 디코딩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private func decodeQuestions() -> [Question] {
    var decodedQuestions: [Question] = []
    
    if let url = Bundle.main.url(forResource: "trivia", withExtension: "json") {
        do {
            let data = try Data(contentsOf: url)
            decodedQuestions = try JSONDecoder().decode([Question].self, from: data)
        } catch {
            print("Erorr decoding JSON data: \(error)")
        }
    }
    
    return decodedQuestions
}

여기는 크게 언급할게 없다.

퀴즈 정리 (책별 분류)

1
2
3
4
5
6
7
8
9
private func organizeQuestions(_ questions: [Question]) -> [[Question]] {
    var organizedQuestions: [[Question]] = [[], [], [], [], [], [], [], []]
    
    for question in questions {
        organizedQuestions[question.book].append(question)
    }
    
    return organizedQuestions
}

book의 번호가 같은 것끼리 문제 배열에 넣어준다.
책별로 문제를 분류하기 위해 [[Question]]처럼 중첩 배열 구조를 사용하였다. 이 구조는 책 번호와 질문 리스트를 효과적으로 연결해준다.

이때, 배열의 인덱스를 책 번호와 맞추기 위해 index 0은 사용하지 않고 비워둔 채, index 1~7에 각각의 책 문제를 넣도록 구성했다.

Book 객체 생성 및 상태 부여

1
2
3
4
5
6
7
8
9
private func populateBooks(with questions: [[Question]]) {
    books.append(Book(id: 1, image: "hp1", questions: questions[1], status: .active))
    books.append(Book(id: 2, image: "hp2", questions: questions[2], status: .active))
    books.append(Book(id: 3, image: "hp3", questions: questions[3], status: .inactive))
    books.append(Book(id: 4, image: "hp4", questions: questions[4], status: .locked))
    books.append(Book(id: 5, image: "hp5", questions: questions[5], status: .locked))
    books.append(Book(id: 6, image: "hp6", questions: questions[6], status: .locked))
    books.append(Book(id: 7, image: "hp7", questions: questions[7], status: .locked))
}

이후 books라는 배열에 담을때는 처음에 모델링한 Book에 정보를 담는다. 이때 questions는 위에서 책별로 분류가 된 문제가 담긴다.

총 7권의 책(Book)을 생성하여 books 배열에 추가하였고 그중 1, 2권은 active, 3권은 inactive, 4~7권은 locked 상태로 설정했다. (초기 상태는 인앱 결제 구조를 고려하여 설정되었다. 1,2권은 기본 활성, 3권은 비활성 예시, 4~7권은 구매 전 잠금 상태로 구성한다.)

초기화

1
2
3
4
5
6
7
init() {
    let decodedQuestions = decodeQuestions()
    // Organize Questions
    let organizedQuestions = organizeQuestions(decodedQuestions)
    // Populate Books
    populateBooks(with: organizedQuestions)
}

클래스가 인스턴스화되면 init()을 통해 자동으로 JSON을 디코딩하고, 문제를 정리한 뒤 books 배열을 구성하게한다.

SelectBooks

책을 선택하는 UI를 디자인한다.
우선 InstructionsView의 코드를 복사해서 SelectBooksView에 붙여 넣어 기본 틀을 잡는다.

기본 UI 구성

지금은 전체적인 구조만 설정해 둔다.

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
ZStack {
    Image(.parchment)
        .resizable()
        .ignoresSafeArea()
        .background(.brown)
    
    VStack {
        Text("Which books would you like to see questions from?")
            .font(.title)
            .multilineTextAlignment(.center)
            .padding()
        
        ScrollView {
            LazyVGrid(columns: [GridItem(), GridItem()]) {
                // 책 카드 UI가 들어갈 예정
            }
            .padding()
        }
        
        Button("Done") {
            dismiss()
        }
        .font(.largeTitle)
        .padding()
        .buttonStyle(.borderedProminent)
        .tint(.brown.mix(with: .black, by: 0.2))
        .foregroundStyle(.white)
    }
    .foregroundStyle(.black)
}

이렇게 기본 UI 틀만 먼저 구성해 둔다.

Game ViewModel 생성 및 전역 공유 설정

BookQuestions의 데이터를 사용하기 위해 Game이라는 뷰모델 클래스를 만든다.

1
2
3
4
@Observable
class Game {
    var bookQuestions = BookQuestions()
}

이 뷰모델은 이후 앱의 전체 게임 로직을 담당하게 된다. 지금은 bookQuestions 프로퍼티만 포함한 상태다.

Custom Environment 설정 (의존성 주입 방식)

App 진입점인 HPTriviaApp.swift 파일에서 Game 인스턴스를 생성하고, 전역으로 주입한다.

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

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

이렇게 하면 앱 전역에서 Game 인스턴스를 동일하게 공유할 수 있다.
이는 SwiftUI에서 제공하는 환경 기반 의존성 주입 방식이며, 이전 의존성 주입 정리글에서도 다룬 내용이다.

SelectBooksView에서 Game 인스턴스 사용하기

기존처럼 private var game = Game() 형태로 새로운 인스턴스를 만들면 뷰마다 독립된 상태가 생성되어 상태 공유가 되지 않는다.

그래서 이전에는 @EnvironmentObject.environmentObject() modifier를 함께 사용해 전역 상태를 공유했었다.

이번에는 @Environment(Game.self)를 사용해, App에서 주입한 Game 인스턴스를 참조한다.

@Environment(Game.self) private var game

이 방식은 여러 뷰에서 동일한 상태를 공유할 수 있으며, 꼭 필요한 뷰에서만 의존성을 주입받을 수 있어 유연하다.

책 목록 표시 (책 상태별 UI 렌더링)

책의 상태는 .active, .inactive, .locked 세 가지로 구분된다.
각 상태에 따라 UI를 다르게 구성하며, 현재는 .active 상태에만 체크 아이콘을 표시하고 있다.

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
LazyVGrid(columns: [GridItem(), GridItem()]) {
    ForEach(game.bookQuestions.books) { book in
        if book.status == .active {
            ZStack(alignment: .bottomTrailing) {
                Image(book.image)
                    .resizable()
                    .scaledToFit()
                    .shadow(radius: 7)
                
                Image(systemName: "checkmark.circle.fill")
                    .font(.largeTitle)
                    .imageScale(.large)
                    .foregroundStyle(.green)
                    .shadow(radius: 1)
                    .padding(3)
            }
        } else if book.status == .inactive {
            ZStack {
                Image(book.image)
                    .resizable()
                    .scaledToFit()
                    .shadow(radius: 7)
            }
        } else {
            ZStack {
                Image(book.image)
                    .resizable()
                    .scaledToFit()
                    .shadow(radius: 7)
            }
        }
    }
}

현재는 .inactive, .locked 상태에 대한 시각적 구분은 없지만, 이후 강의에서 이부분을 수정할 예쩡

ContentView와 Settings 연결

ContentView 내의 Settings 버튼에서 SelectBooksView를 표시할 수 있도록 sheet를 연결한다.

1
2
3
.sheet(isPresented: $showSettings) {
    SelectBooksView()
}

프리뷰에서도 environment(Game())을 추가하면 미리보기를 통해 UI를 확인할 수 있다.

실행하면 이렇게 나온다.

Image

추가로 언급은 하지않았지만 preview에도 .environment(Game())을 통해 전달을 하기에 확인이 가능하다.

Environment-based Dependency Injection 비교

방식초기화 방식Modifier하위뷰에서 주입 방식
@EnvironmentObject@StateObject 또는 @ObservedObject 사용.environmentObject(instance)@EnvironmentObject var game: Game
@Environment(Type.self)private var game = Game().environment(game)@Environment(Game.self) private var game

먼저 표로 정리를 했다.

이전에 공부한것 때문에 EnvironmentObject와 Environment 두개를 비교를 할 수 밖에 없었다.

간단한 결론

이번 작업의 포커스는 @Environment를 활용한 의존성 주입 방식이었다.

처음부터 Game ViewModel을 만든 이유는 단순히 var bookQuestions = BookQuestions() 한 줄만 쓰기 위해서가 아니다. 이후 퀴즈 진행 관련 로직이 추가될 예정이며, 이 ViewModel은 앱의 전반적인 게임 상태를 관리하게 된다.

그리고 현재는 SelectBooksView에서 ForEach를 통해 book 데이터를 사용해야 하므로, 해당 데이터를 제공하는 Game 객체가 필요했다.

이때, 각 뷰에서 Game 인스턴스를 새로 만들면 상태가 공유되지 않으므로, 앱의 진입점(App.swift)에서 Game 인스턴스를 생성하고 .environment(...)를 통해 하위 뷰로 주입하는 구조로 설계한 것이다.

결과적으로, 이 방식은 상태 공유와 의존성 관리 측면에서 깔끔하고 확장 가능성이 높은 접근이라 할 수 있다.

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