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를 확인할 수 있다.
실행하면 이렇게 나온다.
추가로 언급은 하지않았지만 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(...)
를 통해 하위 뷰로 주입하는 구조로 설계한 것이다.
결과적으로, 이 방식은 상태 공유와 의존성 관리 측면에서 깔끔하고 확장 가능성이 높은 접근이라 할 수 있다.