Lecture 12: Bindings Sheet Navigation EditMode

sun·2021년 11월 19일
0

유튜브 링크

# Property Wrappers

  • 모든 @Something statments 들은 property wrapper 이며 property wrapper 들은 사실 구조체다!
    e.g.

    • @State : 힙에 변수 저장
    • @Published : 변수의 변화를 공표하게 함
    • @ObservedObject : published change 가 감지되면 View 를 다시 그림
  • wrapped value : where everything happens...! 실제 값으로 우리가 선언한 property wrapper 변수들은 결국 얘를 읽고, 쓰는 computed var

  • projected value : way to share the same wrapped value

@Published var emojiArt: EmojiArt = EmojiArt()

struct Published {
    var wrappedValue: EmojiArt
    var projectedValue: Publisher<EmojiArt, Never>  // access by using "$" (e.g. $emojiArt)
}

// if u use @propertyWrapper with sth, u get this underbar version
// which is where the actual struct that the @propertyWrapper is making is
var _emojiArt: Published = Published(wrappedValue: EmojiArt())

// and the var we've been using all the time is actually
// just a computed var based on the underbar version
var emojiArt: EmojiArt {
    get { _emojiArt.wrappedValue }
    set {_emojiArt.wrappedValue = newValue }
}

# @Published 의 구조와 작동 방식


# @State

  • single source of truth!


# @StateObject

  • source of truth
    cf. ObservableObjectreference to source of truth

  • @StateObject 를 사용하는 경우 ViewModel 의 생명 주기가 해당 View 의 생명주기에 연동되므로 주의해야 한다


# @StateObject 와 @ObservableObject

  • wrappedValue : ObservableObject 프로토콜 에 순응하는 것(i.e. ViewModel)
  • projectedValue : wrappedValue 의 변수들에 대한 Binding
  • wrappedValue 가 objectWillChange.send() 를 하면 현재 View 를 무효화함(버리고 다시 그림)


# @Binding

  • wrappedValueget/set 하고, 값이 바뀌면 기존 View 를 무효화
  • single source of truth 를 여러 곳에서 공유하기 위해 쓴다
  • 따라서 source of truth 를 다른 어디에션가 받아오는 것이므로 절대 = 를 써서 지정해주지 않는다!!!


# 특수 Binding


# @EnvironmentObject

  • 임의의 View 에 주입하면 해당 View 를 포함한 모든 하위 View 에서 사용 가능
  • ObservableObject 타입 별로 하나만 주입할 수 있다


# @Environment

  • @EnvironmentObject 와 완전히 무관하다
  • 현재 View 가 속해있는 환경을 나타내고 있어 Environment 라고 칭한다
  • key path(i.e \.somePath) 를 사용해서 property wrapper 내부의 여타 변수들에 접근할 수 있음!!
  • 보통은 projected value 가 없다


PaletteStore를 쓰는 View 만들기 : PaletteChooser

  • 여기서 하단에 보이는 친구를 만드는 게 오늘의 목표!
  • 전체 구조는 요약하면 HStack 안에 다양한 옵션(수정, 추가, 삭제 등)이 들어있는 paletteControlButton 과 현재 선택된 테마의 이모지들을 쭉 보여주는 ScrollingEmojiView (원래 알던 그 친구가 맞다) 를 담고 있는 body 가 담겨있는 형태다
    • 왼쪽 팔레트 모양 아이콘이 paletteControlButton 이고
    • 오른쪽이 테마 이름과 이모지들을 담은 body
struct PaletteChooser: View {
    ...
    var body: some View {
        HStack {
            paletteControlButton
            body(for: store.palette(at: chosenPaletteIndex))
        }
        .clipped()
    }
    ...
}

# 머리가 나쁘면 손이 고생한다 @EnvironmentObject

  • 지난 시간에 이모지 팔레트를 위한 ViewModel 을 만들었으니, 이번에는 해당 ViewModel 을 사용하는 ViewPaletteChooser 를 만들어 볼 차례다!

  • 이를 위해서는 일단 가장 먼저 observeviewModel 이 필요한데, 사실상 하나의 View 로 해결됐던 EmojiArtDocument 와 달리 PaletteChooser 는 여러 하위 View 로 구성되고, 이러한 하위 View 들 또한 ViewModel 을 필요로 한다. 따라서 기존의 ObservableObject 방식으로 ViewModel 을 선언해주기 보다는 가장 상단에 있는 EmojiArtApp 구조체 에서 @StateObject 를 사용해 ViewModelsource of truth로 선언해주고 하위의 모든 View 에서 @EnvironemntObject 로 받아 동일한 ViewModel 을 공유할 수 있도록 .environmentObject() 를 사용해서 주입해줬다!

  • EnvironmentObject@ObservabedObject 와 마찬가지로 ObservableObject 에 변화가 생겨 wrappedValueobjectWillChange.send() 를 하면 현재 View 를 버리고 다시 그린다...!

@main
struct EmojiArtApp: App {
    @StateObject var document = EmojiArtDocument()
    @StateObject var paletteStore = PaletteStore(named: "Default")
    
    var body: some Scene {
        WindowGroup {
            EmojiArtDocumentView(document: document)
                .environmentObject(paletteStore)
        }
    }
}

struct PaletteChooser: View {
    @EnvironmentObject var store: PaletteStore
    ...
}

# 있었는데요...없었습니다...(feat. transition)

  • paletteControlButton 는 누르면 다음 팔레트로 넘어가는 기능이 있다. 이를 위해서는 현재 선택된 팔레트의 인덱스를 기억하고 있어야 하고, 이건 전적으로 View 를 위한 것이므로 @State private var chosenPaletteIndex 를 선언해서 힙에 저장해준다.

  • 다음 인덱스로 넘기는 로직은 아래와 같은데, 맨 마지막 팔레트에 도달한 경우 다시 맨 앞으로 가줘야 하므로 % 로 나눠서 처리한다

struct PaletteChooser: View {
    ...
    @State private var chosenPaletteIndex = 0

    var paletteControlButton: some View {
        Button {
            withAnimation {
                chosenPaletteIndex = (chosenPaletteIndex + 1) % store.palettes.count
            }
        } label: {
            Image(systemName: "paintpalette")
        }
        .font(emojiFont)
        .contextMenu { contextMenu }
    }
    ...
}
  • 문제는 다음 팔레트로 넘어갈 때 transition 을 사용해서 애니메이션을 추가하고 싶은데 아래 코드에서 팔레트가 넘어갈 때 HStack 자체는 그대로 있고, 안의 내용만 바뀌는 형태라 transition 을 적용할 수 없었다...

    • transition 을 쓰려는 이유는 교수님 말씀에 의하면 transition 에 내장된 .offset(x:y:) 를 이용하면 우리가 적용하려는 애니메이션을 쉽게 구현할 수 있기 때문...!
  • 해결책은 놀랍게도 HStack 에 일종의 tag 처럼 id 를 달면, id 가 바뀔 때마다 View 를 아예 다시 그리게 되므로 transition 애니메이션 을 적용할 수 있다고 한다...! 이걸 미리 알았더라면...내 Set 과제가 좀 더 나은 모습이지 않았을까...

struct PaletteChooser: View {
    ...
    func body(for palette: Palette) -> some View {
        HStack {
            Text(palette.name)
            ScrollingEmojisView(emojis: palette.emojis)
                .font(emojiFont)
        }
        .id(palette.id)
        .transition(rollTransition)

    var rollTransition: AnyTransition {
        AnyTransition.asymmetric(
            insertion: .offset(x: 0, y: emojiFontSize),
            removal: .offset(x: 0, y: -emojiFontSize)
        )
    }
    ...
}   

# 우리는 이런 걸 contextMenu 라고 부르기로 했어요...

  • paletteControlButton 은 위에서와 같이 꾹 눌렀을 때 다양한 옵션을 제공한다. 처음에는 VStack 같은 데 Button 들을 담고 이걸 또 LongTapGesture 에 연결해주는 건가 싶었는데, 해당 기능을 갖고 있는 .contextMenu 라는 아주 유용한 viewModifier 가 있었다...! 유저의 현재 상황에 따라 수행하는 작업이 바뀌는 메뉴View 에 덧붙이고 싶을 때 이 친구를 사용하면 보다 간단하게 코드를 짤 수 있다
struct PaletteChooser: View {
    ...
    var paletteControlButton: some View {
        Button {
            // some code... 
        }
        .font(emojiFont)
        .contextMenu { contextMenu }
    }
    
    @ViewBuilder
    var contextMenu: some View {
        AnimatedActionButton(title: "Edit", systemImage: "pencil") {
            paletteToEdit = store.palette(at: chosenPaletteIndex)
        }
        AnimatedActionButton(title: "New", systemImage: "plus") {
            store.insertPalette(named: "New", emojis: "", at: chosenPaletteIndex)
            paletteToEdit = store.palette(at: chosenPaletteIndex)
        }
        AnimatedActionButton(title: "Delete", systemImage: "minus.circle") {
            chosenPaletteIndex = store.removePalette(at: chosenPaletteIndex)
        }
        AnimatedActionButton(title: "Manager", systemImage: "slider.vertical.3") {
            managing = true
        }
        gotoMenu
    }
    ...
}
  • 위에서 사용한 AnimatedActionButton 은 편의를 위해 만든 구조체로 버튼을 눌렀을 때 수행할 작업에 애니메이션을 곁들이도록 해 준 syntactic sugar !
struct AnimatedActionButton: View {
    var title: String? = nil
    var systemImage: String? = nil
    let action: () -> Void
    
    var body: some View {
        Button {
            withAnimation {
                action()
            }
        } label: {
            if title != nil && systemImage != nil {
                Label(title!, systemImage: systemImage!)
            } else if title != nil {
                Text(title!)
            } else if systemImage != nil {
                Image(systemName: systemImage!)
            }
        }
    }
}

# gotoMenu

  • delete 버튼 은 쉬우니까...생략하고 나머지 기능들은 또 별도의 View 에 대한 설명이 필요하기 때문에 gotoMenu 부터 짚고 넘어가려고 한다. 이 친구의 기능은 일종의 바로 가기! 누르면 전체 팔레트 목록을 보여주고, 각 팔레트 이름을 누르면 현재 팔레트가 해당 팔레트로 변경된다!

Menu 와 contextMenu

  • 전자는 구조체, 즉 그 자체로 View 인 반면, 후자는 View 를 반환하는 property wrapper 다.
  • Menu 는 기본적으로 tapGesture 에 반응하지만 contextMenulongTapGesture 에 반응한다
  • 조건에 따라 메뉴가 수행할 작업이 바뀐다면 후자, 일정하다면 전자
struct PaletteChooser: View {
    var gotoMenu: some View {
        Menu {
            ForEach(store.palettes) { palette in
                AnimatedActionButton(title: palette.name) {
                    if let index = store.palettes.index(matching: palette) {
                        chosenPaletteIndex = index
                    }
                }
            }
        } label: {
            Label("Go To", systemImage: "text.insert")
        }
    }
}

PaletteChooser의 세상에 PaletteEditor의 등장이라...

# .popover 와 @Binding

  • contextMenu 에서 EditNew 버튼을 누르면 현재 선택된/생성된 팔레트를 편집할 수 있는 팝업(i.e. PaletteEditor )이 뜬다. .popover(item:content:) 를 써서 이러한 팝업을 만들건데 popover 를 쓰는 이유는 얘는 어디에서 popover 했는지 꼬리로 나타낼 수 있기 때문...!

  • 이때 PaletteEditorPaletteStorePalette 를 수정하고자 하는 것이므로 바인딩 을 이용해 넘겨줌으로써 PaletteEditorPaletteChooser 가 동일한 Palette 를 보고 있도록 해줘야 한다!

    struct PaletteEditor: View {
        @Binding var palette: Palette
        ...
    }
    
  • .popover 를 쓰려면 팝업창을 띄울 지 말 지를 결정하는 source of truthBinding 을 해서 써야 하는데 여기서는 Bool 이 아니고 <Item?> 에 바인딩했다. 왜냐하면 source of truthnil 인 상태가 자연스러운 경우 Item? 에 바인딩하고 얘를 바로 인자로 넘겨주는 데 활용할 수 있어 코드가 더 간단해지기 때문!
    • source of truth 가 되어줄 @State private var paletteToEdit: Palette? 을 선언해준 다음
    • contextMenu 에서 Edit 혹은 New 를 누르면 paletteToEdit 을 현재 선택한 팔레트로 변경해준다.
    • 이러면 paletteToEditnon-nil 이 되면서 자동으로 popover 가 작동해 PaletteEditor 가 뜬다
struct PaletteChooser: View {
    ...
    @State private var paletteToEdit: Palette?
    
    var contextMenu: some View {
        AnimatedActionButton(title: "Edit", systemImage: "pencil") {
            paletteToEdit = store.palette(at: chosenPaletteIndex)
        }
        AnimatedActionButton(title: "New", systemImage: "plus") {
            store.insertPalette(named: "New", emojis: "", at: chosenPaletteIndex)
            paletteToEdit = store.palette(at: chosenPaletteIndex)
        }
        ...
    }
    
    func body(for palette: Palette) -> some View {
        HStack {
            // code for each Palette 
        }
        .popover(item: $paletteToEdit) { palette in
            // created binding!(get and set!)
            PaletteEditor(palette: $store.palettes[palette])
        }
}
  • .popover(item:) 에서 PaletteEditor 에 인자로 바인딩을 넘겨줄 때 보면 palettes[palette] 라는 특이한 형태의 subscripting 이 보이는 데, ViewModel 자체의palettes 를 변경할 수 있도록 store.palettes 에서 현재 선택된 palette 의 인덱스를 찾아 이에 접근하는 과정을 간단히 하기 위해 만든 extenstion 이다.
extension RangeReplaceableCollection where Element: Identifiable {
    subscript(_ element: Element) -> Element {
        get {
            if let index = index(matching: element) {
                return self[index]
            } else {
                return element
            }
        }
        set {
            if let index = index(matching: element) {
                replaceSubrange(index...index, with: [newValue])
            }
        }
    }
}

# PaletteEditor

Form

  • 데이터를 입력받을 때 입력창들을 그룹화해서 나타낼 때 사용할 수 있는 컨테이너!
  • Section 구조체 를 사용해서 적절히 섹션을 나눌 수 있다.
struct PaletteEditor: View {
    ...
    var body: some View {
        Form {
            nameSection
            addEmojisSection
            removeEmojiSection
        }
        .navigationTitle("Edit \(palette.name)")
        .frame(minWidth: 300, minHeight: 350)
    }
    ...

nameSection

  • 입력을 받을 떄 우리는 TextField(_:text:) 를 사용할 수 있는데 text 인자로 source of truth (여기서는 palette.name) 를 바인딩 해주면 창에 기존에 입력받았던 값을 띄울 뿐만 아니라, 새로 입력받은 값을 source of truth 에 계속 업데이트해준다
struct PaletteEditor: View {
    ...
    var nameSection: some View {
        Section(header: Text("Name")) {
            TextField("Name", text: $palette.name)
        }
    }
    ...
}

addEmojiSection

  • 그럼 이모지를 추가할 때도 위에서처럼 똑같이 해주면 되는 거 아니야? 하고 생각할 수 있지만, palette.emojis 에 바인딩하면 이미 팔레트에 들어있는 이모지들이 계속 TextField 에 뜨는 데 여기서는 지금 새로 추가한 이모지만 띄우고 싶으므로 다른 방법을 생각해야 한다.

  • 따라서 현재 새로 추가하려고 누른 이모지들만 담고 있는 새로운 변수(@State private var emojisToAdd = "")를 선언해서 여기에 바인딩해주고, 입력받은 이모지들을 다시 팔레트에 추가해주면 된다!

    • 여기서 고민됐던 건 어떤 시점에 팔레트에 이모지를 추가하는 작업을 하는가였는데 일단은 .onChange(of:perform:) 를 사용해서 emojisToAdd 변수 가 바뀔 때마다 추가하도록 했다.
    • 참고로 .onChange(of:perform:) 을 사용하면 perform 의 인자로 업데이트된 newValue 가 들어가고, 메인스레드에서 작업하기 때문에 오래 걸리는 작업을 하게 된다면 백그운드 큐에 넘겨줘야 한다.
struct PaletteEditor: View {
    @State private var emojisToAdd = ""
    
    var addEmojisSection: some View {
        Section(header: Text("Add Emojis")) {
            TextField("", text: $emojisToAdd)
                .onChange(of: emojisToAdd) { emojis in
                    addEmojis(emojis)
                }
        }
    }
    
    func addEmojis(_ emojis: String) {
        withAnimation {
            palette.emojis = (emojis + palette.emojis)
                .filter { $0.isEmoji }
                .removingDuplicateCharacters
        }
    }

}
  • 참고로 .removingDuplicateCharacters 는 팔레트에 이모지가 중복으로 들어가는 것을 방지하기 위한 extension
extension String {
    var removingDuplicateCharacters: String {
        reduce(into: "") { sofar, element in
            if !sofar.contains(element) {
                sofar.append(element)
            }
        }
    }
}

removeEmojiSection

  • 팔레트에 있던 이모지를 삭제하기 위해서 LazyVGrid 안에서 ForEach() 를 이용해서 각 이모지를 UI 상에 나타낸 다음 .onTapGesture 를 사용해서 특정 이모지를 탭 하는 경우 지워지게 해줄거다...!

  • 유의사항은 palette.emojisString 이라 RandomAccessCollection 프로토콜에 순응하지 않아 ForEach() 를 할 수 없으므로 Arraymap 해줘야한다

  • 그리고 onTapGesture 에서 탭한 이모지를 삭제할 때 .removeAll(where:) 를 이용하면 조건에 일치하지 확인하고 삭제해주기 때문에 인덱스를 찾고, optional unwrapping 하는 과정을 생략할 수 있어 더 간단하다!

struct PaletteEditor: View {
    ...
    var removeEmojiSection: some View {
        Section(header: Text("Remove Emoji")) {
            let emojis = palette.emojis.removingDuplicateCharacters.map { String($0) }
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))]) {
                ForEach(emojis, id: \.self) { emoji in
                    Text(emoji)
                        .onTapGesture {
                            withAnimation {
                                palette.emojis.removeAll(where: { String($0) == emoji })
                            }
                        }
                }
            }
            .font(.system(size: 40))
        }
    }
    ...
}

# PaletteManager

  • contextMenu 에서 Manager 를 누르면 위와 같이 모든 팔레트가 뜨고, 각 팔레트를 누르면 해당 팔레트를 편집할 수 있는 PaletteEditor 로 넘어가며, Edit 버튼을 누르면 각 팔레트를 삭제하거나 이동할 수 있다

.sheet(isPresented:content:)

  • 팝업을 띄우기 위해서 이번에는 .sheet(isPresented:content:) 를 사용할 건데, 이번에는 굳이 특정 팔레트에 국한되지 않으므로 popover 보다 sheet 이 더 자연스럽고, contentPaletteManger() 구조체ViewModel 전체를 필요로 하므로 @EnvironmentObject 를 사용할 거라 굳이 Item 에 바인딩할 필요없이, @State private var managing: Bool 에 바인딩해서 sheet 을 팝업할 지 말 지를 알려주면 되기 때문!
struct PaletteManager: View {
    @EnvironmentObject var store: PaletteStore
    ...
}

struct PaletteChooser: View {
    ...
    @ViewBuilder
    var contextMenu: some View {
        ...
        AnimatedActionButton(title: "Manager", systemImage: "slider.vertical.3") {
            managing = true
        }
        ...
    }
    
    @State private var managing = false
        
    func body(for palette: Palette) -> some View {
        HStack {
            // code for each palette
        }
        .sheet(isPresented: $managing) {
            PaletteManager()
        }
    }
    ...
}

# 화려한 NavigationLink가 Palette를 감싸네...

  • 각 팔레트를 나타내는 데는 List(content:) 를 쓸건데 VStack 을 써도 유사하게 구현은 할 수 있겠지만 List 를 쓰면 각 항목마다 자동으로 행이 구분되고, NavigationLink 를 사용해서 다른 View 로 넘어가게 하면 각 항목 끝에 화살표가 생겨서 시각적으로 더 이해가 빠르기 때문!

  • List 안에서 ForEach 를 사용할건데 굳이 ForEach 를 사용하는 이유는 .onDelete(perform:) 메서드 를 지원해 나중에 각 팔레트를 지우는 작업을 쉽게 구현할 수 있기 때문이다.

  • ForEach 내부의 contentNavigationLink 로 감싸면 눌렀을 때 다른 View 로 이동할 수 있도록 할 수 있다. 단, 반드시 전체 View 체계에서 해당 View 혹은 그 상위의 ViewNavigationView 안에 들어가 있어야 navigating 할 수 있다

    • NavigationLink 에서 destination 으로 PaletteEditor 를 보낼 떄, $store.palettes[palette] 바인딩을 보냄으로써 source of truth 자체에 접근하고 있음에 유의
    • 개인적으로 와 진짜 바인딩 짱이다 싶었던 부분이 여기였다...어떻게 모델과 동기화할까 싶었는데 애초에 View 를 만들 때 바인딩을 인자로 받도록 해주면 되는 거였음...
  • 그리고 잘보면 navigationTitleNavigationView 가 아니라 그 내부에 달고 있는데 이는 NavigationView 가 자신이 현재 보여주고 있는 View 내부에서 정보를 찾기 때문(이후에 나올 toolbar 도 같은 이유로 안에 닮)
struct PaletteManager: View {
    ...
    var body: some View {
        NavigationView {
            List {
                ForEach(store.palettes) { palette in
                    NavigationLink(destination: PaletteEditor(palette: $store.palettes[palette])) {
                        VStack(alignment: .leading) {
                            Text(palette.name)
                            Text(palette.emojis)
                        }
                    }
                }
            }
            .navigationTitle("Manage Palettes")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
    ...
}

# @Environment

  • @Environment(\.pathForWantedVar) 을 써서 현재 View 가 있는 환경의 값들에 접근해서 local variable 을 바인딩함으로써 해당 값을 get/set 해서 활용할 수 있다.
    • e.g. 현재 colorScheme 을 불러와서 darkMode 에서는 글자 크기 키우기...특정 버튼을 누르면 darkMode 로 들어가게 하기 등...

# toolbar

  • 현재 ViewNavigationView 내부에 포함되어 있을 때 .toolbar(content:) 를 사용해서 쉽게 툴바를 만들어줄 수 있다.
  • 우리는 툴바에 두 가지 버튼을 넣을 건데, 하나는 팝업창을 닫는 버튼이고, 다른 하나는 편집 모드로 들어가게 하는 Edit 버튼..!

EditButton()

  • 내장된 EditButton() 이 있어 얘를 사용해줄건데, 현재 버튼을 누르면 environmentValues 중 하나인 EditMode 를 토글한다

close Button

  • environmentValues 중 하나인 presentationModeget 하는 지역변수를 선언(set 은 일부 프로퍼티들만 가능하며 별도 메서드 필요)해서 사용해줄건데 presentationMode 가 바인딩하고 있는 PresentationMode 가 현재isPresented == trueView 를 닫아주는 dismiss() 를 지원하기 때문

  • presentationMode 자체는 바인딩이다...! 하지만 @Binding 한 게 아니라 @Environment 로 데려와서 wrappedValue 안에 들어가여 프로퍼티에 접근할 수 있다. (여기 잘 이해 안간다...)

  • UIDevice.current.userInterfaceIdiom != .pad 이 줄은 교수님 설명에 의하면 아이패의 경우 팝업 창 밖의 영역을 누르면 자동으로 팝업이 닫혀 close 버튼 의 필요가 낮으므로 아이패드를 제외한 다른 기기에서만 해당 버튼이 나타나도록 하기 위한 코드
struct PaletteManager: View {
    ...
    // can only get using this property 
    // set requires another method 
    @Environment(\.presentationMode) var presentationMode
    ...
    var body: some View {
        NavigationView {
            List {
                // code for each Palette
            }
            .toolbar {
                ToolbarItem { EditButton() }
                ToolbarItem(placement: .navigationBarLeading) {
                    // need to access wrappedValue
                    // b/c presentataionMode is a binding
                    if presentationMode.wrappedValue.isPresented,
                       UIDevice.current.userInterfaceIdiom != .pad {
                        Button("Close") {
                            presentationMode.wrappedValue.dismiss()
                        }
                    }
                }
            }
        }
    }
    ...
}

# List 와 Editbutton 의 EditMode 동기화

  • @State private var editMode: EditMode = .inactive 와 같이 현재 View 에서 사용할 local variable 을 선언한 다음, .environment(_:_:) 를 이용해서 우리의 EditMode 를 우리의 local variable 에 바인딩해준다.
struct PaletteManager: View {
    ...
    @State private var editMode: EditMode = .inactive
    
    var body: some View {
        NavigationView {
            List {
                // some code... 
            }
            .toolbar {
                ToolbarItem { EditButton() }
                ...
            }
            .environment(\.editMode, $editMode)
    }
    ...
}
  • 코드가 위와 같을 때, .environment(\.editMode, $editMode)toolbar 보다 밑에 있으므로 .environment(\.editMode, $editMode)List toolbar 모두의 editMode set 한다. 즉, 우리의 local variable 에 바인딩 되어 있는 동일한 editMode 를 갖는다. 즉, 이러한 방식의 목적은 동기화!
    • 굳이 따로 동기화해주지 않아도 같은 환경에 있는 거 아닌가? 라고 생각했었는데 .environment(\.editMode, $editMode) 를 제거하면 애매한 버그가 있었다...

# EditMode 에서 팔레트 지우고 이동하기

  • 매우 다행히도 onDelete(perform:)onMove(perform:) 메서드를 사용하면 SwiftUI 가 각각 .remove(atOffsets:).move(fromOffsets:toOffset:) 메서드에 필요한 인자들을 알아서 보내줘서 쉽게 구현할 수 있었다...
struct PaletteManager: View {
    ...
    var body: some View {
        NavigationView {
            List {
                ForEach(store.palettes) { palette in
                    NavigationLink(destination: PaletteEditor(palette: $store.palettes[palette])) {
                        VStack(alignment: .leading) {
                            Text(palette.name)
                            Text(palette.emojis)
                        }
                        .gesture(editMode == .active ? tap : nil)
                    }
                }
                .onDelete { indexSet in
                    store.palettes.remove(atOffsets: indexSet)
                }
                .onMove { indexSet, newOffset in
                    store.palettes.move(fromOffsets: indexSet, toOffset: newOffset)
                }
            }
        }
    }
}

  • navigationLink 를 단 곳에 tapGesture 를 적용하면 전자가 후자에 의해 오버라이딩되어 양립이 불가하다. 하지만 editMode 가 켜졌을 때는 tap 시에 다른 작업을 수행하게 하고, 아닐 때는 그냥 navigationLink 가 작동하도록 해주고 싶을 수 있다. 이럴 때 별도의tap 제스처 를 선언해서 삼항연산자를 사용해 아래와 같은 꼼수를 쓸 수 있다!
    • 여기서는 별 내용 없지만 과제할 때 필요할 거라고 한다...

struct PaletteManager: View {
    ...
    var tap: some Gesture {
        TapGesture().onEnded { }
    }
    
    var body: some View {
        NavigationView {
            List {
                ForEach(store.palettes) { palette in
                    NavigationLink(destination: PaletteEditor(palette: $store.palettes[palette])) {
                        VStack {
                            // some code...
                        }
                        .gesture(editMode == .active ? tap : nil)  // 바로 여기!!!
                    }
                }
            }
        }
    }
}

# 배경이미지에 유효하지 않은 url 이 들어왔을 때 alert 띄우기!

  • url 로부터 데이터를 가져와서 UIImage 를 만드는 작업은 ViewModel 에서 하므로 데이터를 가져온ㄴ 데 실패하는 경우 backgroundImageFetchStatus = .fail 로 선언해준다.
class EmojiArtDocument: ObservableObject {
    ...
    enum BackgroundImageFetchStatus: Equatable {
        case idle
        case fetching
        case failed(URL) // L12 added
    }
    
    private func fetchBackgroundImageDataIfNecessary() {
        backgroundImage = nil
        switch emojiArt.background {
        case .url(let url):
            backgroundImageFetchStatus = .fetching
            DispatchQueue.global(qos: .userInitiated).async {
                let imageData = try? Data(contentsOf: url)
                DispatchQueue.main.async { [weak self] in
                    if self?.emojiArt.background == EmojiArtModel.Background.url(url) {
                        self?.backgroundImageFetchStatus = .idle
                        if imageData != nil {
                            self?.backgroundImage = UIImage(data: imageData!)
                        }
                        // L12 note failure if we couldn't load background image
                        if self?.backgroundImage == nil {
                            self?.backgroundImageFetchStatus = .failed(url)
                        }
                    }
                }
            }
        case .imageData(let data):
            backgroundImage = UIImage(data: data)
        case .blank:
            break
        }
    }
    ...
}
  • 그리고 EmojiArtDocumentView 에서는 alert(item:content:) 를 사용해서 알림창을 띄울건데, 알림창을 띄울 지 여부를 결정해 주고 Alert 를 띄우는 데 사용할 optional source of truth 가 필요하다...! 또한 정의를 보면 source of truthIdentifiable 해야 하므로 IdentifiableAlert 구조체 를 별도로 선언한 다음 @State private var alertToShow: IdentifiableAlert? 와 같이 선언해 여기에 바인딩해서 쓴다.
struct IdentifiableAlert: Identifiable {
    var id: String
    var alert: () -> Alert
}

struct EmojiArtDocumentView: View {
    ...
    var background: some View {
        GeometryReader { geometry in
            ZStack {

            }
            .alert(item: $alertToShow) { alertToShow in
                alertToShow.alert()
            }
        }
    }
}
  • 이제 optional source of truth 를 선언했으니, 배경 이미지 url 이 잘못된 경우 이 soure of truthnon-nil 하게 만들어 알림창을 띄울 수 있도록 해야한다! 이를 위해서는 onChange(of:perform:) 를 이용해서 ViewModelbackgroundImageFetchStatus = .failed(url) 인 경우 showBackgroundImageFetchFailedAlert(_:) 을 이용해 alertToShownon-nil 하게 만들어주면 끝!!!!!
struct EmojiArtDocumentView: View {
    ...
    var background: some View {
        GeometryReader { geometry in
            ZStack {

            }
            .alert(item: $alertToShow) { alertToShow in
                alertToShow.alert()
            }
            .onChange(of: document.backgroundImageFetchStatus) { status in
                switch status {
                case .failed(let url):
                    showBackgroundImageFetchFailedAlert(url)
                default:
                    break
                }
            }
        }
    }
    
    private func showBackgroundImageFetchFailedAlert(_ url: URL) {
        alertToShow = IdentifiableAlert(id: "fetch failed: " + url.absoluteString) {
            Alert(
                title: Text("Background Image Fetch"),
                message: Text("Couldn't load image from \(url)."),
                dismissButton: .default(Text("OK")))
        }
    }
    ...
}

☀️ 느낀점

  • 배운 게 정말 많은 강의였지만 그만큼 길이도 가장 길었고 포스팅도 쓰면서 끝이 없어서 진짜로 토할 것 같았다...ㅋㅋㅋ특히 약간 교수님이 SwiftUI 에는 이런 것도 있고 저런 것도 있고 이런저런 것도 있어..! 하고 2시간 내내 던져주시는 기분이라 너무...뷔페 갔을 때 너무 맛있게 먹고는 있지만 배불러서 체할 것 같은 그런 기분이었다...ㅎㅎ 포스팅도 마지막으로 갈수록 그냥...그냥 여기까지만 쓰자 싶은 맘과 싸우며 정말 간신히 썼다...

  • 오늘 배운 것 중에 가장 놀라웠던 건 @property wrapper 를 붙여서 선언한 변수들이 사실은 wrapped valueget/set 하는 compueted var 이었다는 점...좀 더 이런 저런 실험을 해보고 싶었는데 핑계지만 지쳐서 하지 못했다...

  • @Stateobject@ObservableObject 를 사실 막 혼용해서 쓰고 있었는데 전자는 source of truth , 후자는 reference to source of truth 라는 점을 배울 수 있었다...지난 날들의 실수가 주마등...처럼 스치고 지나갔다...
  • 그동안 코드를 짜면서 cs193p 과제나 혼자 개인 공부를 할 때 동기화에 대해서 되게 많이 고민헀는데 Binding 을 배우면서 source of truth 를 공유하는 법에 대해 배워 앞으로 코드가 좀 더 깔끔해질 수 있을 것 같다!
  • EnvironmentValue 에 대해서도 늘 너무 어렴풋이만 이해를 해서 한번쯤 공부해봐야지 했는데 오늘 강의를 들으면서 어떤 친구들이고 앞으로 어떤 부분들을 공부해보면 되겠구나 싶었다
profile
☀️

0개의 댓글

관련 채용 정보