본문 바로가기
iOS/Combine

#Chapter15 In Practice: Combine & SwiftUI

by HaningYa 2020. 11. 2.
728x90

SwiftUI 의 장점에 대한 내용

  • Declarative syntax
    • view 는 계층에 따라 시각적으로 쉽게 파싱할 수 있다. (Hstack 안에 VStack 등등)
    • view는 타입에 따라 파라미터를 가진다. (Text - String, HStack - spacing)
    • view는 modifier들을 가진다.
  • Cross-platform
    • 각 플랫폼별 UI를 개발하는 통일화된 방법을 제시한다.
    • 각각 플랫폼별 특징에 따라 튜닝만 해주면 된다. (Picker Controller)
  • New memory model (view controller out)
    • data model 과 view 를 싱크맞춰주기 위해서 view controller 가 필요했다.
    • SwiftUI는 화면에 표시되는 UI 는 일종의 데이터에 대한 함수이다. 단 하나의 source of truch 라고 불리는 데이터를 바탕으로 UI는 그 single data source를 동적으로 화면에 반영한다.

Hello, SwiftUI!

UI를 위해 선언한 각각의 view 들은 View protocol 을 준수한다. View protocol 이 필요로 하는 것은 body 프로퍼티이다.

데이터 모델이 변경될 때 마다 현재 body의 내용을 업데이트 시킨다.

이제 어떻게 UI를 업데이트 할건지 고민하기 보다 어떤 데이터가 화면에 표시될지 집중하기만 하면 된다.

Memory management

이런 것들을 가능하게 해주는 것은 SwiftUI의 memory management 때문이다.

SwiftUI는 데이터 모델과 UI의 데이터를 중복되게 하는 것이 아닌 UI를 model state 의 함수처럼 만들어 준다.

No data duplication

이 말뜻은 무엇이냐면 UIKit에는 DataModel View controller View 3개 가 있는데 하는 일이 비슷하다. 전부 데이터를 가지고 있고 수정될 수 있으며 reference type 이 될 수 도 있다. 예를들어 현재 날씨를 화면에 표시하고 싶을 때 Weather이라는 모델을 만들어서 Condition이라는 텍스트 프로퍼티에 날씨를 저장해 뚜고 UILabel instance 를 만들어 UILabel의 text 프로퍼티에 Whether.condition을 할당해 준다.

이렇게 되면 날씨에 관해 2개의 중복되는 데이터가 생긴 것이다.

또한 두개의 데이터는 binding 되있지 않기 때문에 한쪽이 수정되도 다른 한쪽은 update 되지 않는다.

SwiftUI는 이러한 중복되는 데이터들을 정리하고 효과적으로 한곳에 데이터를 모으면서 문제점을 해결했다.

less need to control your views

추가적으로 view controller 가 필요없다. 

이번장에서는 

  • SwiftUI 간단한 문법과
  • UI input 을 선언하고 source of truth 에 connect 하는 방법
  • data model 을 만들고 SwiftUI 에 공급하는 방법을 배운다.

Getting started with "News"

UI는 이미 그려진 상태입니다.

  • App : appdelegate, scenedelegate
  • Network : Hacker News API
  • Model : Story, FilterKeyword, Settings, ReaderViewModel
  • View : app views, buttons, badges
  • Util : JSON R/W

A first taste of managing view state

먼저 Settings 화면부터 구성하겠습니다.

SettingsView.Swift 파일을 보면 presentingAddKeywordSheet 라는 Bool 변수를 볼 수 있습니다.

이 변수를 바꾸는게 settings view 를 present, dismiss 하는 것을 결정합니다.

그래서 다시 SettingView를 띄우는 ReaderView 로 와서 button callback 에 해당 변수를 다르게 해주면 error 가 발생합니다.

self immutable 합니다, 왜냐하면 view body는 dynamic property이기 때문에 ReaderView 를 mutate 할 수 없습니다.

다시말해 구조체의 값을 변경하려면 mutation을 사용할 수 있지만 SwiftUI 에서는 computed property 를 사용하기 때문에 그렇게 할 수 없습니다.


Dynamic property?

An interface for a stored variable that updates an external property of a view.

developer.apple.com/documentation/swiftui/dynamicproperty

 

Apple Developer Documentation

 

developer.apple.com


SwiftUI memory management 에 대해 다시 보자면 SwiftUI는 주어진 properties들이 view state 의 한 부분이고 그런 state에 연관된 properties 의 변화는 새로운 UI snapshot 을 trigger 합니다. SwiftUI는 이런 과정을 도와주는 여러가지 내장된 property wrapper 를 제공합니다.

이것이 실제로 어떤걸 뜻하는지 알아보겠습니다.

ReaderView 에서 그냥 변수였던 var presentingSettingsSheet에 @State property wrapper 를 달아줍니다.

307

  1. property 저장공간은 view 바깥으로 옮겨 presentingSettingsSheet를 수정하는게 self를 수정하는게 아닌 것으로 만들어 줍니다.
  2. local storage 로 프로퍼티를 마킹하는 것은 다른 말로는 데이터가 view 에 의해 소유된다는 뜻 입니다.
  3. publisher를 추가하면 (@Published 와 같이) ReaderView는 $presentingSettingsSheet를 호출해 다른 view 에서 프로퍼티를 구독하거나 UI control, other view 에 bind 할 수 있습니다.

일단 @State attribute 가 presentingSettingsSheet 에 추가되면 컴파일러는 non-mutating context에서 해당 프로퍼티를 수정할 수 있는걸 알기 때문에  에러는 사라집니다.

이제 이 프로퍼티를 통해 ReaderView 에서 sheet 을 띄웁니다. 

Fetching the latest stories(308)

이제 combine 을 사용해 API 에서 데이터를 다운받겠습니다.

ReaderViewModel 에서 작업합니다.

  1. import Combine 해주고
  2. ViewModel 의 모든 subscription들을 담을 subscription Set 을 만듭니다.
  3. network API 를 사용할 새로운 메서드를 만듭니다.
  4. 이 메서드에서 API.storeis()를 구독하여 서버로 부터 받은 응답을 model type 에 맞게 저장합니다.
  5. receive(on:) operator 를 사용해 main queue 에서 나오는 모든 output 을 받습니다.
  6. API 소비자에게 thread 관리를 맡겨도 되지만 ReaderViewModel의 경우 ReaderView(UI) 에서 쓸게 당연하기 때문에 미리 main queue 로 바꿔주어 UI 를 수정하는 것에 대한 준비를 마칩니다.
  7. 다음 sink subscriber를 통해 model.Append 에서 나오는 데이터와 에러를 저장합니다.
    1. 먼저 completion이 실패했는지 확인하고 실패했을 경우 self.error 에 저장합니다.
    2. completion 이 성공하여 데이터를 받아올 경우 self.allStories에 저장합니다.
  8. SceneDelegate.swift 에서 ReaderView가 rootView가 되는 부분에 방금 작성한 fetching method 를 실행합니다.
    (근데 view 가 뜨기 전에 먼저 fetching 을 받아와야 되는거 아닌가??)

앱을 실행해서 확인해보기 위해 받아온 데이터 갯수를 출력시키면 앱을 실행했을때 데이터를 받아오는 것을 볼 수 있다.

Using ObservableObject for model types (311)

model과 ReaderView를 연동하는데 있어서 필요한건 data model 과 SwiftUI view를 서로 적절한 memory management이다. 

그러기 위해선 model이 ObservableObject 프로토콜을 준수하게 만들어야 한다.

ObservableObject프로토콜을 준수하려면 objectWillChange 라는 publisher 를 무조건 가지고 있어야 한다.

또한 ObservableObject는 objectWillChange의 디폴트 구현을 제공하기 때문에 따로 해당 publisher 를 구현해줄 필요는 없고 @Published 프로퍼티가 emit 할때 자동으로 emit 한다.

이제 ViewModel 을 ObservableObject를 conform 하게 실제로 구현해 보면

  1. import SwiftUI
  2. add ObservableObject protocol
  3. 따로 추가적인 behavior가 필요하다면 본인만의 objectWillChange를 선언해서 사용할 수 있다.
  4. 어떤 데이터 모델의 어떤 프로퍼티가 state를 나타내는지 고려한다.
  5. 현재 view 에서 update 해주는 것은 allStories 와 error 이다. 이 둘을 기준을 삼는다.
  6. allStories 와 error 를 @Published 로 감싸준다.
  7. 이제 ReaderViewModel 이 ObservableObject를 준수하니 ReaderView 에 데이터 모델을 바인딩 해주자

view 를 생성할때 model을 inject 해줄 필요가 없다. 대신 model 을 bind하여 state가 바뀌는 언제든, view 가 수정사항을 감지하여 새로운 UI snapshot 을 생성할 것 이다.

@ObservedObject wrapper 는 다음과 같은 역할을 한다.

  1. property 저장소를 view에서 제거하고 원본 모델에 바인딩해서 사용한다. (no duplication of data)
  2. external storage 라고 마킹하여 데이터가 view에 의해 소유되지 않는다는걸 표현한다.
  3. @Published 와 @State 같이 프로퍼티에게 publisher 를 추가해 해당 값에 대해 구독할 수 있게 해준다.

@ObservedObject를 추가함으로써 model 을 동적으로 만들어 봤다.

이 말은 이제 model에서 story를 fetch 해올때마다 앱의 UI 가 데이터를 바로바로 반영할 수 있다는 뜻이다.

Displaying errors(313)

만약 fethcing 에 실패하면 error를 표시해야 한다.

방금 story를 표시했던 방법과 비슷하게 구현해볼 껀데 이번엔 UI alert 를 띄워본다.

ReaderView.swift 에서 작업한다.

alert(item:) modifier는 item 이라는 optional 값에 바인딩 된다. item 이 nil 이 아닌 값을 방출하게 되면 alert view 가 presnet 된다.

ViewModel 에서 error는 디폴트 값으로 nil이고 오직 서버로 부터 fetching 하는 작업이 실패할 경우 non-nil 값(error) 가 방출된다.

확인해보기 위해 API 에서 baseURL 을 틀리게 작성해본다.

Subscribing to an external publisher(315)

ObservableObject/ObservedObject 방식이 아니라 그냥 하나의 publisher 를 구독해서 값을 받고 싶을 땐 새로운 ReaderViewModel과 같이 새로운 타입을 만드는게 아니라 onReceive(_)라는 modifier를 사용한다. 이것은 view code 에서 publisher에 직접적으로 구독할 수 있게 해준다.

view안에서 publisher 를 만들던 외부 publisher를 view 초기화 단계에 inject 해서 넘겨주던 상관없다.

이번에는 timer publisher를 통해 현재 화면에서 story 가 작성된 시간 (3 minutes ago by XXX) 를 지속적으로 업데이트 해주겠다.

  • ReaderView는 currentDate 라는 프로퍼티를 가지고 있고 view 가 생성될 때의 시간을 저장한다.
  • 각각의 row 는 postBy(time:user;currentDate:) view를 통해 화면에 그려진다.

새로운 정보가 화면에 계속 반영되기 위해 Timer publisher 가 매번 emit 할 때 currentDate를 업데이트 해줄 것이다.

ReaderView.swift 에서 작업한다.

  1. import Combine
  2. timer publisher 를 만들어 준다.
    Timer.publish(every:on:in:)은 connectable publisher를 반환한다. 이것은 동면중인 publisher 랑 비슷한데 subscriber가 bind 되어야지 활성화 된다. 하지만 Timer 같은 경우는 바로 활성화 되어야 하고 별다른 binding 할 로직이 필요없기 때문에 autoconnect()를 통해 바로 publisher 를 활성화 시킨다.
  3. currentDate를 이제 timer 가 emit 할 때마다 update 해주면 된다. 이때 sink(receiveValue:) 와 행동이 비슷한 onReceive(_)를 사용할 것 이다.
  4. 또한 currentDate를 @State로 바꿔준다.

view 에 publisher 를 갖는 방법도 있지만 다른 Combine model code에 있는 publisher 를 view 생성자 또는 enviroment 에 inject 해서 쓰는 방법도 있다. 

initializing the app's settings(318)

이제 SettingsView를 만들어 볼 것이다.

Model/Settings.swift에서 작업한다. 

코드를 보면 FilteredKeyword 만 가지는 뼈대만 볼 수 있다.

FilteredKeyword는 helper model 타입으로 filter 역할을 하는 single keyword 를 wrapping 한다.

Identifiable를 준수해 각각의 인스턴스를 구분할 수 있게 했다.

  1. import Combine
  2. keyword에 @Published attribute 추가
  3. Settings class 를 ObservableObject 추가

Settings instance 를 sceneDelegate에서 생성해 ReaderViewModel 에 바인딩 시켜준다.

이제 Settings.keywords 를 ReaderViewModel.filter 에 연동시켜줄 수 있다.

이제 ReaderViewModel state 에 포함될 수 있도록 filter  프로퍼티를 추가해야 한다.

keyword list 가 업데이트 될 때 마다 새로운 데이터가 view 에 반영될 수 있도록 하기위해 작업한다.

ReaderViewModel.swift 에서 filter 를 @Published var filter 로 수정한다.

이렇게 binding 이 끝났다.

Editing the keywords list(321)

SwiftUI environment 에 대해서 배운다.

environment 는 shared pool of publisher 로써 자동으로 뷰 계층에 publisher 를 주입해준다.

system environment

environment 는 system 에 의해 주입된 publisher 도 가지고 있다. (calendar, layout direction, locale, timezome 등등)

이런 값들의 변경사항을 관찰 할 수 있다.

system enviroment 중 하나인 colorscheme 을 관찰 해 보겠다.

ReaderView.swift 에서 @Environement 를 추가한다.

Environment 프로퍼티 wrapper 를 사용해 colorScheme property 에 접근한다. 

이제 이 프로퍼티는 view 의 state 가 되었기 떄문에 다크모드, 라이트모드 변경시 SwiftUI 는 지금 view 를 re-rendering 할 것 이다.

이 변경사항을 바탕으로 각각의 모드마다 다른 view 를 보여줄 수 있다.

debug preview 에서 확인해볼 수 있다.

Custom environment objects(322)

system enviroment 를 사용하는 것 처럼 자신만의 custom environment object를 만들어 사용할 수 있다.

특히 deeply nested view 를 다룰 경우 model 이나 shared resource 를 environment 에 주입하는 것은 상당히 유용하다.

뷰 계층에 주입된 enviroment 는 모든 child view 에서 접근 가능하기 때문에 Settings를 구현하는데 적합하다고 생각된다.

scene delegate 에서 생성되기 때문에 userSetting 을 environment 에도 주입해 준다.

이 Modifier는 view modifier로써 뷰 계층 environement 에 주어진 object를 주입한다.

다음 view 에 방금 주입한 environment 에 대한 dependency를 추가한다.

SettingsView 에서 새로운 @EnvironmentObject를 추가한다.

이때는 system environment 와 같이 따로 key path를 명시할 필요는 없고 @EnvironmentObject가 자동으로 맞는 type의 프로퍼티를 찾아준다.

이제 settings.keyword를 추가한 view 어디에서나 접근해서 사용할 수 있다. (subscribe, bind get value directly)

SettingsView 기능을 완성하려면 keyword 의 리스트를 화면에서 보여줘야 한다.

ForEach 에 새로만든 FilterKeyword 대신 setting 에 있는 keyword로 값을 바꿔주고 addKeyword버튼을 눌렀을 때

presentingAddKeywordSheet 이 true 가 되도록 한다.

이렇게 하고 앱을 실행해서 Settings view 를 켜게되면 이런 에러가 발생한다.

왜냐하면 environment object는 childview들에게만 제공되지 sheet 로 새롭게 present 된 view 에는 전달되지 않기 떄문이다.

그래서 직접 environment object를 binding 해보는 연습을 한다.

ReaderView 에서 가지고 있는 settings 를 저장할 @EnvironmentObject를 만든다.

그리고 SettingsView()에 environmentObject(self.settings)로 직접 environment object를 주입해준다.

다른 방법으로는 settingsView 의 init 파라미터로도 넘겨줄 수 있다.

AddKeywordView 에서 새로운 키워드를 넣고 추가 버튼을 누르면 settings.keyword에 데이터가 추가되도록 해본다.

AddKeywordView는 콜백을 받기 때문에 SettingsView에 있는 AddKeywordView completion callback 에 코드를 작성한다.

새로운 키워드를 만들고 그것을 user settings에 추가했고 마지막으로 sheet 를 dismiss 한다.

settings model 의 값을 변경하면 자동으로 그 데이터에 관련된 view들은 다시 렌더링 된다.

List editing 기능을 마지막으로 SettingsView를 마무리 한다.

이 코드는 moveKeyword() 키워드를 올리거나 내리거나 오른쪽 스와이프로 지우는 핸들러로 사용한다.

moveKeyword, deleteKeyword 메소드를 완성해준다.

Challenges(328)

1. Displaying filter in the reader view

ReaderView 에는 settings가 있다. 이것에 있는 keywords를 subscribe 해서 filter를 표시해준다.

2. persisting the filter between app launches

JSONFile 이라는 helper type 에서 loadValue(named:) 와 save(value:named:) 메소드를 이용한다.

값이 바뀔 때 마다 저장하고 처음 앱을 켰을 때 불러오는 작업이니 Settings 에서 init 과 didSet 을 활용하여 메서드를 작성한다.

Key points

SwiftUI 와 함께면 UI는 state 의 함수로써 동작하고 UI render 를 data 가 변하는 state 에 따라 업데이트 시켜줄 수 있었다.

그리고 SwiftUI 에서 state 를 관리하는 방법을 배웠는데

  • @State 를 사용해 로컬 state 를 만들고 @ObservedObject를 추가해 외부 ObservableObject 에 대한 의존성을 추가할 수 있었다.
  • onReceive를 통해 외부 publisher 로 부터 값을 직접 구독할 수 있다.
  • @Enviroment 를 통해 시스템 환경을 구독하거나 커스텀 environment object 또한 만들어 보았다.
728x90

'iOS > Combine' 카테고리의 다른 글

#18 Custom Publishers & Handling Backpressure  (0) 2020.11.03
#17 Schedulers  (0) 2020.11.03
#12 Key-Value Observing  (0) 2020.10.30
#9 Networking  (0) 2020.10.28
"UI events are asynchronous"  (0) 2020.10.28

댓글