-
Chapter 15: In Practice: SwiftUI & CombineRaywenderlich/Combine: Asynchronous Programming 2020. 8. 17. 13:46
Version
Swift 5.3, iOS 14, Xcode 12
SwiftUI는 선언적(declaratively)으로 앱 UI를 구축(building)하기 위한 Apple의 새로운 패러다임(paradigm)으로, 기존(existing)의 UIKit 및 AppKit 프레임 워크(frameworks)와는 상당히(big) 다르다(departure). 사용자 인터페이스(user interfaces) 구축(building)을 위한 매우 간결(lean)하고 읽고(read) 쓰기(write) 쉬운 문법(syntax)을 제공한다.
Declarative syntax
SwiftUI 구문(syntax)은 작성(build)하려는 뷰(view) 계층(hierarchy)을 명확하게(clearly) 나타낸다:
HStack(spacing: 10) { Text("My photo") Image("myphoto.png") .padding(20) .resizable() }
계층 구조(hierarchy)를 쉽게 시각적으로(visually) 구문 분석(parse)할 수 있다.
HStack
뷰(view, horizontal stack)에는Text
뷰(view)와Image
뷰(view) 두 가지 하위 뷰(child views)가 있다.각 뷰(views)에는 여러 매개 변수(parameters)가 있을 수 있다. 예를 들어,
Text
는 화면(on-screen)에 표시할(display) 텍스트(text)를 나타내는String
매개 변수(parameter)가 있고,HStack
은 스택(stack) 하위(child) 뷰(views) 사이의 패딩(padding)을 설정하기 위한spacing
매개변수(parameter)가 있다.마지막으로, 각 뷰(view)는 단순히 뷰(view)에서 호출(call)하는 메서드(methods)인 수정자(modifiers) 목록(list)을 가질 수 있다. 위의 예제에서 뷰(view) 수정자(modifier)인
padding(20)
을 사용하여 이미지(image) 주위(around)에 20 포인트(points)의 패딩(padding)을 추가(add)한다. 또한resizable()
을 사용하여 이미지 내용(content)의 크기를 조정(resizing)할 수 있다. 언급했다시피, 이는 위의 샘플 코드에서와 같이 차례로 연결(chain)할 수 있는 뷰(view)에서 호출(call)하는 메서드(methods)일 뿐이다.Cross-platform
SwiftUI는 UI를 구축(build)하는 새로운 방법을 제공(offer)할뿐 아니라, 크로스 플랫폼(cross-platform) UI 구축(building)에 대한 접근 방식(approach)을 통합(unifies)한다. SwiftUI 코드는 iOS, macOS, tvOS 및 나머지 플랫폼 간에 동일하게 유지되며, 구현(implementation)은 지원되는(supported) 각 플랫폼(platforms)의 다양한 요구 사항(needs)을 처리한다. 예를 들어,
Picker
컨트롤(control)은 iOS 앱에서 새로운 모달 뷰(modal view)를 표시하여 사용자가 목록(list)에서 항목(item)을 선택(pick)할 수 있도록 하지만, macOS에서는 동일한Picker
컨트롤(control)이 드랍박스(dropbox)를 표시한다.데이터 양식(form)의 간단한(quick) 코드 예제는 다음과 같다:
VStack { TextField("Name", text: $name) TextField("Proffesion", text: $profession) Picker("Type", selection: $type) { Text("Freelance") Text("Hourly") Text("Employee") } }
이 코드는 iOS에서 두 개의 별개(separate) 뷰(views)를 생성한다. 유형(Type) picker 컨트롤(control)은 다음과 같은 옵션(options) 목록(list)이 있는 별도(separate)의 화면(screen)으로 사용자를 이동시키는(taking) 버튼(button)이 된다:
그러나 macOS에서는 SwiftUI가 Mac의 충분한(abundant) UI 화면(screen) 공간(space)을 고려하여, 드롭 다운(drop-down) 메뉴(menu)가 있는 단일(single) 양식(form)을 만든다:
New memory model
UIKit 및 AppKit을 사용할 때 데이터 모델(data model)과 views를 지속적으로(constantly) 미세 관리(micromanage)하여 동기화(sync)를 유지해야 한다. 애초에 이것이 뷰 컨트롤러(view controller)를 사용해야 하는 이유이다. 뷰(views, 사용자가 화면에서 보는 것)와 데이터(data, 디스크 또는 메모리에 있는 것) 사이의 "glue" 역할을 하는 클래스(class)가 필요하다.
반면에 SwiftUI를 사용하면, 사용자 인터페이스(user interfaces)를 구축(building)하는 새로운 접근 방식(approach)을 채택(adopt)해야 한다. 그리고 앞으로 위에서 설명한(described) 방식보다 이 새로운 접근 방식(approach)이 훨씬 낫다는 것을 알려줄 것이다.
SwiftUI에서 화면에 렌더링(rendered)되는 사용자 인터페이스(user interface)는 데이터의 함수(function)이다. "단일 진실 공급원(source of truth)"라고 하는 데이터의 단일(single) 사본(copy)을 유지하고, UI는 해당 단일 데이터 소스(source)에서 동적으로(dynamically) 파생(derived)된다. 이렇게 하면 UI는 항상 앱의 상태(state)와 함께 최신 상태가 유지된다(up-to-date). 또한 인터페이스(interface)를 구축하는 데 더 높은(higher) 추상화(abstraction)를 사용함으로써, 지원되는 모든 운영 체제(operating systems)에서 프레임 워크(framework)가 많은 핵심(nitty-gritty) 세부 사항(details) 구현(implementation)을 처리 할 수 있도록 허용(supported)한다.
이미 Combine에 대한 확실한(solid) 경험(experience)이 있기 때문에, SwiftUI를 사용해 publishers를 앱의 UI에 연결(plug)하는 방법에 대한 생각으로 가득차 있을 것이다.
Hello, SwiftUI!
이전 섹션(section)에서 이미 언급했듯이(established), SwiftUI를 사용하면 사용자 인터페이스(user interface)를 선언적(declaratively)으로 설명(describe)하고 렌더링(rendering)을 프레임 워크(framework)에 맡긴다.
UI에 대해 선언(declare)하는 각 views(text labels, images, shapes 등)는
View
프로토콜(protocol)을 준수(conform)한다.View
의 유일한 요구 사항(requirement)은body
라는 속성(property)이다.데이터 모델(data model)을 변경(change)할 때마다, SwiftUI는 각 뷰(views)에 현재
body
표현(representation)을 요청(asks)한다. 이는 최신(latest) 데이터 모델(data model)의 변경(changing)에 따라 달라질 수 있다. 그런 다음 프레임 워크(framework)는 모델(model) 변경(changes)의 영향을 받는(affected) 뷰(views)만 계산(calculating)하고, 화면에(on-screen) 렌더링(render)할 뷰(view) 계층(hierarchy)을 구축(builds)하여 고도로(highly) 최적화되고(optimized) 효과적인(effective) 드로잉(drawing) 메커니즘(mechanism)을 만들어 낸다.실제로 SwiftUI는 다음과 같이 데이터 모델(data model)의 변경(changes)으로 실행(triggered)되는 UI "snapshots"을 만든다:
UI를 관리(manage)하는 이 새로운 방법을 사용하면, 사용자 인터페이스(user interface) 업데이트(update)에 대한 생각을 그만둘 수 있다. 대신 화면에(on-screen) 표시되는(represented) 데이터 조각에 초점을 맞추고(focus on), SwiftUI가 뷰(views)를 새로 고침(refresh)할 때마다 효과적으로(effectively) 변경(mutate)해야 한다.
이 장(chapter)에서는 몇 가지 SwiftUI 기본 사항(basics)과 함께, Combine과 SwiftUI 간의 상호 운용(interoperations)을 모두 다루는 여러 작업을 수행(cover)한다.
Memory management
믿거나 말거나(believe it or not), 위의 모든 변화를 만드는 가장 큰 부분(part)은 메모리 관리(management)가 UI에서 작동하는 방식을 바꾸는(shift) 것이다.
SwiftUI는 데이터 모델(data model)과 UI 모두에서 상태(state)를 복제(duplicating)하는 대신, UI를 모델(model) 상태(state)의 함수(function)로 만들 수 있는 몇 가지 멋진 새 구문(syntax)의 도움으로 새로운 개념(concept)을 도입(introduces)한다. 이를 사용해 "단일 진실 공급원(source of truth)"이라는 한 곳에 데이터를 보관(keep)할 수 있다.
No data duplication
그것이 무엇을 의미하는지 예를 살펴 본다. UIKit/AppKit으로 작업할 때, 코드를 대략적으로(in broad strokes) 데이터 모델(data model), 일종의 컨트롤러(controller), 뷰(view)로 구분해야 한다:
이 세 가지 유형(types)은 몇 가지(several) 유사한(similar) 특징(features)을 가질 수 있다. 데이터 저장소에 포함되고, 변경될 수 있으며 참조 유형이 될 수 있다(They include data storage, they can be mutated, they can be reference types and more).
현재 날씨를 화면에(on-screen) 표시(display)하는 예제에서, 해당 목적(purpose)을 위해
Weather
라는 구조체(struct)를 모델(model) 유형(type)으로 사용하고 현재 상태(conditions)는conditions
이라는 텍스트(text) 속성(property)에 저장되어 있다고 가정해 본다. 해당 정보(information)를 사용자(user)에게 표시(display)하려면,UILabel
이라는 다른 유형(type)의 인스턴스(instance)를 생성하고conditions
의 값(value)을 label의text
속성(property)에 복사(copy)해야 한다.이제 작업하는 값(value)의 복사본(copies)이 두 개 있다. 하나는 모델(model) 유형(type)에 있고, 다른 하나는 화면에(on-screen) 표시(displaying)할 목적(purpose)으로
UILabel
에 저장(stored)된다:text
와conditions
사이에는 연관성(connection)이나 바인딩(binding)이 없다. 필요한 모든 곳에 단순히String
값(value)을 복사(copy)하기만 하면 된다.이제 UI에 종속성(dependency)을 추가했다. 화면에 표시되는(on-screen) 정보(information)의 신선도(freshness)는
Weather.conditions
에 따라(depends) 다르다.conditions
속성(property)이 변경될 때마다(whenever)Weather.conditions
의 새 복사본(copy)으로 레이블(label)의text
속성(property)을 수동으로(manually) 업데이트(update)하는 것은 개발자의 책임(responsibility)이다.SwiftUI는 데이터를 화면에(on-screen) 표시하기(showing) 위해, 데이터를 복제(duplicating)할 필요가 없다. UI에서 데이터 저장소(data storage)를 오프로드(offload)할 수 있으므로, 모델(model) 한 곳(single place)에서 데이터를 효과적으로(effectively) 관리(manage)할 수 있으며, 앱 사용자(users)가 화면에서(on-screen) 오래된(stale) 정보(information)를 볼 수 없도록 한다.
Less need to "control" your views
추가적으로 모델(model)과 뷰(view) 사이에 "glue" 코드를 사용할 필요가 없어지면, 대부분의 뷰 컨트롤러(view controller) 코드를 제거할 수 있다(get rid of).
이 장(chapter)에서 배우게 될 내용은 다음과 같다:
- 선언적(declarative) UI 구축(building)을 위한 SwiftUI 기본(basics) 구문(syntax)의 간략한(briefly) 설명.
- 다양한(various) 유형(types)의 UI 입력(inputs)을 선언(declare)하고, 이를 "단일 진실 공급원(sources of truth)"에 연결(connect)하는 방법.
- Combine을 사용하여 데이터 모델(data models)을 구축(build)하고, 데이터를 SwiftUI로 연결(pipe)하는 방법.
Experience with SwiftUI
안타깝게도 이 장(chapter)에서는 SwiftUI를 자세히(detail) 다룰(cover) 수 없다. 물론 SwiftUI에 대해 자세히 알지 못하더라도 이 장(chapter)의 예제를 작업하고 지침(instructions)을 따를 수 있지만, SwiftUI에 대한 실질적인 통찰(insight)이나 경험(experience)을 한다면 훨씬 더 유익(beneficial)할 것이다 .
이 장에서 구축(build)한 내용이 흥미롭고 SwiftUI에 대해 더 자세히 알고 싶다면, SwiftUI by Tutorials(https://bit.ly/2L5wLLi)로 더 심층적인(in-depth) 학습을 할 수 있다.
또한, iOS Animations by Tutorials(https://bit.ly/2MaW6UB)에는 SwiftUI로 애니메이션을 만드는 데 초점(focusing)을 둔 두 개의 장(chapters)이 있다.
이제 Combine with SwiftUI 할 차례이다.
Getting started with "News"
이 장의 시작(starter) 프로젝트(project)에는 Combine과 SwiftUI에 집중(focus on)할 수 있도록 몇 가지 코드가 포함(includes)되어 있다. 즉, 실제 UI 레이아웃(layout)은 이미 배치되어 있다(laid out). 구문(syntax) 레이아웃(layout)은 이 장(chapter)의 범위(scope)를 벗어난다.
- 이 프로젝트(project)에는 다음과 같은 폴더(folders)도 포함되어 있다:
- App에는 app과 scene delegates가 포함되어 있다.
- Network에는 지난 장(chapter)에서 완성(completed)한 Hacker News API가 포함되어 있다.
- Model에는
Story
,FilterKeyword
,Settings
과 같은 간단한 모델(model) 유형(types)이 있다. 또한 여기에는 main newsreader view가 사용하는 모델(model) 유형(type)인ReaderViewModel
가 있다. - View에는 app views가 포함(contains)되어 있으며, View/Helpers 내부에는 버튼(buttons), 배지(badges) 등과 같은 몇 가지 간단한 재사용 가능한(reusable) 구성 요소(components)가 있다.
- 마지막으로 Util에는 디스크(disk)에서 JSON 파일을 쉽게 읽고(read) 쓸(write) 수 있는 도우미(helper) 유형(type)이 있다.
완성된(completed) 프로젝트(project)는 Hacker News stories 목록(list)을 표시(display)하고, 사용자(user)가 키워드(keyword) 필터(filter)를 관리(manage)할 수 있도록 한다:
A first taste of managing view state
시작(starter) 프로젝트(project)를 빌드(build)하고 실행(run)하면, 화면에 빈(empty) 테이블(table)과 "Settings"이라는 제목(titled)의 single bar button이 표시된다:
여기서부터 시작한다. 데이터 변경(changes)과 UI가 상호 작용(interacting)하는 방법을 학습하기 위해, 설정(Settings) 버튼(button)을 탭하면(tapped)
SettingsView
가 표시되도록 한다.기본(main) 앱(app) 인터페이스(interface)를 표시하는(displaying)
ReaderView
뷰(view)가 포함된 View/ReaderView.swift를 연다. 데이터 변경(changes)을 통해 UI를 구동(driving)한다는 것은 직접(directly) 메서드(methods)를 호출(calling)하거나, UI 컨트롤(controls)에 데이터를 설정(setting)하지 않는다는 것을 의미(means)한다.이미 간단한 부울(Boolean) 값(value)인
presentingSettingsSheet
속성(property)이 포함되어 있다. 이 값(value)을 변경하면 settings view가 표시(present)되거나 해제(dismiss)된다. 소스 코드를 아래로 스크롤(scroll down)하여// Set presentingSettingsSheet to true here
주석(comment)을 찾는다.이 주석(comment)은 설정(Settings) 버튼(button) 콜백(callback)에 있으므로, Settings view를 표시(present)하기에 완벽한 위치이다. 주석(comment)을 다음으로 바꾼다:
self.presentingSettingsSheet = true
이 행(line)을 추가하자마자, 다음 오류(error)가 표시된다:
뷰(view)의
body
가 동적(dynamic) 속성(property)이므로ReaderView
를 변경할 수 없기(cannot mutate) 때문에,self
를 변경할 수 없다(self is immutable).메모리(memory) 관리(management)에 대해 한 번 더 간단히(quickly) 언급해 보자면, SwiftUI는 주어진 속성(properties)이 상태(state)의 일부에 속하며 이러한 속성(properties)을 변경(changes)하면 새 UI "snapshot"을 실행(trigger)해야 함을 나타내는 데 도움이 되는 여러 내장(built-in) 속성(property) 래퍼(wrappers)를 제공(offers)한다.
그것이 실제로(practice) 무엇을 의미하는지 확인해 본다. 일반(plain) 속성(property)인
presentingSettingsSheet
를 다음과 같이 조정(adjust)한다:@State var presentingSettingsSheet = false
@State
속성(property) 래퍼(wrapper)는:- 속성(property) 저장소(storage)를 뷰(view) 밖으로 이동하므로,
presentingSettingsSheet
를 수정(modifying)해도self
가 변경(mutate)되지 않는다. - 속성(property)을 로컬 저장소(local storage)로 표시한다. 즉, 뷰(view)가 데이터 조각(piece of data)을 소유(owned)하고 있음을 나타낸다(denotes).
@Published
와 비슷하게$presentingSettingsSheet
라는 publisher를ReaderView
에 추가하여 속성(property)을 구독(subscribe)하거나 UI 컨트롤(controls) 또는 기타 views에 바인딩(bind)하는 데 사용할 수 있다.
presentingSettingsSheet
에@State
를 추가하면, 컴파일러(compiler)가 비 변형(non-mutating) 컨텍스트(context)에서 이 특정(particular) 속성(property)을 수정(modify)할 수 있다는 것을 알게 되므로 오류(error)가 사라진다(clear).마지막으로
presentingSettingsSheet
를 사용하여, 새 상태(state)가 UI에 어떤 영향을 미치는지(affects) 선언(declare)해야 한다. 이 경우 뷰(view) 계층(hierarchy)에sheet(...)
뷰(view) 수정자(modifier)를 추가하고,$presentingSettingsSheet
를 sheet에 바인딩(bind)한다.presentingSettingsSheet
를 변경(change)할 때마다, SwiftUI는 현재 값(value)을 가져와(take) 부울(boolean) 값(value)을 기반으로(based on) view를 표시(present)하거나 해제(dismiss)한다.// Present the Settings sheet here
주석(comment)을 찾아 다음으로 교체(replace)한다:.sheet(isPresented: self.$presentingSettingsSheet, content: { SettingsView() })
sheet(isPresented:content:)
수정자(modifier)는Bool
publisher를 받아, presentation publisher가true
를 내보낼(emits) 때마다 view를 렌더링(render)한다.프로젝트(project)를 빌드(build)하고 실행(run)한다. 설정(Settings)을 탭(tap)하면, 새 프레젠테이션(presentation)에 대상(target) view가 표시된다:
sheet(...)
수정자(modifier)가 iOS 13에서 새로운 sheet presentation 스타일(style)을 사용하기 때문에,SettingsView
아래의ReaderView
에서 상단(top) 가장자리(edge) 유무에 유의한다.presentingSettingsSheet
를false
로 설정하면SettingsView
가 해제(dismiss)된다. 그러나 지금은 코드를 그대로 둔다(leave). 현재 앱은 표시된(presented) views를 자동으로(automatically) 해제(dismisses)하는 기본(default) 방법인 아래로 스와이프(swipe-down) 동작(gesture)을 사용한다.Fetching the latest stories
다음으로 Combine 코드로 돌아가야 할 시간이다. 이 섹션(section)에서는 기존의
ReaderViewModel
을 결합하여,API
네트워킹(networking) 유형(type)에 연결(connect)한다.Model/ReaderViewModel.swift를 연다. 상단에 다음을 삽입(insert)한다:
import Combine
이 코드를 사용하면 자연스럽게 ReaderViewModel.swift에서 Combine 유형(types)을 사용할 수 있도록 한다. 이제 모든 구독(subscriptions)을 저장(store)하기 위해
ReaderViewModel
에 새로운subscriptions
속성(property)을 추가한다:private var subscriptions = Set<AnyCancellable>()
모든 준비(prep) 작업을 마쳤으므로 이제 새로운 메서드(method)를 만들고, 네트워크(network) API를 사용할 때이다.
ReaderViewModel
에 다음 빈(empty) 메서드(method)를 추가한다:func fetchStories() { }
이 메서드(method)는
API.stories()
를 구독(subscribe)하고 모델(model) 유형(type)에 서버의 응답(response)을 저장(store)한다. 이전 장(chapter)에서 구현한 이 메서드(method)에 대해 잘 숙지하고(be familiar with) 있어야 한다.fetchStories()
내부에 다음을 추가한다:api .stories() .receive(on: DispatchQueue.main)
receive(on:)
연산자(operator)를 사용하여 기본(main) 대기열(queue)에서 출력(output)을 수신(receive)한다. 논쟁의 여지없이(arguably), 스레드(thread) 관리(management)를 API 소비자(consumer)에게 맡길 수 있다. 그러나ReaderViewModel
의 경우는 확실하게(certainly)ReaderView
이므로 여기에서 최적화(optimize)하고, 기본 대기열(main queue)로 전환(switch)하여 UI 변경(changes)을 수행할(committing) 준비(prepare for)를 한다.다음으로
sink(...)
을 사용하여, 모델(model)의 stories와 내보낸(emitted) 모든 오류(errors)를 저장(store)한다. 다음을 추가(append)한다:.sink(receiveCompletion: { completion in if case .failure(let error) = completion { self.error = error } }, receiveValue: { stories in self.allStories = stories self.error = nil }) .store(in: &subscriptions) //저장
먼저
completion
이 실패(failure)인지 확인한다. 그렇다면, 관련(associated) 오류(error)를self.error
에 저장(store)한다. stories publisher로부터 값(values)을 받은(receive) 경우,self.allStories
에 저장(store)한다.이 섹션(section)에서 모델(model)에 추가할 논리(logic)는 이것뿐이다. 이제
fetchStories()
메서드(method)가 완료(complete)되었으며, 화면(on screen)에ReaderView
를 표시하는 모델(model)을 즉시(as soon as) "start-up"할 수 있다.이를 위해 App/SceneDelegate.swift를 열고
ReaderView
를 앱(app) 창(window)의 기본 뷰(main view)로 설정한 코드를 찾는다. 찾고있는 코드는 다음과 같이if
로 래핑(wrapped)된다:if let windowScene = scene as? UIWindowScene { ... }
if
본문(body)의 맨 끝에 다음 코드를 추가한다:viewModel.fetchStories()
현재
ReaderViewModel
은 실제로ReaderView
에 연결(hooked up)되어 있지 않으므로, 화면(on-screen)에 변경 사항(change)이 표시되지 않는다. 모든 것이 예상대로(expected) 작동하는지 빠르게(quickly) 확인하려면, 다음을 수행한다. Model/ReaderViewModel.swift로 돌아가서allStories
속성(property)에didSet
처리기(handler)를 추가한다:private var allStories = [Story]() { didSet { //제대로 작동하는지 확인하기 위한 임시 코드 print(allStories.count) } }
앱을 실행(run)하고 콘솔(Console)을 확인한다. 다음과 같은 출력(output)이 표시되어야 한다:
1
2
3
4
...앱을 실행(run)할 때마다 표시되는 출력(output)을 보고 싶지 않은 경우, 방금 추가 한
didSet
처리기(handler)를 제거(remove)할 수 있다.Using ObservableObject for model types
이 섹션에서 모델(model)을
ReaderView
에 연결(hooking up)하는 작업을 수행한다. 적절한(proper) 메모리(memory) 관리(management)를 사용하여 데이터 모델(data model) 유형(type)을 SwiftUI view에 바인딩(bind)하려면, 모델(model)이ObservableObject
를 준수(conform)하도록 해야한다.ObservableObject
는 해당 유형(types)이 단일(single) 요구 사항(requirement)을 준수(conform)해야 한다. 해당 유형(type)의 상태(state)가 변경(change)될 때마다 방출(emits)하는objectWillChange
라는 publisher가 있어야 한다.ObservableObject
는objectWillChange
의 기본(default) 구현(implementation)을 제공(provides)한다. 따라서 간단한 사용 사례(use cases)의 경우, 기존(existing) 모델(model) 코드를 조정(adjust)할 필요도 없다. 유형(type)에ObservableObject
구현(conformance)을 추가하면@Published
속성(properties)이 방출(emit)될 때마다, 기본(default) 프로토콜(protocol) 구현(implementation)이 자동(automatically)으로 방출(emit)된다.너무 쉬운 것(easy enough)처럼 들리지만, 정말 그렇다.
먼저 ReaderViewModel.swift 상단에서 SwiftUI를 가져온다:
import SwiftUI
그런 다음
ReaderViewModel
이ObservableObject
를 구현(conformance)하기 위해, 클래스(class) 정의(definition)를 다음과 같이 변경(alter)한다:class ReaderViewModel: ObservableObject {
모델(model)의 좀 더 난해한(esoteric) 동작(behavior)을 구현(implement)하려면, 고유한(own)
objectWillChange
선언(declaration)을 추가할 수 있다. 그러나 이 장(chapter)에서는 기본값(default)을 사용한다.다음으로 데이터 모델(data model)의 어떤 속성(properties)이 상태(state)를 구성(constitute)하는지 고려(consider)해야 한다. 현재
sink(...)
subscriber에서 업데이트(update)하는 두 가지 속성(properties)은allStories
와error
이다. 이러한 상태 변화(state-change)가 가치(worthy) 있는 것으로 여길 수 있다.Note:
filter
라는 세 번째 속성(property)도 있다. 여기서는 잠시 무시(ignore)하고, 나중에 다시 설명할 것이다.다음과 같이
@Published
속성(property) 래퍼(wrapper)를 포함(include)하도록allStories
를 조정(adjust)한다:@Published private var allStories = [Story]()
그런 다음
error
에 대해서도 동일하게 수행한다:@Published var error: API.Error? = nil
ObservableObject
의 단순함(simplicity)을 즐길 수 있다. 고유(own)의 데이터 코드(data code)가 작동하는 방식에 대한 가정(assumptions)을 하지 않고, 모든 유형(types)의 변경 사항(changes)을 감지(detect)하는 기능(ability)을 추가하는 제네릭(generic) 다목적(all-purpose) 프로토콜(protocol)이다. 이보다 더 쉬울 수는 없다(It doesn't get any easier than this).ReaderViewModel
이 이제ObservableObject
를 준수(conforms)하므로, 이 섹션(section)의 마지막 단계(step)는 데이터 모델(data model)을ReaderView
에 바인딩(bind)하는 것이다.View/ReaderView.swift를 열고
@ObservedObject
속성(property) 래퍼(wrapper)를var model: ReaderViewModel
에 다음과 같이 추가한다:@ObservedObject var model: ReaderViewModel
뷰(view)를 초기화(initializing)할 때 더 이상 모델(model)을 주입(inject)하지 않는다. 대신, 상태(state)가 변경(changes)될 때마다 view가 최신 데이터를 수신(receive)하고 새 UI "snapshot"을 생성하도록 모델(model)을 바인딩(bind)한다.
@ObservedObject
래퍼(wrapper)는 다음을 수행한다:- view에서 속성(property) 저장소(storage)를 제거(removes)하고, 대신 원본(original) 모델(model)에 대한 바인딩(binding)을 사용한다. 즉, 데이터를 복제(duplicate)하지 않는다.
- 속성(property)을 외부 저장소(external storage)로 표시(marks)한다. 즉, view가 데이터 조각(piece of data)를 소유(owned)하지 않음을 나타낸다(denotes).
@Published
및@State
와 마찬가지로 속성(property)에 publisher를 추가하여 구독(subscribe)하거나(and/or) view 계층(hierarchy) 아래(further down)에서 바인딩(bind)할 수 있다.
@ObservedObject
를 추가하여model
을 동적으로(dynamic) 만들었다. 이는 뷰 모델(view model)이 Hacker News 서버에서 stories를 가져올(fetches) 때, 모든 업데이트(updates)를 수신하게 된다는 것을 의미한다. 실제로 지금 앱을 실행(run)하면, 뷰 모델(view model)에서 stories를 가져 오면서(fetched) view가 새로 고침(refresh) 되는 것을 볼 수 있다:깔끔(neat)하게 구현되었다. 단순(simple)해 보이는 게 아니라 실제로 그렇다.
Displaying errors
가져온(fetched) stories를 표시하는 것과 동일한 방식으로 오류(errors)를 표시(display)한다. 현재 뷰 모델(view model)은 화면의(on-screen) UI 알림(alert)에 바인딩(bind)할 수 있는
error
속성(property)에 모든 오류(errors)를 저장(stores)한다.View/ReaderView.swift를 열고
// Display errors here
. 주석(comment)을 찾는다. 이 주석(comment)을 다음 코드로 바꾸어, 모델(model)을 alert view에 바인딩(bind)한다:.alert(item: self.$model.error) { error in Alert(title: Text("Network error"), message: Text(error.localizedDescription), dismissButton: .cancel()) }
alert(item:)
수정자(modifier)는 화면의(on-screen) 알림(alert) 표시(presentation)를 제어(controls)한다. 이는item
이라는 선택적(optional) 출력(output)이 있는 바인딩(binding)이 필요하다. 바인딩(binding)이nil
이 아닌(non-nil) 값(value)을 내보낼(emits) 때마다 UI는 alert view를 표시한다.모델(model)의error
속성(property)은 기본적(default)으로nil
이며, 모델(model)이 서버에서 stories를 가져오는(fetching) 동안 오류가 발생하면nil
이 아닌(non-nil) 오류(error) 값(value)으로 설정된다. 이는alert(item:)
의 입력(input)으로error
를 직접 바인딩(bind)할 수 있으므로, 알림(alert)을 표시하는 이상적인(ideal) 시나리오(scenario)이다.이를 테스트(test)하려면, Network/API.swift를 열고
baseURL
속성(property)을 잘못된(invalid) URL(예:https://123hacker-news.firebaseio.com/v0/
)로 수정(modify)한다.앱을 다시 실행(run)하면 stories 엔드 포인트(endpoint)에 대한 요청(request)이 실패하는 즉시(as soon as) 오류(error) 알림(alert)이 표시된다:
다음 섹션(section)으로 넘어가기 전에, 잠시 시간을 내어
baseURL
의 변경 사항(changes)을 되돌리면(revert) 앱이 다시 서버에 성공적으로(successfully) 연결(connects)된다.Subscribing to an external publisher
단일 publisher를 구독(subscribe)하고 SwiftUI view에서 해당 값(values)을 수신(receive)하는 것 뿐인 경우처럼, 간혹
ObservableObject
/ObservedObject
구현(route)을 따르고 싶지 않을 때도 있다.onReceive(_)
라는 특수한(special) 뷰(view) 수정자(modifier)를 사용할 수 있기 때문에, 이와 같은 간단한(simpler) 상황(situations)에서는ReaderViewModel
에서 했던 것처럼 추가(extra) 유형(type)을 만들 필요가 없다. 이렇게 하면, view 코드에서 publisher를 직접(directly) 구독(subscribe)할 수 있다.외부(external) publisher를 view의 생성자(initializer)에 전달(passing)하여 주입(inject)하거나, view 내부(inside)에 publisher를 만들 수 있다. 어느 쪽이든(either way), 새 출력(output) 값(value)을 받을(receiving) 때, 수행할 작업을 자유롭게 결정(decide)할 수 있다. 이를 무시(ignore)하거나 어떻게든 처리(process)하거나(and/or) view 상태(state)를 변경하고 새 UI "snapshot"을 실행(trigger)할 수 있다.
지금 앱을 실행(run)하면 각 stories에 작성자(author)의 이름과 함께(alongside) 상대적(relative) 시간(time)이 포함(included)되어 있음을 알 수 있다.
상대적(relative) 시간(time)은 사용자(user)에게 story의 "freshness"을 즉시(instantly) 전달(communicate)하는 데 유용하다. 그러나 일단(once) 화면에(on-screen) 렌더링(rendered)되면, 잠시 후 정보는 부정확해(stale)진다. 사용자(user)가 앱을 오랫동안(for a long time) 열어두면 "1 minute ago"은 꽤 오랜 시간동안 그대로 일(off) 수 있다.
이 섹션(section)에서는 타이머(timer) publisher를 사용해, 규칙적인(regular) 간격(intervals)으로 UI 업데이트(updates)를 실행(trigger)하여 각 행(row)이 정확한(correct) 시간을 다시 계산하고(recalculate) 표시할 수 있도록 한다.
현재 코드가 작동하는 방식은 다음과 같다:
ReaderView
에는 뷰(view) 생성시 현재 날짜(date)로 한 번 설정되는currentDate
라는 속성(property)이 있다.- stories 목록(list)의 각 행(row)은
currentDate
값(value)을 사용하여 작성자(author)와 시간(time) 정보(information)를 컴파일(compiles)하는PostedBy(time:user:currentDate:)
뷰(view)를 포함(includes)하고 있다.
화면의 정보(information)를 주기적으로(periodically) "refresh"하기 위해, 새로운 타이머(timer) publisher를 추가한다. 이는 방출(emits)할 때마다,
currentDate
를 업데이트(update)한다. 또한 이미 짐작(guessed)했듯이, view의 상태(state)에currentDate
를 추가하여 변경 될 때 새 UI "snapshot"을 실행(trigger)한다.publishers를 작업하려면, 먼저 ReaderView.swift 상단에 다음을 추가한다:
import Combine
그런 다음
ReaderView
에 새로운 publisher 속성(property)을 추가하여, 누구나 구독(subscribes)하는 즉시(as soon as) 사용할 수 있는 새 타이머(timer) publisher를 만든다.private let timer = Timer.publish(every: 10, on: .main, in: .common) .autoconnect() .eraseToAnyPublisher()
책의 앞부분에서 이미 배웠듯이
Timer.publish(every:on:in:)
은 connectable publisher를 반환(returns)한다. 이것은 활성화(activate)하기 위해 subscribers가 연결(connect)해야하는 일종의 "휴면(dormant)" publisher 이다. 그러나 이 경우에는 view가 첫 번째 "snapshot"을 생성(generates)하자마자(as soon as) 타이머(timer)를 구독(subscribe)하므로 복잡한(complex) connectable 논리(logic)가 필요하지 않다.autoconnect()
를 사용하여 publisher가 처음 구독(subscribed)할 때, 자동으로(automatically) "awake" 하도록 지시(instruct)한다.이제 남은 것은 타이머(timer)가 방출(emits)될 때마다
currentDate
를 업데이트(update)하는 것이다.sink(receiveValue:)
subscriber와 매우 유사하게 작동하는 SwiftUI 수정자(modifier)인onReceive(_)를
호출(call)한다. 조금 아래로 스크롤(scroll)하여// Add timer here
주석(comment)을 다음으로 바꾼다(replace):.onReceive(timer) { self.currentDate = $0 }
타이머(timer)는 현재 날짜(date)와 시간(time)을 내보내(emits)므로, 해당 값(value)을 가져와
currentDate
에 할당(assign)하면 된다. 이렇게 하면 이미 익숙한(familiar) 오류(error)가 발생한다:이러한 현상(happens)은 변경되지 않는(non-mutating) 컨텍스트(context)에서 속성(property)을 변경(mutate)할 수 없기 때문에 발생한다. 이전과 마찬가지로(just as before), 뷰(view)의 로컬 저장소(local storage) 상태(state)에
currentDate
를 추가하여 이 문제를 해결(solve)한다.다음과 같이
@State
속성(property) 래퍼(wrapper)를 속성(property)에 추가한다:@State var currentDate = Date()
이러한 방식으로
currentDate
에 대한 모든 업데이트(update)는 새 UI "snapshot"을 실행(trigger)하여 각 행(row)이 story의 상대적(relative) 시간(time)을 다시 계산(recalculate)하고 필요한 경우(if necessary) 텍스트를 업데이트(update)하도록 한다.앱을 다시 실행(run)하고 시뮬레이터(Simulator) 또는 기기(device)에서 연다. 맨 위의 story가 게시(posted)된 지 얼마나 되었는지 기억(note)해 둔다:
1분 이상 기다리면, 표시되는(visible) 행(rows)이 현재(current) 시간(time)으로 정보(information)를 업데이트(update)하는 것을 확인할 수 있다. 주황색(orange) 시간 배지(badge)는 여전히 story가 게시된(posted) 시간을 표시(show)하지만, 제목(title) 아래의 텍스트(text)는 정확하게 "... minutes ago" 텍스트(text)로 업데이트(update)된다:
view에 속성(property)으로 publisher를 할당하는 것 외에도, view의 초기화(initializer) 또는 환경(environment)을 사용해 Combine 모델(model) 코드의 publisher를 뷰(view)에 주입(inject)할 수 있다. 그런 다음 위와 같은 방식으로
onReceive(...)
를 사용하면 된다.//iOS 업데이트 되면서 작동 안함. SwiftUI에서 forEach에 문제 있다는 듯. VStack에서 위로 올리면 되긴 하는데, 그러면 여러번 호출 됨
https://forums.raywenderlich.com/t/chapter-15-subscribing-to-an-external-publisher/105056/4
Initializing the app's settings
이 부분에서는
Settings
뷰(view)를 작업한다. UI 자체를 작업하기 전에, 먼저Settings
유형(type) 구현(implementation)을 완료(finish)해야 한다.Model/Settings.swift를 열면 현재 유형(type)이 거의 비어 있는(bare bones) 것을 볼 수 있다. 여기에는
FilterKeyword
값(values) 목록(list)의 단일(single) 속성(property)을 포함하고 있다.이제 Model/FilterKeyword.swift를 연다.
FilterKeyword
는 main reader view에서 stories 목록(list)에 대한 필터(filter)로 사용할 단일 키워드(keyword)를 래핑(wraps)하는 도우미(helper) 모델(model) 유형(type)이다. SwiftUI 코드에서 이러한 유형(types)을 사용할 때처럼, 각 인스턴스(instance)를 고유하게(uniquely) 식별(identify)하는 데 사용할 수 있는id
속성(property)이 있는(requires)Identifiable
을 준수(conforms)한다. Network/API.swift와 Model/Story.swift의API.Error
및Story
의 정의(definitions)를 각각 자세히 살펴보면, 이러한 유형(types)도Identifiable
을 준수(conform)하고 있음을 알 수 있다.한 번 더 이전 내용을 반복(merry-go-round, 회전목마)한다. 단순하고(plain) 오래된(old) 모델(model)인
Settings
를 Combine 및 SwiftUI 코드와 함께 사용할 수 있는 최신(modern) 유형(type)으로 전환(turn)해야 한다.Model/Settings.swift 상단에 다음을 추가하여 시작한다:
import Combine
그런 다음
@Published
속성(property) 래퍼(wrapper)를keywords
에 추가한다:@Published var keywords = [FilterKeyword]()
이제 다른 유형(types)이
Settings
객체(object)의 현재 keywords를 구독(subscribe)할 수 있다. 또한 keywords 목록(list)을 바인딩(binding)을 허용하는 뷰(view)에 연결(pipe)할 수도 있다.마지막으로, 뷰(views)에서
Settings
을 관찰하거나(observed) SwiftUI 환경(environment)에 주입(injected)할 수 있도록, 다음과 같이 유형(type)이ObservableObject
를 준수(conform)하도록 한다:final class Settings: ObservableObject {
ObservableObject
가 구현(conformance)되도록 하기 위해 다른 것을 추가할 필요가 없다. 기본(default) 구현(implementation)은$keywords
publisher가 수행될 때마다 내보낸다(emit).이것이 몇 가지 간단한 단계(steps)로
Settings
의 모델(model) 유형(type)을 전환(turned into)하는 방법이다. 이제 앱의 나머지 반응형(reactive) 코드에 연결(plug)할 수 있다.Settings
을 바인딩(bind)하려면, scene delegate에서 인스턴스화(instantiate)하고,ReaderViewModel
에 바인딩(bind)한다. App/SceneDelegate.swift를 열고 기존(existing) import 문에 다음을 추가한다:import Combine
다음으로
scene(_:willConnectTo:options:)
의 맨 위에 다음을 삽입(insert)한다:let userSettings = Settings()
평소와 같이 구독(subscriptions)을 저장(store)하려면 cancelable 컬렉션(collection)이 필요하다.
SceneDelegate
의window
속성(property) 바로 아래에 추가한다:private var subscriptions = Set<AnyCancellable>()
이제
Settings.keywords
를ReaderViewModel.filter
에 바인딩(bind)하여 사용자가 keywords 목록(list)을 편집(edits)할 때마다, main view가 초기(initial) keywords 목록(list)뿐만 아니라 업데이트 목록(list)도 수신(receive)하도록 할 수 있다.scene(_:willConnectTo:options:)
에서let viewModel = ReaderViewModel()
행(line) 뒤에 삽입(insert)한다:userSettings.$keywords .map { $0.map { $0.value }} .assign(to: \.filter, on: viewModel) .store(in: &subscriptions) //저장
[FilterKeyword]
를 출력(outputs)하는userSettings.$keywords
를 구독(subscribe)하고, 각 keyword의value
속성(property)을 가져와[String]
에 매핑(map)한다. 그런 다음 결과 값을viewModel.filter
에 할당(assign)한다.바인딩(binding)한 뷰 모델(view model)이 상태(state)의 일부이기 때문에,
Settings.keywords
의 내용(contents)을 변경(alter)할 때마다 궁극적으로(ultimately)ReaderView
의 새로운 UI "snapshot"이 생성(generation)된다.그리고
ReaderViewModel
상태(state)의 일부가 되도록filter
속성(property)을 추가해야 한다. 이렇게 하면 keywords 목록(list)을 업데이트(update)할 때마다, 새 데이터가 view로 전달(relayed)된다.이렇게 하려면 Model/ReaderViewModel.swift를 열고,
filter
에 다음과 같이@Published
속성(property) 래퍼(wrapper)를 추가한다:@Published var filter = [String]()
이제
Settings
에서 뷰 모델(view model)로의 완전한(complete) 바인딩(binding)이 완료되었다.이는 다음 섹션(section)에서
Settings
뷰(view)를Settings
모델(model)에 연결(connect)하여, 사용자가 keyword 목록(list)을 변경(change)하면 바인딩(bindings)과 구독(subscriptions)의 전체(whole) 체인(chain)을 실행(trigger)하여 궁극적으로(ultimately) 다음과 같이 main app view의 story 목록(list)을 새로 고침(refresh)할 수 있기 때문에 매우 편리하다:Editing the keywords list
이 장(chapter)의 마지막 부분에서는 SwiftUI 환경(environment)을 살펴볼 것이다. 환경(environment)은 view 계층 구조(hierarchy)에 자동으로(automatically) 주입되는(injected) publishers의 공유 풀(shared pool)이다.
System environment
환경(environment)에는 현재 달력(current calendar), 레이아웃 방향(layout direction), 언어(locale), 현재 시간대(time zone) 등과 같은 시스템에서 주입한(injected) publishers가 포함(contains)된다. 보다시피, 그것들은 모두 시간이 지남(over time)에 따라 변할(change) 수 있는 값(values)이다. 따라서 view의 종속성(dependency)을 선언(declare)하거나 상태(state)에 포함(include)하면, 종속성(dependency)이 변경(changes)될 때 view가 자동으로(automatically) 다시 렌더링(re-render)된다.
시스템 설정(system settings) 중 하나를 관찰(observing)하려면, View/ReaderView.swift를 열고
ReaderView
에 새 속성(property)을 추가한다.@Environment(\.colorScheme) var colorScheme: ColorScheme
@Environment
속성(property) 래퍼(wrapper)를 사용하여,colorScheme
속성(property)에 연결해야 하는 환경(environment) 키(key)를 정의(defines)한다. 이제 이 속성(property)은 view 상태(state)의 일부이다. 시스템(system)의 외관(appearance) 모드(mode)가 밝음(light)과 어두움(dark) 사이에서 변경(changes)될 때마다, SwiftUI는 view를 다시 렌더링(re-render)한다.또한 view 본문(body)에서 최신(latest) 색 구성표(color scheme)에 접근(access)할 수 있다. 따라서 밝고(light) 어두운(dark) 모드(mode)에서 각각 다르게 렌더링(render)할 수 있다.
아래로 스크롤(scroll down)하여 story의 링크(link) 색상(color)을 설정(setting)하는
.foregroundColor(Color.blue)
행(line)을 찾는다. 해당 행(line)을 다음으로 바꾼다:.foregroundColor(self.colorScheme == .light ? .blue : .orange) //설정의 모드에 따라 리스트의 링크 색상이 바뀐다.
이제
colorScheme
의 현재 값(value)에 따라 링크(link)가 파란색 또는 주황색이 된다.시스템 외관(system appearance)을 어둡게(dark) 변경하여, 이 새로운 코드를 확인해 본다. Xcode에서 Debug ▶ View Debugging ▶ Configure Environment Overrides...를 열거나 Xcode의 하단 툴바(bottom toolbar)에서 Environment Overrides 버튼(button)을 누른다. 그런 다음 인터페이스 스타일(Interface Style) 옆의 스위치(switch)를 전환(toggle)한다.
어두운(dark) 외관(appearance) 모드(mode)를 유지할 수 있다. 그러나 이 책에서는 스크린샷(screenshots)이 더 잘 보이게 하기 위해 다시 밝은(light) 외관(appearance)으로 전환(switch)했다.
Custom environment objects
@Environment(_)
를 사용해 시스템 설정(system settings)을 관찰(observing)하는 것이 SwiftUI 환경(environment)이 제공(offer)하는 전부는 아니다. 실제로 객체(objects)를 환경화(environment-ify)할 수도 있다.깊이(deeply) 중첩된(nested) view 계층 구조(hierarchies)가 있는 경우 이는 매우 편리(handy)하다. 모델(model) 또는 다른 공유 리소스(shared resource)를 환경(environment)에 삽입하면(Inserting), 실제 필요한 데이터가 깊이(deeply) 중첩된(nested) view에 있을 경우 이에 도달 할 때까지 여러(multitude) 뷰(view)를 의존성 주입(dependency-inject)할 필요가 없다.
뷰(view)의 환경(environment)에 삽입(insert)한 객체(objects)는 해당 view의 모든 하위 뷰(child view)와 해당 뷰(view)에서 연결(linked)되는 모든 뷰(view)에서 자동으로(automatically) 사용할 수 있다.
이는 사용자(user)의 story filter를 사용할 수 있도록, 앱의 모든 뷰(views)와 사용자(user)의
Settings
을 공유(sharing)할 수 있는 좋은 기회(opportunity)이다.앱에서 main view를 생성하는 지점(spot)은 scene delegate이다. 이전에(previously) 여기서
Settings
의userSettings
인스턴스(instance)를 만들고 해당$keyword
를ReaderViewModel
에 연결(bound)했다. 이제userSettings
를 환경(environment)에도 주입(inject)한다.App/SceneDelegate.swift를 열고, 다음 행(line)을 대체하여(replacing)
environmentObject
수정자(modifier)를ReaderView
생성(creation)에 연결(attach)한다. 아래 부분을:let rootView = ReaderView(model: viewModel)
아래로 교체한다:
let rootView = ReaderView(model: viewModel) .environmentObject(userSettings)
environmentObject
수정자(modifier)는 view 계층 구조(hierarchy)의 환경(environment)에 주어진 객체(object)를 삽입(inserts)하는 view 수정자(modifier)이다. 이미Settings
인스턴스(instance)가 있으므로, 해당 인스턴스를 환경(environment)으로 보내기(send)만하면 완료(done)된다.다음으로 사용자 정의(custom) 객체(object)를 사용할 views에 환경(environment) 종속성(dependency)을 추가해야 한다. View/SettingsView.swift를 열고
@EnvironmentObject
래퍼(wrapper)로 새 속성(property)을 추가한다:@EnvironmentObject var settings: Settings
settings
속성(property)은 자동으로(automatically) 환경(environment)의 최신(latest) 사용자 설정(user settings)으로 채워진다(be populated with).자체(own) 객체(objects)의 경우에는 시스템 환경(system environment)과 같은 키 경로(key path)를 지정할(specify) 필요가 없다.
@EnvironmentObject
는 속성(property) 유형(type)(이 경우에는Settings
)을 환경(environment)에 저장된(stored) 객체(objects)와 일치(match)시키고, 올바른 객체(objects)를 찾는다.이제 다른 view의 상태(states)와 마찬가지로,
settings.keywords
를 사용할 수 있다. 값(value)을 직접(directly) 가져(get) 오거나 구독(subscribe)하거나 다른 뷰(views)에 바인딩(bind)할 수 있다.SettingsView
의 기능(functionality)을 완료(complete)하기 위해, keywords 목록(list)을 표시(display)하고 목록(list)에서 keywords를 추가(adding), 편집(editing), 삭제(deleting)를 구현한다.다음 행(line)을 찾아:
ForEach([FilterKeyword]()) { keyword in
다음으로 변경한다:
ForEach(settings.keywords) { keyword in
업데이트(updated)된 코드는 화면(on-screen) 목록(list)에 filter keywords를 사용할 것이다. 그러나 사용자(user)가 새 keywords를 추가할 방법이 없기 때문에, 이 목록(list)은 여전히 비어(empty) 있다.
시작(starter) 프로젝트(project)에는 keywords를 추가하기 위한 view가 포함되어(includes) 있다. 따라서 사용자(user)가 + 버튼(button)을 탭(taps)할 때 표시(present)만 하면 된다. + 버튼(button) 동작은
SettingsView
의addKeyword()
로 설정되어 있다.private
addKeyword()
메서드(method)로 스크롤(scroll)하여 그 안에(inside) 추가한다:presentingAddKeywordSheet = true
presentingAddKeywordSheet
는 이 장(chapter)의 앞부분에서 이미 작업 한 것과 매우 유사하게 알림(alert)를 표시(present)하기 위한 게시된(published) 속성(property)dl다..sheet(isPresented:$presentingAddKeywordSheet)
에서 선언(declaration)을 확인할 수 있다.지금 앱(app)을 실행(run)하고
Settings
뷰(view)로 이동하면, 디버거(debugger)에서 다음과 같은 충돌(crash)이 표시된다:환경(environment) 객체(object)를 삽입한(inserted) view와 연결된(linked) views의 하위(children) 뷰(view)만
Settings
을 자동으로(automatically) 수신(receiving)하기 때문이다.sheet(...)
수정자(modifier)로 표시된 views는 환경(environment) 객체(objects)를 가져 오지 않는다.그러나 이것은 바인딩(binding) 객체(objects)를 한 번 더 연습(exercise)할 수 있는 좋은 기회이다. 해야할 일은 코드를 사용해 수동으로(manually) 환경(environment) 객체(object)로 settings을 주입(inject)하는 것이다.
View/ReaderView.swift로 전환(switch)하여
SettingsView
를 표시(present)하는 지점(spot)을 찾는다. 이는SettingsView()
와 같이 새 인스턴스(instance)를 생성하는 한 줄(line)이다.ReaderView
에 settings을 주입(injected)한 것과 같은 방법으로, 여기에서도 그것들을 주입(inject)할 수 있다.ReaderView
에 새 속성(property)을 추가한다:@EnvironmentObject var settings: Settings
그런 다음
SettingsView()
바로 아래에.environmentObject
수정자(modifier)를 추가한다..environmentObject(self.settings)
이제
Settings
에 대한ReaderView
종속성(dependency)을 선언(declared)하고, 해당 종속성(dependency)을 환경(environment)을 통해SettingsView
에 전달(passed)한다. 이 특별한(particular) 경우에는SettingsView
의init
에 매개 변수(parameter)로 전달했을 수도 있다.계속하기 전에, 앱을 다시 실행(run)한다. Settings을 탭하면
SettingsView
팝업(pop up)을 볼 수 있어야 한다. 이는 사용자(user) 설정(settings)이 환경(environment)을 통해 제시된(presented) 뷰(view)로 올바르게(correctly) 전달(passed)되었으며, 더 이상 런타임(runtime) 오류(complaining)가 발생하지 않는다는 것을 의미한다.이제 View/SettingsView.swift으로 다시 전환(switch back )하고, 처음 의도한 대로 목록(list) 편집(editing) 작업을 완료(complete)한다.
sheet(isPresented: $presentingAddKeywordSheet)
내부(Inside)에 새AddKeywordView
가 이미(already) 생성되어 있다. 시작(starter) 프로젝트(project)에 포함된 사용자 정의(custom) view로, 사용자(user)가 새 키워드(keyword)를 입력하고 버튼(button)을 눌러(tap) 목록(list)에 추가할 수 있다.AddKeywordView는 사용자(user)가 새 키워드(keyword)를 추가하기 위해 버튼(button)을 탭(taps)할 때 호출되는 콜백(callback)을 받는다.
AddKeywordView
의 빈(empty) 완료(completion) 콜백(callback)에 다음을 추가한다:let new = FilterKeyword(value: newKeyword.lowercased()) self.settings.keywords.append(new) self.presentingAddKeywordSheet = false
새 키워드(keyword)를 만들어 사용자(user) settings에 추가하고, 마지막으로 표시된(presented) sheet를 닫는다(dismiss).
여기의 목록(list)에 키워드(keyword)를 추가하면, settings 모델(model) 객체(object)가 업데이트(update)되고 차례로 reader view 모델(model)이 업데이트(update)되어
RenderView
도 새로 고쳐(refresh)진다. 코드에 선언(declared)된 대로 모두 자동으로(automatically) 작동한다.SettingsView
로 마무리(wrap up)하기 위해, 키워드(keywords) 삭제(deleting) 및 이동(moving)을 추가한다.// List editing actions
을 찾아 다음으로 바꾼다:.onMove(perform: moveKeyword) .onDelete(perform: deleteKeyword)
이 코드는 사용자(user)가 키워드(keywords) 중 하나를 목록(list)에서 위(up) 또는 아래(down)로 이동(moves)할 때
moveKeyword()
를 처리기(handler)를 설정하고, 사용자(user)가 키워드(keywords)를 삭제(delete)하기 위해 오른쪽(right)으로 스와이프(swipes)할 때deleteKeyword()
를 처리기(handler)로 설정한다.현재 비어(empty)있는
moveKeyword(from:to:)
메서드(method)에 다음을 추가한다:guard let source = source.first, destination != settings.keywords.endIndex else { return } settings.keywords .swapAt(source, source > destination ? destination : destination - 1)
그리고
deleteKeyword(at:)
에 다음을 추가한다:settings.keywords.remove(at: index.first!)
목록(list)에서 편집(editing)을 활성화(enable)하는 데 필요한 것은 이것뿐이다. 마지막으로 앱을 빌드(build)하고 실행(run)하면 키워드(keywords) 추가(adding), 이동(moving), 삭제(deleting)를 포함한 story 필터(filter)를 완벽하게(fully) 관리(manage)할 수 있다:
Note: 현재 시점에서 편집(editing) 모드(mode)는 약간 어색(clunky)하다. 행(row)을 삭제(delete)하려면 Edit를 누르고(tap), 부분적으로 스와이프(swipe)한 다음 Edit을 다시 눌러(tap) 항목(items)을 다시 정렬해야(re-order)할 수 있다.
또한 story 목록(list)으로 다시 이동하면, 애플리케이션 전체에 구독(subscriptions)및 바인딩(bindings)과 함께 settings이 전파되고(propagated), 목록(list)에는 필터(filter)와 일치하는 stories만 표시(displays)된다. 제목(title)에는 일치하는(matching) stories의 수도 표시된다:
Challenges
이 장(chapter)에는 완전히(completely) 선택적인(optional) 두 가지 SwiftUI 연습(exercises)이 포함되어 있다. 나중을 위해 제쳐두고 다음 장의 더 흥미로운 Combine 주제(topics)로 이동할 수도 있다.
Challenge 1: Displaying the filter in the reader view
첫 번째 챌린지(challenge)에서는
ReaderView
의 story 목록(list) 헤더(header)에 필터(filter) 키워드(keywords) 목록(list)을 삽입(insert)한다. 현재 헤더(header)에는 항상 "Showing all stories"가 표시된다. 사용자가 추가한 키워드(keywords) 목록(list)을 표시(display)하도록 해당 텍스트(text)를 다음과 같이 변경한다:Challenge 2: Persisting the filter between app launches
시작(starter) 프로젝트(project)에는
loadValue(named:)
및save(value:named:)
의 두 가지 메서드(methods)를 제공하는JSONFile
이라는 도우미(helper) 유형(type)이 포함되어(includes) 있다.이 유형(type)을 사용하여 다음을 수행한다:
- 사용자(user)가
Settings.keywords
에didSet
처리기(handler)를 추가하여, 필터(filter)를 수정(modifies)할 때마다 키워드(keywords) 목록(list)을 디스크(disk)에 저장(save)한다. Settings.init()
의 디스크(disk)에서 키워드(keywords)를 불러온다(load).
이렇게 하면 실제 앱처럼, 앱 런칭(launches) 사이에 사용자의 필터(filter)가 지속(persist)된다.
이러한 과제(challenges) 중 해결책(solution)이 확실하지 않거나, 도움이 필요하면 projects/challenge 폴더(folder)에서 완성된(finished) 프로젝트(project)를 살펴볼 수 있다.
Key points
SwiftUI를 사용하면 UI가 상태(state)의 함수(function)가 된다. 다른 view 종속성(dependencies) 중에서, view의 상태(state)로 선언된(declared) 데이터에 대한 변경 사항(changes)을 적용(committing)하여 UI가 자체적으로 렌더링(render)되도록 한다. SwiftUI에서 상태(state)를 관리(manage)하는 다양한(various) 방법을 배웠다:
- 뷰(view)에 로컬(local) 상태(state)를 추가하려면,
@State
를 사용하고 Combine 코드에서 외부(external)ObservableObject
에 대한 종속성(dependency)을 추가하려면@ObservedObject
를 사용한다. onReceive
뷰(view) 수정자(modifier)를 사용하여, 외부(external) publisher를 직접(directly) 구독(subscribe)한다.@Environment
를 사용하여 시스템 제공 환경 설정(system-provided environment settings) 중 하나에 종속성(dependency)을 추가하고, 사용자 정의(custom) 환경(environment) 객체(objects)에 대한@EnvironmentObject
를 추가한다.
'Raywenderlich > Combine: Asynchronous Programming' 카테고리의 다른 글
Chapter 17: Schedulers (0) 2020.08.31 Chapter 16: Error Handling (0) 2020.08.26 Chapter 14: In Practice: Project "News" (0) 2020.08.16 Chapter 13: Resource Management (0) 2020.08.15 Chapter 12: Key-Value Observing (0) 2020.08.14