[내 과제 깃허브 : 1fba3d7] [과제 요구사항]
2주차 과제였던
Memorize
에 테마 선택 기능을 추가하는 게 6주차 과제!2주차에는 그냥 새 게임을 할 때마다 랜덤으로 테마가 바뀌도록 하는 게 지시사항이었는데 이번에는 유저가 직접 선택할 수 있도록 하는 게 핵심...! 처음에 봤을 땐 이거 완전 11, 12강 복붙 아니야..? 금방 하겠네! 라고 생각했는데요...
ViewModel
인 EmojiMemoryGame
에서 테마를 설정하는 코드를 모두 제거하고 외부에서 set
할 수 있는 theme 변수
만 남기라는 것였다...!chosenTheme 변수
를 선언해서 외부(ThemeChooser
)에서 테마를 설정할 수 있게 해줬고, 게임에 실제로 사용되지 않는 이모지가 없도록 매번 이모지를 셔플해 주는 부분도 createMemoryGame(of:)
함수 안에서 깔끔하게 처리해줬다...!createMemoryGame(of:) 함수
에서 카드 컨텐츠를 만들 때 theme.emojis
를 인덱싱해서 쓰기 때문에 매번 앞에서부터 차례대로 쓰여서 뒤에 있는 이모지들은 게임에 등장하지 않을 수도 있기 때문에 해주는 것..!String
으로 map
해 준 이유는 초반 강의에서 일부 이모지는 이모지들의 조합으로 구성된다고 했는데 얘네는 그럼 여러 개의 Character
로 구성된 걸로 여겨지는 게 아닐까 싶어 혹시라도 이상하게 바뀔까봐...그랬는데 유효한 걱정인지는 모르겠다...ㅋㅋㅋModel
은 정말 하나도 고치지 않았는데 나중에 다 구현했을 때 원하는 대로 잘 작동해서 정말 기분 좋았다... class EmojiMemoryGame: ObservableObject {
@Published private var model: MemoryGame<String>
let chosenTheme: Theme
static func createMemoryGame(of theme: Theme) -> MemoryGame<String> {
let emojis = theme.emojis.map { String($0) }.shuffled() // 모든 이모지가 쓰일 수 있도록 셔플해주는 부분
return MemoryGame(numberOfPairsOfCards: theme.numberOfPairsOfCards) { index in
emojis[index]
}
}
init(theme: Theme) {
chosenTheme = theme
model = EmojiMemoryGame.createMemoryGame(of: chosenTheme)
}
var cards: [MemoryGame<String>.Card] { model.cards }
var score: Int { model.score }
// MARK: - Intent(s)
func choose(_ card: MemoryGame<String>.Card) {
model.choose(card)
}
func startNewGame() {
model = EmojiMemoryGame.createMemoryGame(of: chosenTheme)
}
}
View
들도 같은 이유로 전체 구조에 대한 설명은 건너뛸 예정UserDefaults
에 저장하는 메서드와 테마를 추가하고 삭제하는 메서드가 들어있다!Model
설명을 여기에서...? 싶을 수 있고, 실제로도 과제 내내 별도의 파일에서 관리하다가 마지막에 ThemeStore
와 같은 파일로 옮겨줬다. 이유는 Privacy! 기존 테마의 변경은 과제에 추천한 것처럼 ViewModel
을 거치지 않고도 가능하게 했지만, 테마를 아무데서나 막 추가할 수 없도록 새로운 테마 추가는 반드시 ViewModel
을 거치게 하고 싶어서 Theme
의 init
을 fileprivate
으로 바꿔주려고 옮겼다. struct Theme: Codable, Identifiable, Hashable {
var name: String
var emojis: String
var numberOfPairsOfCards: Int
var color: RGBAColor
let id: Int
fileprivate init(name: String, emojis: String, numberOfPairsOfCards: Int, color: RGBAColor, id: Int) {
self.name = name
self.emojis = emojis
self.numberOfPairsOfCards = max(2, min(numberOfPairsOfCards, emojis.count))
self.color = color
self.id = id
}
}
Theme
의 프로퍼티 중에 color
가 있는데, 문제는 Color
는 UI
에서만 지원하는 타입이기도 하고 아무튼 인코딩/디코딩할 수 없다. 과제에서도 이 부분이 까다로울 것이며 힌트로 RGBAColor 구조체
와 Extension
들을 사용할 것을 권한다. RGBAColor
는 색상의 rgb
와 alpha
즉, 투명도에 각각 해당하는 4개의 Double 타입
변수를 갖는 구조체struct RGBAColor: Codable, Equatable, Hashable {
let red: Double
let green: Double
let blue: Double
let alpha: Double
}
extension Color {
init(rgbaColor rgba: RGBAColor) {
self.init(.sRGB, red: rgba.red, green: rgba.green, blue: rgba.blue, opacity: rgba.alpha)
}
}
extension RGBAColor {
init(color: Color) {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
if let cgColor = color.cgColor {
UIColor(cgColor: cgColor).getRed(&red, green: &green, blue: &blue, alpha: &alpha)
}
self.init(red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha))
}
}
MVVM
의 각 구조에서 어떤 타입으로 저장되어야할까 생각해봤다. 앞에 강의 에서 Color, UIColor, CGColor
에 대해 얘기했던 게 생각나서 강의 노트를 다시 참고했더니 Color
는 View
로 쓰이고 UIColor
가 색상을 담는 데 쓰이는 타입이며, 둘 사이의 변환에 CGColor
가 사용된다고 되어있었다!Model: RGBAColor
, ViewModel: UIColor, CGColor
, View: Color
이렇게 되어야 하지 않을까 생각하고 구성했다!ViewModel
에서 RGBAColor
와 Color
간의 변환을 담당하는 메서드를 제공해야한다고 생각했는데(그것이 ViewModel
이니까...) 생각보다 큰 공사가 필요했다...RGBAColor
와 Color
의extension
에서 이미 그러한 메서드를 제공하고 있어서 그냥 별도의 메서드를 만들어주지 않고 ViewModel
을 안 거치고 바로 변환하도록 했는데 지금 생각해보니까 그걸 활용해서 따로 만들었어야 하나 싶다... RGBAColor(red: 12, green: 250, blue: 100, alpha: 1)
이런식으로 정해줬는데 전부 그냥 흰색으로 떴다...찾아보니 Color
는 rgba
를 받을 때 0 ~ 1 사이의 값으로 받는다고 한다! 그래서 앞선 내 입력의 경우 값들이 다 1보다 커서 (1, 1, 1, 1) 인 흰색처럼 받아들여졌던 것...! RGBAColor
를 소수점으로 넣어주면 되긴 하는데 인터넷 RGBA
색상 팔레트에서 맘에 드는 색깔을 골라서 넣어주고 있었는데 보통 0 ~ 255 사이의 값을 줘서 그냥 RGBAColor
를 정수로 입력하면 255로 나눠주는 init
을 하나 추가했다.extension RGBAColor {
...
init(_ red: Double, _ green: Double, _ blue: Double, _ alpha: Double) {
self.init(red: red/255, green: green/255 , blue: blue/255, alpha: alpha)
}
}
ViewModel
인 ThemeStore
를 App
으로부터 EnvironmentObject
로 받아와서 처리하는 구조themeChooser
에서 구현해야 하는 기능은 다음 3가지가 있다해당 테마에 변경사항이 있었을 때
NavigationLink(destination:lable:)
를 사용해서 해당 테마의 게임을 나타내는 EmojiMemoryGameView
로 연결되게 했다. 과제 지시사항에서 추천한대로 Theme
을 키로, 해당 테마의 EmojiMemoryGame
인스턴스를 밸류로 하는 딕셔너리(이하 games
) 를 선언해서 List
를 렌더링할 때, destination
에 games
에서 각 테마별 게임을 불러오는 방식으로 구성했다. 문제는, 테마를 편집하거나 새로 추가하는 경우 store.themes
가 바뀌면서 기존 View
가 무효화되고 다시 그려지는데, 이때 아직 새로 변경된 games
가 store.themes
에 대응하도록 변경되지 않았다는 점이다.
@property wrapper
들이 연속적으로 변화를 감지해서 기존 View
를 무효화시키는 경우, 그리는 도중에 다음 변화에 맞게 바로 무효화시키는 게 아니라 일단 다 그리고, 그 다음에 받은 신호에 따라 또 그리는 과정을 반복해서 최종적인 상태를 완성하는 거였다. store.themes
가 바뀌는 경우 onChange(of:content)
를 사용해서 games
도 변화에 대응시켜주면 문제가 없을거라고 생각했는데, 실제로는 store.themes
가 바뀜 -> 1 ) store.themes
는 바뀌고 games
는 아직 안 바뀐 상태의 View
-> 2 ) store.themes
도 바뀌고 games
도 바뀐 상태의 View
순으로 최종 완성을 향해 가는 거였다.games
는 아직 업데이트 되지 않았으므로 새로 추가되거나 변경된 테마에 대한 밸류가 없어 nil
이 반환되는 문제가 있었고, 이로인해 NavigationLink
의 destination
이 유효하지 않아 앱이 계속 터졌다...(앱을 최초에 실행했을 때도 비슷한 이유로 문제 발생)해결책은 games
에 현재 테마에 대응되는 값이 없을 경우 nil
이 아니고 View
를 리턴하게 해줘야 되는데, getAndUpdateDestination(for:)
를 써서 딕셔너리에 해당 테마가 없다면 대응되는 EmojiMemoryGame
을 만들어서 딕셔너리에도 업데이트 해주고, 반환해줬다.
여기서 업데이트도 해 준 이유는 동일한 EmojiMemoryGame View
가 항상 유지되어야 한다고 생각했기 때문
이러면서 기존의 onChange(of:content)
에서 games
를 업데이트해줄 필요성이 사라져서 여기서는 removeOutdatedThemes(notIn:)
를 사용해서 불필요한(삭제되거나 다른 테마로 변경된) 테마들을 games
에서 제거하는 작업만 하도록 했다. 지금 생각이 드는 건 onChange(of:content) 에서 업데이트 작업과 제거 작업을 동시에 하고, getUpdatedDestination(for:) 는 업데이트 작업 없이 그냥 View 만 리턴하는 방식 이 더 좋았을 것 같다...
아니면 EmojiMemoryGameView
가 nil
을 받으면 EmptyView
를 리턴하거나 하는 것도 깔끔했을 것 같다...
지금 생각해보면 파이썬의 defaultdict 같은 걸 찾아볼 걸
싶다
struct ThemeChooser: View {
@EnvironmentObject var store: ThemeStore
@State private var games = [Theme: EmojiMemoryGame]()
var body: some View {
NavigationView {
List {
ForEach(store.themes.filter { $0.emojis.count > 1 }) { theme in
NavigationLink(destination: getAndUpdateDestination(for: theme)) {
themeRow(for: theme)
}
}
}
}
.onChange(of: store.themes) { newThemes in
removeOutdatedThemes(notIn: newThemes)
}
}
private func getAndUpdateDestination(for theme: Theme) -> some View{
if games[theme] == nil {
let newGame = EmojiMemoryGame(theme: theme)
games.updateValue(newGame, forKey: theme)
return EmojiMemoryGameView(game: newGame)
}
return EmojiMemoryGameView(game: games[theme]!)
}
private func removeOutdatedThemes(notIn newThemes: [Theme]) {
store.themes.filter { $0.emojis.count >= 2}.forEach { theme in
if !newThemes.contains(theme) {
store.themes.remove(theme)
}
}
}
@State var themeToEdit
과 .sheet(item:content:)
를 사용해줘서 ThemeEditor
를 띄워서 추가하도록 했다. ViewModel
의 themes
에 실제로 추가해주는 시점이었다... ThemeEditor
에서 작업을 저장하고, 유효하면 추가해준다ThemeEditor
로 ThemeStore
를 넘겨줘야 하고, 완료 버튼을 누르면 저장과 창닫기가 동시에 실행되도록 구현했는데 자꾸 저장이 안되는 문제가 있어서 2번 방식으로 선회했다. store.themes
에 들어가게 되어 ViewModel
이 바뀌므로 View
를 다시 그리게 되고 이 과정에서 List
에 빈 테마도 들어가게 되는데 이모지가 없으므로 getAndUpdateDestination(for:)
에서 해당 테마에 대한 EmojiMemoryGame(theme:)
을 만들 수가 없어 터졌다. 그래서 List
를 렌더링 할때 ForEach(store.themes.filter { $0.emojis.count > 1}
로 아예 제거해줬다. ThemeChooser
로 돌아왔을 때 새로 추가한 테마가 유효한지(이모지가 2개 이상이고, 카드 개수가 이모지 2보다 크고 이모지 개수보다 작거나 같은지) 확인하는 작업을 어디서 하느냐였다...! 어디서 어떻게 처리할 지 난감했는데 .sheet(item:onDismiss:content:)
라는, 종료 시에 할 작업을 지정해주는 버전의 init
이 있었다. 그래서 시트가 닫히고 나서 딕셔너리에 이제 안 쓸 테마의 key
가 남아서 무제한으로 커지는 것을 막기 위해 removeNewThemeOnDismissIfInvalid()
를 이용해 유효한 테마인지 확인하고 아니라면 삭제하도록 했다.struct ThemeChooser: View {
...
var body: some View {
NavigationView {
List {
// some code...
}
.sheet(item: $themeToEdit) {
removeNewThemeOnDismissIfInvalid()
} content: { theme in
ThemeEditor(theme: $store.themes[theme])
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) { addThemeButton } // 여기서 추가
ToolbarItem { EditButton() }
}
}
}
@State private var themeToEdit: Theme?
private var addThemeButton: some View {
Button {
store.insertTheme(named: "new") // 항상 맨 앞에 오도록 추가
themeToEdit = store.themes.first
} label : {
Image(systemName: "plus")
.foregroundColor(.blue)
}
}
private func removeNewThemeOnDismissIfInvalid() {
if let newButInvalidTheme = store.themes.first {
if newButInvalidTheme.emojis.count < 2 {
store.removeTheme(at: 0)
}
}
}
onChange(of:perform)
에서 store.themes
이 바뀌어서 getAndUpdateDestination(for:)
을 호출할 때 해당 테마 key
값으로 들어있지 않은 경우에만 games
에 넣어줘서 새로운 게임이 시작되도록 했다! 코드는 위에 참조 AnimalFaces
테마처럼 이모지가 많은 경우 일부만 표시하고 나머지는 ...
으로 생략한다! 간단하게 처리하는 방법이 있을 것 같아 검색해봤는데 .lineLimit(_:)
을 쓰면 Text(_:)
의 줄 수가 제한되고 넘치는 부분은 생략된다고 나와서 아래와 같이 처리했다!struct ThemeChooser: View {
...
private func themeRow(for theme: Theme) -> some View {
VStack(alignment: .leading) {
Text(theme.name)
.foregroundColor(Color(rgbaColor: theme.color))
.font(.system(size: 25))
.bold()
HStack {
if theme.numberOfPairsOfCards == theme.emojis.count {
Text("All of \(theme.emojis)")
} else {
Text("\(String(theme.numberOfPairsOfCards)) pairs from \(theme.emojis)")
}
}
.lineLimit(1) // 여기!
}
}
...
}
NavigationView
를 쓰면 아이패드에서는 디폴트로 SplitView
가 나타나는데 이게 맘에 안들었다...이거는 테마선택창이 메인 View
라 나름 자연스러워보이는데 그냥 아이폰처럼 ThemeChooser
와 EmojiMemoryGameView
가 각각 하나만 떴으면 좋겠다고 생각했다. 그래서 View extension
으로 아이패드인 경우에 StackNavigationViewStyle()
을 반환하는 메서드를 추가해줬다!View
를 어떻게 반환할까 싶었는데(늘 이런 문제로 애를 먹었다...) AnyView()
로 감싸서 type erasing
을 하면 해결되는 문제였다...! 나도 앞으로 써먹어야지ㅋㅋㅋstruct ThemeChooser: View {
...
var body: some View {
NavigationView {
}
.stackNavigationViewStyleIfiPad()
}
...
}
extension View {
func stackNavigationViewStyleIfiPad() -> some View {
if UIDevice.current.userInterfaceIdiom == .pad {
return AnyView(self.navigationViewStyle(StackNavigationViewStyle()))
} else {
return AnyView(self)
}
}
}
ThemeChooser
에서 각 테마를 눌렀을 때 기본 상태에서는 해당 테마의 게임으로 연결되지만, EditMode
인 경우 해당 테마의 편집창으로 연결되어야 한다! 문제는 NavigationLink
가 있을 때 TapGesture
를 선언해주면 후자가 전자를 오버라이딩한다는 것! editMode
일 때만 tapToOpenThemeEditor(for)
메서드가 호출돼서 tapGesture
가 효과가 있도록 해줬다!struct ThemeChooser: View {
@State private var editMode: EditMode = .inactive
var body: some View {
NavigationView {
List {
ForEach(store.themes.filter { $0.emojis.count > 1 }) { theme in
NavigationLink(destination: getAndUpdateDestination(for: theme)) {
themeRow(for: theme)
}
.gesture(editMode == .active ? tapToOpenThemeEditor(for: theme) : nil) // 여기!
}
}
.environment(\.editMode, $editMode)
}
...
}
...
private func tapToOpenThemeEditor(for theme: Theme) -> some Gesture {
TapGesture()
.onEnded {
themeToEdit = store.themes[theme]
}
}
...
}
didSet
을 써서 store.themes
가 바뀌었을 때 games
를 업데이트해주려고 했었다...그런데 값이 바뀌는 것은 확인이 되는데 didSet
이 절대 호출이 안됐다...검색해보다가 onChange(of:perform)
을 써보라는 조언을 봤고, 그랬더니 원하는대로 작동됐다...@State
의 경우 didSet/willSet
이 잘 작동해서 당연히 EnvironmentObject
도 될 줄 알았는데 전자는 source of truth
인 반면 후자는 reference to source of truth
라서 wrapped property
가 다른 데서 바뀌고 있어서 작동하지 않나 싶었다...그런데 실험해 봤더니 @StateObject
도 안먹혀서 다소 의문...💡 이것저것 실험해보다가 든 생각은 ViewModel 은 클래스이므로 reference type 이고 따라서 ViewModel 내부의 프로퍼티가 변화하더라도 관찰하고 있는 @propertyWrapper 들은 변화를 인식하지 못하는 것 같다 왜냐하면
.onChange(of: ViewModel)
로 했을 때는.onChange(of: ViewModel.property)
로 하니까 출력됐다...!
ThemeChooser
로 되돌아와서, 다른 테마를 누르면 a테마의 게임은 리셋되도록 해야된다고 생각했었다...@State var chosenTheme: Theme?
을 선언해서 현재 선택된 테마를 항상 기억하고, 이걸 사용해서 새로 선택한 테마가 이전 테마와 같은지 확인한 다음 다른 경우에는 게임을 리셋해야겠다고 생각했다! NavigationLink(destination:tag:selection:label)
를 사용해서 selection
에 chosenTheme
을 바인딩해줘서 tag
테마가 선택될 때 토글되게 해서 선택된 테마를 기억하고, onChange(of:perform:)
을 사용해서 chosenTheme
이 변화하면 oldValue
와 newValue
를 비교해서 리셋 여부를 결정하려고 했다...!struct ThemeChooser: View {
...
@State private var chosenTheme: Theme?
@State private var lastChosenTheme : Theme?
var body: some View {
NavigationView {
if !games.isEmpty {
List {
ForEach(store.themes.filter { $0.emojis.count > 1 }) { theme in
NavigationLink(destination: getdestination(for: theme), tag: theme, selection: $chosenTheme) {
themeRow(for: theme)
}
.gesture(editMode == .active ? tapToOpenThemeEditor(for: theme) : nil)
}
}
}
}
.onChange(of: chosenTheme) { newChosenTheme in
if lastChosenTheme != nil && newChosenTheme != nil && lastChosenTheme != newChosenTheme {
updateGames(from: store.themes)
}
if newChosenTheme != nil {
lastChosenTheme = newChosenTheme
}
}
}
...
}
destination
에서 themeChooser
로 돌아올 때마다 chosenTheme
이 nil
로 바뀌었다...! (개인적으로 항상 궁금한 부분이었는데 확인할 수 있어서 좋긴 했음...) 그러니까 음식 테마를 고르면 chosenTheme = Theme(food)
가 되고 링크의 바인딩이 참이 되어서 Game
으로 넘어갔다가 내가 뒤로가기를 누르면 돌아오면서 동시에 chosenTheme = nil
이 되고 있었던 것... @State var lastChosenTheme: Theme?
을 선언해서 onChange(of:chosenTheme)
마다 nil
이 아니면 기억하고, 새로운 값도 nil
이 아닐때만 양자 간 비교를 통해 동일한 테마인지 아닌지 확인했다 결국 리셋되는 조건을 아예 갈아엎으면서 싹 버리게 됐지만
@State var
를 바인딩으로 써서 다른 뷰를 토글했을 때 돌아오는 순간 값이 어떻게 바뀌는지(nil
이 됨), 그리고NavigationLink
를 바인딩과 함께 사용하는 법을 배울 수 있어서 유익했다
ThemeEditor
를 구상할 때 가장 많이 고민했던 것은 편집한 내용이 실제로 저장되는 시점을 언제로 할 것이냐였다. 6주차 강의에서는 입력을 받는 순간 바로 ThemeStore
에 업데이트 해줬는데 나는 Done
버튼을 눌러야만 새로 추가/수정한 사항이 저장이 되게 하고 싶었다. 왜냐하면 제스처를 통해(시트 내리기, 아이패드의 경우 시트 바깥부분 터치하기 등) 편집창을 닫을 수 있는데 그러한 행동을 했을 때 유저의 의도는 취소라고 생각했기 때문이다.ThemeEditor
에서 각 항목을 받는 TextField
에 store.theme
의 각 프로퍼티를 바인딩해줄 수 없었다. 그래서 모든 항목마다 ThemeEditor
에서 임시로 source of truth
가 되어줄 @State 변수
를 선언해서 TextField
에 바인딩해주고, Done
버튼을 눌렀을 때만 store.theme
에 추가/수정한 사항을 저장해줬다. 전체 코드는 여기 참고struct ThemeEditor: View {
@Binding var theme: Theme
@Environment(\.presentationMode) private var presentationMode
var body: some View {
NavigationView {
Form {
nameSection
removeEmojiSection
addEmojiSection
cardPairSection
colorSection
}
.navigationTitle("\(name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
cancelButton
}
ToolbarItem { doneButton }
}
}
}
private var doneButton: some View {
Button("Done") {
if presentationMode.wrappedValue.isPresented && candidateEmojis.count >= 2 {
saveAllEdits()
presentationMode.wrappedValue.dismiss()
}
}
}
private func saveAllEdits() {
theme.name = name
theme.emojis = candidateEmojis
theme.numberOfPairsOfCards = min(numberOfPairs, candidateEmojis.count)
theme.color = RGBAColor(color: chosenColor)
}
private var cancelButton: some View {
Button("Cancel") {
if presentationMode.wrappedValue.isPresented {
presentationMode.wrappedValue.dismiss()
}
}
}
...
}
ThemeEditor
의 @State 변수
들을 TextField
와 연동했지만 편집창을 처음 띄웠을 때는 그냥 빈칸이나 디폴트값이 아니라 위의 사진처럼 store.themes
의 프로퍼티 값들이 뜨게 하고 싶었다. init
에서 처리주면 될 것 같아서 찾아봤더니 @State 변수
를 init
하려면 @State
자체는 일종의 연산 변수이기 때문에 아래와 같이 내부의 wrapped value
에 접근해서 init
해줘야 한다고 했다! 그래서 아래 코드처럼 해줬다struct ThemeEditor: View {
init(theme: Binding<Theme>) {
self._theme = theme
self._name = State(initialValue: theme.wrappedValue.name)
self._candidateEmojis = State(initialValue: theme.wrappedValue.emojis)
self._numberOfPairs = State(initialValue: theme.wrappedValue.numberOfPairsOfCards)
self._chosenColor = State(initialValue: Color(rgbaColor: theme.wrappedValue.color))
}
}
Stepper(_:value:in:)
를 사용했는데, 이동 범위가 최소 개수인 2와 최대 개수인 해당 테마의 이모지의 개수 사이에서만 이동하기를 원해서(유저에게 시각적으로 범위를 알려줄 수 있도록) in
의 인자로 삼항 연산자를 사용해서 이모지 개수가 2보다 작은 경우(새로 테마를 추가하는 경우)에는 2가, 그외에는 앞에서 말한 범위가 작동하도록 해줬다. onChange(of:perform)
을 써서 candidateEmojis
가 바뀔 때마다 numberOfPairs = max(2, min(numberOfPairs, candidateEmojis.count))
와 같이 업데이트 해줬다.max(2, _)
가 필요한 이유는 새로운 테마를 추가하는 경우 두 값 모두 2보다 작을 수 있기 때문...! struct ThemeEditor: View {
...
private var cardPairSection: some View {
Section(header: Text("Card Count")) {
Stepper("\(numberOfPairs) Pairs", value: $numberOfPairs, in: candidateEmojis.count < 2 ? 2...2 : 2...candidateEmojis.count)
.onChange(of: candidateEmojis) { _ in
numberOfPairs = max(2, min(numberOfPairs, candidateEmojis.count))
}
}
}
}
Done
버튼을 눌러도 애초에 저장이 안되게 하고 싶었다. 왜냐하면 위와 마찬가지로 유저가 아 테마를 추가/변경하려면 충족해야될 조건이 있구나를 바로 인식하기를 원했다! 여기서 유효성을 판별하는 기준은 나의 경우 emoji
개수였는데 카드 짝 개수와 색깔은 디폴트 값을 정해놓은 상태로 편집창을 띄우는 게 유저 입장에서 편할 것 같아 각각 2와 빨강으로 미리 설정했기 때문...! done
버튼을 눌렀을 때 수정한 버전의 이모지 총 개수가 2 이상일 때, 즉 candidateEmojis.count >= 2
일 때만 saveAllEdits()
메서드를 호출하도록 했다. View
가 렌더링되고, ViewModifier
들이 중첩되어 작동한다는 것을 배웠는데도 막상 그러한 방식에 대한 이해가 부족해서 엄청 실수했다....onAppear
에 대해 배우면서 분명 일단 View
가 그려지고 그 다음에 onAppear
가 작동한다고 배웠음에도 불구하고 과제를 하면서 .onAppear
써서 분명이 딕셔너리를 채워줬는데 왜 터지지!!! 하고 생각했었다...당연히 터진다... 왜냐면...onAppear
전에 일단 한 번 View
를 그리고 시작하니까.... @State
의 lifecycle
이 궁금했는데 body
를 다시 그려도 계속 상태가 저장되는 게 맞았다...근데 이 부분은 좀 더 깊게 공부해봐야 할 것 같다init
을 쓰면 쉽게 가능하더라...feat. sheet(item:onDismiss:content:)
나중에 수정해 볼 사항
games
를 [theme.id : EmojiMemoryGame(for:)]
로 관리하고 테마가 바뀌면 테마만 갈아끼워서 MemoryGameViewModel
에서 게임을 리셋하게 만들어보기 ColorPicker
업그레이드!