ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Chapter 15: In Practice: SwiftUI & Combine
    Raywenderlich/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)된다:

     

    textconditions 사이에는 연관성(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) 유무에 유의한다.

    presentingSettingsSheetfalse로 설정하면 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가 있어야 한다.

    ObservableObjectobjectWillChange의 기본(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

    그런 다음 ReaderViewModelObservableObject를 구현(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)은 allStorieserror이다. 이러한 상태 변화(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)는 다음을 수행한다:

    1. view에서 속성(property) 저장소(storage)를 제거(removes)하고, 대신 원본(original) 모델(model)에 대한 바인딩(binding)을 사용한다. 즉, 데이터를 복제(duplicate)하지 않는다.
    2. 속성(property)을 외부 저장소(external storage)로 표시(marks)한다. 즉, view가 데이터 조각(piece of data)를 소유(owned)하지 않음을 나타낸다(denotes).
    3. @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.swiftAPI.ErrorStory의 정의(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)이 필요하다. SceneDelegatewindow 속성(property) 바로 아래에 추가한다:

    private var subscriptions = Set<AnyCancellable>()

    이제 Settings.keywordsReaderViewModel.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) 여기서 SettingsuserSettings 인스턴스(instance)를 만들고 해당 $keywordReaderViewModel에 연결(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) 동작은 SettingsViewaddKeyword()로 설정되어 있다.

    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) 경우에는 SettingsViewinit에 매개 변수(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.keywordsdidSet 처리기(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를 추가한다.
Designed by Tistory.