Week 6: Assignment

[내 과제 깃허브 : 1fba3d7]   [과제 요구사항]

짜잔 일단 자랑!!!!!

2주차 과제였던 Memorize 에 테마 선택 기능을 추가하는 게 6주차 과제!

2주차에는 그냥 새 게임을 할 때마다 랜덤으로 테마가 바뀌도록 하는 게 지시사항이었는데 이번에는 유저가 직접 선택할 수 있도록 하는 게 핵심...! 처음에 봤을 땐 이거 완전 11, 12강 복붙 아니야..? 금방 하겠네! 라고 생각했는데요...


# EmojiMemoryGame에서 테마 관련 코드 제거하기

  • 과제 첫 번째 지시사항이 ViewModelEmojiMemoryGame에서 테마를 설정하는 코드를 모두 제거하고 외부에서 set 할 수 있는 theme 변수 만 남기라는 것였다...!
  • 2주차 과제에서 테마 선택을 극한의 하드코딩으로...해결했기 때문에 슥 읽어보고 문제될 부분이 없는 거 같아 테마 관련 함수를 싹 다 날렸더니 너무 깔끔해져서 감동적이었다... 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)
    }
}

ViewModel : ThemeStore

  • 전체적인 구조는 6주차 강의 내용과 유사해서 생략한다..이하 각 View 들도 같은 이유로 전체 구조에 대한 설명은 건너뛸 예정
  • 유저가 설정한 테마를 UserDefaults 에 저장하는 메서드와 테마를 추가하고 삭제하는 메서드가 들어있다!

# Theme 구조체와 RGBAColor 구조체 : Model

Theme

  • Model 설명을 여기에서...? 싶을 수 있고, 실제로도 과제 내내 별도의 파일에서 관리하다가 마지막에 ThemeStore 와 같은 파일로 옮겨줬다. 이유는 Privacy! 기존 테마의 변경은 과제에 추천한 것처럼 ViewModel 을 거치지 않고도 가능하게 했지만, 테마를 아무데서나 막 추가할 수 없도록 새로운 테마 추가는 반드시 ViewModel 을 거치게 하고 싶어서 Themeinitfileprivate 으로 바꿔주려고 옮겼다.
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
    }
}

RGBAColor

  • Theme 의 프로퍼티 중에 color 가 있는데, 문제는 ColorUI 에서만 지원하는 타입이기도 하고 아무튼 인코딩/디코딩할 수 없다. 과제에서도 이 부분이 까다로울 것이며 힌트로 RGBAColor 구조체Extension 들을 사용할 것을 권한다.
    • RGBAColor 는 색상의 rgbalpha 즉, 투명도에 각각 해당하는 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 에 대해 얘기했던 게 생각나서 강의 노트를 다시 참고했더니 ColorView 로 쓰이고 UIColor 가 색상을 담는 데 쓰이는 타입이며, 둘 사이의 변환에 CGColor 가 사용된다고 되어있었다!
    • 그래서 Model: RGBAColor, ViewModel: UIColor, CGColor, View: Color 이렇게 되어야 하지 않을까 생각하고 구성했다!
    • 따라서 ViewModel 에서 RGBAColorColor 간의 변환을 담당하는 메서드를 제공해야한다고 생각했는데(그것이 ViewModel 이니까...) 생각보다 큰 공사가 필요했다...
    • 그런데 보니까 힌트로 제공된 RGBAColorColorextension 에서 이미 그러한 메서드를 제공하고 있어서 그냥 별도의 메서드를 만들어주지 않고 ViewModel 을 안 거치고 바로 변환하도록 했는데 지금 생각해보니까 그걸 활용해서 따로 만들었어야 하나 싶다...
  • 그 외에 내가 별도로 익스텐션을 하나 추가했는데, 맨 처음에 게임이 시작할 때 디폴트 테마들의 색깔을 RGBAColor(red: 12, green: 250, blue: 100, alpha: 1) 이런식으로 정해줬는데 전부 그냥 흰색으로 떴다...찾아보니 Colorrgba 를 받을 때 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)
    }
}

ThemeChooser : Main View

  • 모든 UI는 첫주차 강의에 나오는 Memorize를 최대한 재현하려고 했다

  • ViewModelThemeStoreApp 으로부터 EnvironmentObject 로 받아와서 처리하는 구조
  • themeChooser 에서 구현해야 하는 기능은 다음 3가지가 있다
    • 기본 상태에서 각 테마를 누르면 경우 해당 테마의 게임으로 넘어가기
    • 새로운 테마 추가
    • 편집 모드
      • 단, 편집모드에서 각 테마를 누르면 해당 테마 편집창이 뜬다
  • 다음 경우에는 게임이 리셋되어야 한다!
    • 해당 테마에 변경사항이 있었을 때

      • 와 이걸 잘못 이해해서 포스팅 쓰면서 결국 코드 뜯어고치고 왔다...하지만 이전 삽질 과정에서 많이 배웠기 때문에 후회는 없는데...아니...아.....
      • 현재 포스팅 기준으로는 정말 오직 해당 테마가 변할 때만 새로운 게임이 시작되도록 했다....
      • 이전 버전은 편집창에서 편집 행위가 있거나(테마 이동, 임의의 테마를 수정), A 테마를 플레이하다가 나가서 B 테마를 플레이하는 경우, 새로운 테마가 추가되는 경우 모두 리셋되도록 했었다...
  • 구조는 사실상 6주차 강의 내용과 같아서 쉽게 할 줄 알았는데...아니.....처음부터 위에처럼 이해했으면 쉽게 했을수도...

# @State var games: [Theme: EmojiMemoryGame(theme:)]

  • 각 테마를 누르면 해당 테마의 게임으로 넘어가야 하기 때문에 NavigationLink(destination:lable:) 를 사용해서 해당 테마의 게임을 나타내는 EmojiMemoryGameView 로 연결되게 했다. 과제 지시사항에서 추천한대로 Theme 을 키로, 해당 테마의 EmojiMemoryGame 인스턴스를 밸류로 하는 딕셔너리(이하 games) 를 선언해서 List 를 렌더링할 때, destinationgames 에서 각 테마별 게임을 불러오는 방식으로 구성했다.
  • 문제는, 테마를 편집하거나 새로 추가하는 경우 store.themes 가 바뀌면서 기존 View 가 무효화되고 다시 그려지는데, 이때 아직 새로 변경된 gamesstore.themes 에 대응하도록 변경되지 않았다는 점이다.

    • 개인적으로 계속 헷갈렸던 부분인데 @property wrapper 들이 연속적으로 변화를 감지해서 기존 View 를 무효화시키는 경우, 그리는 도중에 다음 변화에 맞게 바로 무효화시키는 게 아니라 일단 다 그리고, 그 다음에 받은 신호에 따라 또 그리는 과정을 반복해서 최종적인 상태를 완성하는 거였다.
    • 나는 store.themes 가 바뀌는 경우 onChange(of:content) 를 사용해서 games 도 변화에 대응시켜주면 문제가 없을거라고 생각했는데, 실제로는 store.themes 가 바뀜 -> 1 ) store.themes 는 바뀌고 games 는 아직 안 바뀐 상태의 View -> 2 ) store.themes 도 바뀌고 games 도 바뀐 상태의 View 순으로 최종 완성을 향해 가는 거였다.
    • 따라서 1) 상태에서 games 는 아직 업데이트 되지 않았으므로 새로 추가되거나 변경된 테마에 대한 밸류가 없어 nil 이 반환되는 문제가 있었고, 이로인해 NavigationLinkdestination 이 유효하지 않아 앱이 계속 터졌다...(앱을 최초에 실행했을 때도 비슷한 이유로 문제 발생)
  • 해결책games 에 현재 테마에 대응되는 값이 없을 경우 nil 이 아니고 View 를 리턴하게 해줘야 되는데, getAndUpdateDestination(for:) 를 써서 딕셔너리에 해당 테마가 없다면 대응되는 EmojiMemoryGame 을 만들어서 딕셔너리에도 업데이트 해주고, 반환해줬다.

    • 여기서 업데이트도 해 준 이유는 동일한 EmojiMemoryGame View 가 항상 유지되어야 한다고 생각했기 때문

    • 이러면서 기존의 onChange(of:content) 에서 games 를 업데이트해줄 필요성이 사라져서 여기서는 removeOutdatedThemes(notIn:) 를 사용해서 불필요한(삭제되거나 다른 테마로 변경된) 테마들을 games 에서 제거하는 작업만 하도록 했다. 지금 생각이 드는 건 onChange(of:content) 에서 업데이트 작업과 제거 작업을 동시에 하고, getUpdatedDestination(for:) 는 업데이트 작업 없이 그냥 View 만 리턴하는 방식 이 더 좋았을 것 같다...

    • 아니면 EmojiMemoryGameViewnil 을 받으면 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 를 띄워서 추가하도록 했다.
  • 문제는 새로운 테마를 전체 테마를 관리하고 있는 ViewModelthemes 에 실제로 추가해주는 시점이었다...
    1. 편집창인 ThemeEditor 에서 작업을 저장하고, 유효하면 추가해준다
    2. 일단 새 테마를 추가하고 편집창으로 넘어간 다음 저장이 유효하지 않거나, 중간에 취소하면 다시 삭제해준다.
  • 처음에는 1번 방식으로 구현해보려고 했는데 그렇게하니까 ThemeEditorThemeStore 를 넘겨줘야 하고, 완료 버튼을 누르면 저장과 창닫기가 동시에 실행되도록 구현했는데 자꾸 저장이 안되는 문제가 있어서 2번 방식으로 선회했다.
  • 2번에서 문제가 됐던 건 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 에 넣어줘서 새로운 게임이 시작되도록 했다! 코드는 위에 참조

# 그 외 자잘한 사항: .linelimit(), StackNavigationView, tapGesture와 NavigationLink

.lineLimit(_:)

  • 진짜 별거 아니긴 한데 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)  // 여기!
        }
    }
    ...
}

StackNavigationView

  • 사실 이거는 예전부터 알고 있는 문제였는데 아니 근데 어쨌든 돌아가긴 하잖아..? 하고 넘긴 문제였다...ㅋㅋㅋㅋㅋㅋㅋ 바로 가장 상단에서 NavigationView 를 쓰면 아이패드에서는 디폴트로 SplitView 가 나타나는데 이게 맘에 안들었다...이거는 테마선택창이 메인 View 라 나름 자연스러워보이는데 그냥 아이폰처럼 ThemeChooserEmojiMemoryGameView 가 각각 하나만 떴으면 좋겠다고 생각했다. 그래서 View extension 으로 아이패드인 경우에 StackNavigationViewStyle() 을 반환하는 메서드를 추가해줬다!
    • 해당 메서드는 검색해서 찾은거였는데, 서로 다른 타입의 View 를 어떻게 반환할까 싶었는데(늘 이런 문제로 애를 먹었다...) AnyView() 로 감싸서 type erasing 을 하면 해결되는 문제였다...! 나도 앞으로 써먹어야지ㅋㅋㅋ
  • 바뀐 모습! 근데 지금 보니까 원래 UI가 더 나은 것 같기도 하고...?

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)
        }
    }
}

TapGesture와 NavigationLink의 공존

  • 지난 강의 마지막에 과제에서 보게될 거라고 하셨는데 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]
            }
    }
    ...
}

폐기했으나 공부가 돼서 정리하는 것들

# onChange(of:perform:)

  • 여기서는 사실 그냥 이걸로 했다고 썼지만 과제를 하면서는 별 생각 없이 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) 로 했을 때는 print 문이 출력되지 않았는데 .onChange(of: ViewModel.property) 로 하니까 출력됐다...!


# NavigationLink(destination:tag:selection:label)

  • 사실 원래는 지시사항을 오해해서 a 테마의 게임을 플레이하다가 ThemeChooser 로 되돌아와서, 다른 테마를 누르면 a테마의 게임은 리셋되도록 해야된다고 생각했었다...
  • 그래서 @State var chosenTheme: Theme? 을 선언해서 현재 선택된 테마를 항상 기억하고, 이걸 사용해서 새로 선택한 테마가 이전 테마와 같은지 확인한 다음 다른 경우에는 게임을 리셋해야겠다고 생각했다!
  • NavigationLink(destination:tag:selection:label) 를 사용해서 selectionchosenTheme 을 바인딩해줘서 tag 테마가 선택될 때 토글되게 해서 선택된 테마를 기억하고, onChange(of:perform:) 을 사용해서 chosenTheme 이 변화하면 oldValuenewValue 를 비교해서 리셋 여부를 결정하려고 했다...!
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 로 돌아올 때마다 chosenThemenil 로 바뀌었다...! (개인적으로 항상 궁금한 부분이었는데 확인할 수 있어서 좋긴 했음...) 그러니까 음식 테마를 고르면 chosenTheme = Theme(food) 가 되고 링크의 바인딩이 참이 되어서 Game 으로 넘어갔다가 내가 뒤로가기를 누르면 돌아오면서 동시에 chosenTheme = nil 이 되고 있었던 것...
  • 해결책으로 @State var lastChosenTheme: Theme? 을 선언해서 onChange(of:chosenTheme) 마다 nil 이 아니면 기억하고, 새로운 값도 nil 이 아닐때만 양자 간 비교를 통해 동일한 테마인지 아닌지 확인했다

    결국 리셋되는 조건을 아예 갈아엎으면서 싹 버리게 됐지만 @State var 를 바인딩으로 써서 다른 뷰를 토글했을 때 돌아오는 순간 값이 어떻게 바뀌는지(nil 이 됨), 그리고 NavigationLink 를 바인딩과 함께 사용하는 법을 배울 수 있어서 유익했다


ThemeEditor : Destination View

# 변경사항이 실제로 적용되는 시점에 대한 고민..

  • ThemeEditor 를 구상할 때 가장 많이 고민했던 것은 편집한 내용이 실제로 저장되는 시점을 언제로 할 것이냐였다. 6주차 강의에서는 입력을 받는 순간 바로 ThemeStore 에 업데이트 해줬는데 나는 Done 버튼을 눌러야만 새로 추가/수정한 사항이 저장이 되게 하고 싶었다. 왜냐하면 제스처를 통해(시트 내리기, 아이패드의 경우 시트 바깥부분 터치하기 등) 편집창을 닫을 수 있는데 그러한 행동을 했을 때 유저의 의도는 취소라고 생각했기 때문이다.
  • 이렇게 하면 ThemeEditor 에서 각 항목을 받는 TextFieldstore.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))
    }
}

# 카드 짝 개수 지정도 2개 미만은 불가

  • 게임의 카드 짝 개수를 정할 때, 위와 같은 Stepper(_:value:in:) 를 사용했는데, 이동 범위가 최소 개수인 2와 최대 개수인 해당 테마의 이모지의 개수 사이에서만 이동하기를 원해서(유저에게 시각적으로 범위를 알려줄 수 있도록) in 의 인자로 삼항 연산자를 사용해서 이모지 개수가 2보다 작은 경우(새로 테마를 추가하는 경우)에는 2가, 그외에는 앞에서 말한 범위가 작동하도록 해줬다.
  • 문제는 카드 짝 개수는 유저가 선택한 값 뿐만 아니라, 유저가 이모지를 삭제해서 최대 짝 개수가 변화하는 경우에도 영향을 받을 수 있다는 점이었다! 얘를 들어 처음에 이모지 개수가 6개였고, 카드 짝도 6쌍으로 설정했는데 이모지 2개를 삭제해서 이모지가 4개가 되었다면 카드 짝도 자동으로 4쌍이 되도록 바꿔줘야했다! 그래서 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 를 그리고 시작하니까....
  • @Statelifecycle 이 궁금했는데 body 를 다시 그려도 계속 상태가 저장되는 게 맞았다...근데 이 부분은 좀 더 깊게 공부해봐야 할 것 같다
  • 아 ~~한 함수 없나 궁금할 때는 제발 부디 꼭 공식문서 찾아봐야한다는 교훈을 얻었다...와 이거 a하고 b랑 c를 한 다음에 d랑 e를 써서 해결하면 되겠다 싶은 건 보통 공식문서 보면 a 메서드의 다른 init 을 쓰면 쉽게 가능하더라...feat. sheet(item:onDismiss:content:)
  • 어쨌든간에 파이널 빼면 마지막 과제인 6주차 과제까지 끝냈고요...포스팅 쓰면서 와 이거 이렇게 바꾸면 더 좋을 것 같은데 하는 생각을 수십번 했지만...그러다가는 끝이 없을 것 같아 일단 여기서 마무리하고 13-16강은 과제는 따로 없으니까 슬금슬금 이거나 고쳐봐야겠다...처음부터 좋은 구조가 머릿속에 딱딱 그려지면 좋겠지만 그래도 작성하고 난 뒤에라도 개선점을 찾아낸다는 점에 스스로를 칭찬해주기로! 파이널은 검색해보니까 자유주제인 것 같아서 시간을 갖고 널널하게 완성할 예정..
  • 보너스는 쉬운 것 같아서 뚝딱 하려다가 응 아니야여서 잠정 중단 마찬가지로 슬금슬금 건드려봐야지ㅎㅎㅎ
  • 개선할 점이 많아보이지만 이 정도면 정말 공부도 많이 됐고 구현도 웬만큼 잘 한것 같아서 만-족


나중에 수정해 볼 사항

  • games[theme.id : EmojiMemoryGame(for:)] 로 관리하고 테마가 바뀌면 테마만 갈아끼워서 MemoryGameViewModel 에서 게임을 리셋하게 만들어보기
  • 색깔 선택 팔레트를 만들거나 암튼 ColorPicker 업그레이드!

좋은 웹페이지 즐겨찾기