HP Trivia (5)

 

Books & Questions

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

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

Book Modeling

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

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

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 로드 및 디코딩

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
}

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

퀴즈 정리 (책별 분류)

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 객체 생성 및 상태 부여

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권은 구매 전 잠금 상태로 구성한다.)

초기화

init() {
    let decodedQuestions = decodeQuestions()
    // Organize Questions
    let organizedQuestions = organizeQuestions(decodedQuestions)
    // Populate Books
    populateBooks(with: organizedQuestions)
}

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

SelectBooks

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

기본 UI 구성

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

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이라는 뷰모델 클래스를 만든다.

@Observable
class Game {
    var bookQuestions = BookQuestions()
}

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

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

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

@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 상태에만 체크 아이콘을 표시하고 있다.

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를 연결한다.

.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(...)를 통해 하위 뷰로 주입하는 구조로 설계한 것이다.

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