모든 @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 }
}
single source of truth!
source of truth
cf. ObservableObject
는 reference to source of truth
@StateObject
를 사용하는 경우 ViewModel
의 생명 주기가 해당 View
의 생명주기에 연동되므로 주의해야 한다
wrappedValue
: ObservableObject 프로토콜
에 순응하는 것(i.e. ViewModel
)projectedValue
: wrappedValue
의 변수들에 대한 Binding
objectWillChange.send()
를 하면 현재 View
를 무효화함(버리고 다시 그림)wrappedValue
를 get/set
하고, 값이 바뀌면 기존 View
를 무효화single source of truth
를 여러 곳에서 공유하기 위해 쓴다source of truth
를 다른 어디에션가 받아오는 것이므로 절대 =
를 써서 지정해주지 않는다!!!View
에 주입하면 해당 View
를 포함한 모든 하위 View
에서 사용 가능ObservableObject 타입
별로 하나만 주입할 수 있다@EnvironmentObject
와 완전히 무관하다View
가 속해있는 환경을 나타내고 있어 Environment
라고 칭한다key path(i.e \.somePath)
를 사용해서 property wrapper
내부의 여타 변수들에 접근할 수 있음!!projected value
가 없다HStack
안에 다양한 옵션(수정, 추가, 삭제 등)이 들어있는 paletteControlButton
과 현재 선택된 테마의 이모지들을 쭉 보여주는 ScrollingEmojiView
(원래 알던 그 친구가 맞다) 를 담고 있는 body
가 담겨있는 형태다paletteControlButton
이고body
struct PaletteChooser: View {
...
var body: some View {
HStack {
paletteControlButton
body(for: store.palette(at: chosenPaletteIndex))
}
.clipped()
}
...
}
지난 시간에 이모지 팔레트를 위한 ViewModel
을 만들었으니, 이번에는 해당 ViewModel
을 사용하는 View
인 PaletteChooser
를 만들어 볼 차례다!
이를 위해서는 일단 가장 먼저 observe
할 viewModel
이 필요한데, 사실상 하나의 View
로 해결됐던 EmojiArtDocument
와 달리 PaletteChooser
는 여러 하위 View
로 구성되고, 이러한 하위 View
들 또한 ViewModel
을 필요로 한다. 따라서 기존의 ObservableObject
방식으로 ViewModel
을 선언해주기 보다는 가장 상단에 있는 EmojiArtApp 구조체
에서 @StateObject
를 사용해 ViewModel
을 source of truth로 선언해주고 하위의 모든 View
에서 @EnvironemntObject
로 받아 동일한 ViewModel
을 공유할 수 있도록 .environmentObject()
를 사용해서 주입해줬다!
EnvironmentObject
도 @ObservabedObject
와 마찬가지로 ObservableObject
에 변화가 생겨 wrappedValue
가 objectWillChange.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
...
}
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)
)
}
...
}
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!)
}
}
}
}
delete 버튼
은 쉬우니까...생략하고 나머지 기능들은 또 별도의 View
에 대한 설명이 필요하기 때문에 gotoMenu
부터 짚고 넘어가려고 한다. 이 친구의 기능은 일종의 바로 가기! 누르면 전체 팔레트 목록을 보여주고, 각 팔레트 이름을 누르면 현재 팔레트가 해당 팔레트로 변경된다!Menu 와 contextMenu
- 전자는 구조체, 즉 그 자체로
View
인 반면, 후자는View
를 반환하는property wrapper
다.Menu
는 기본적으로tapGesture
에 반응하지만contextMenu
는longTapGesture
에 반응한다- 조건에 따라 메뉴가 수행할 작업이 바뀐다면 후자, 일정하다면 전자
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")
}
}
}
contextMenu
에서 Edit
과 New
버튼을 누르면 현재 선택된/생성된 팔레트를 편집할 수 있는 팝업(i.e. PaletteEditor
)이 뜬다. .popover(item:content:)
를 써서 이러한 팝업을 만들건데 popover
를 쓰는 이유는 얘는 어디에서 popover
했는지 꼬리로 나타낼 수 있기 때문...!
이때 PaletteEditor
는 PaletteStore
의 Palette
를 수정하고자 하는 것이므로 바인딩 을 이용해 넘겨줌으로써 PaletteEditor
와 PaletteChooser
가 동일한 Palette
를 보고 있도록 해줘야 한다!
struct PaletteEditor: View {
@Binding var palette: Palette
...
}
.popover
를 쓰려면 팝업창을 띄울 지 말 지를 결정하는 source of truth
에 Binding
을 해서 써야 하는데 여기서는 Bool
이 아니고 <Item?>
에 바인딩했다. 왜냐하면 source of truth
가 nil
인 상태가 자연스러운 경우 Item?
에 바인딩하고 얘를 바로 인자로 넘겨주는 데 활용할 수 있어 코드가 더 간단해지기 때문!source of truth
가 되어줄 @State private var paletteToEdit: Palette?
을 선언해준 다음 contextMenu
에서 Edit
혹은 New
를 누르면 paletteToEdit
을 현재 선택한 팔레트로 변경해준다.paletteToEdit
이 non-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])
}
}
}
}
Section 구조체
를 사용해서 적절히 섹션을 나눌 수 있다. struct PaletteEditor: View {
...
var body: some View {
Form {
nameSection
addEmojisSection
removeEmojiSection
}
.navigationTitle("Edit \(palette.name)")
.frame(minWidth: 300, minHeight: 350)
}
...
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)
}
}
...
}
그럼 이모지를 추가할 때도 위에서처럼 똑같이 해주면 되는 거 아니야? 하고 생각할 수 있지만, 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)
}
}
}
}
팔레트에 있던 이모지를 삭제하기 위해서 LazyVGrid
안에서 ForEach()
를 이용해서 각 이모지를 UI
상에 나타낸 다음 .onTapGesture
를 사용해서 특정 이모지를 탭 하는 경우 지워지게 해줄거다...!
유의사항은 palette.emojis
는 String
이라 RandomAccessCollection
프로토콜에 순응하지 않아 ForEach()
를 할 수 없으므로 Array
로 map
해줘야한다
그리고 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))
}
}
...
}
contextMenu
에서 Manager
를 누르면 위와 같이 모든 팔레트가 뜨고, 각 팔레트를 누르면 해당 팔레트를 편집할 수 있는 PaletteEditor
로 넘어가며, Edit
버튼을 누르면 각 팔레트를 삭제하거나 이동할 수 있다.sheet(isPresented:content:)
를 사용할 건데, 이번에는 굳이 특정 팔레트에 국한되지 않으므로 popover
보다 sheet
이 더 자연스럽고, content
인 PaletteManger() 구조체
가 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()
}
}
...
}
각 팔레트를 나타내는 데는 List(content:)
를 쓸건데 VStack
을 써도 유사하게 구현은 할 수 있겠지만 List
를 쓰면 각 항목마다 자동으로 행이 구분되고, NavigationLink
를 사용해서 다른 View
로 넘어가게 하면 각 항목 끝에 화살표가 생겨서 시각적으로 더 이해가 빠르기 때문!
List
안에서 ForEach
를 사용할건데 굳이 ForEach
를 사용하는 이유는 .onDelete(perform:) 메서드
를 지원해 나중에 각 팔레트를 지우는 작업을 쉽게 구현할 수 있기 때문이다.
ForEach
내부의 content
를 NavigationLink
로 감싸면 눌렀을 때 다른 View
로 이동할 수 있도록 할 수 있다. 단, 반드시 전체 View
체계에서 해당 View
혹은 그 상위의 View
가 NavigationView
안에 들어가 있어야 navigating
할 수 있다
NavigationLink
에서 destination
으로 PaletteEditor
를 보낼 떄, $store.palettes[palette]
바인딩을 보냄으로써 source of truth
자체에 접근하고 있음에 유의View
를 만들 때 바인딩을 인자로 받도록 해주면 되는 거였음... navigationTitle
을 NavigationView
가 아니라 그 내부에 달고 있는데 이는 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(\.pathForWantedVar)
을 써서 현재 View
가 있는 환경의 값들에 접근해서 local variable
을 바인딩함으로써 해당 값을 get/set
해서 활용할 수 있다.colorScheme
을 불러와서 darkMode
에서는 글자 크기 키우기...특정 버튼을 누르면 darkMode
로 들어가게 하기 등... View
가 NavigationView
내부에 포함되어 있을 때 .toolbar(content:)
를 사용해서 쉽게 툴바를 만들어줄 수 있다. Edit
버튼..!EditButton()
이 있어 얘를 사용해줄건데, 현재 버튼을 누르면 environmentValues
중 하나인 EditMode
를 토글한다environmentValues
중 하나인 presentationMode
를 get
하는 지역변수를 선언(set
은 일부 프로퍼티들만 가능하며 별도 메서드 필요)해서 사용해줄건데 presentationMode
가 바인딩하고 있는 PresentationMode
가 현재isPresented == true
인 View
를 닫아주는 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()
}
}
}
}
}
}
...
}
@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)
를 제거하면 애매한 버그가 있었다... 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
로부터 데이터를 가져와서 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 truth
는Identifiable
해야 하므로 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 truth
를 non-nil
하게 만들어 알림창을 띄울 수 있도록 해야한다! 이를 위해서는 onChange(of:perform:)
를 이용해서 ViewModel
의 backgroundImageFetchStatus = .failed(url)
인 경우 showBackgroundImageFetchFailedAlert(_:)
을 이용해 alertToShow
를 non-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 value
를 get/set
하는 compueted var
이었다는 점...좀 더 이런 저런 실험을 해보고 싶었는데 핑계지만 지쳐서 하지 못했다...
@ViewBuilder
를 만나서 아...얘가 정확히 뭐해주는 애였더라 싶었는데 그냥 conainer View
를 만들어준다고 생각하면 된다...여기 가면 대충 이런 식으로 작동하는구나 알 수 있다@Stateobject
와 @ObservableObject
를 사실 막 혼용해서 쓰고 있었는데 전자는 source of truth
, 후자는 reference to source of truth
라는 점을 배울 수 있었다...지난 날들의 실수가 주마등...처럼 스치고 지나갔다... cs193p
과제나 혼자 개인 공부를 할 때 동기화에 대해서 되게 많이 고민헀는데 Binding
을 배우면서 source of truth
를 공유하는 법에 대해 배워 앞으로 코드가 좀 더 깔끔해질 수 있을 것 같다!EnvironmentValue
에 대해서도 늘 너무 어렴풋이만 이해를 해서 한번쯤 공부해봐야지 했는데 오늘 강의를 들으면서 어떤 친구들이고 앞으로 어떤 부분들을 공부해보면 되겠구나 싶었다