<h1><center> Data Flow Through SwiftUI [1주차] </center></h1> ###### tags: `💻 WWDC 스터디` > [color=#724cd1][name=데릭] > [Data Flow Through SwiftUI - wwdc](https://developer.apple.com/videos/play/wwdc2019/226/) > WWDC 2019 Session 중 하나인 `Data Flow Through SwiftUI`에 대해 알아보자 # 개요 SwiftUI는 UI개발의 복잡성을 해결하기 위해 새롭게 고안된 툴이다. ## SwiftUI에서 살펴볼 사항들 **Principles of Data Flow** - SwiftUI는 View의 계층에서 데이터를 Flow를 타는 툴이다.(View 계층에서 데이터가 왔다갔다 하는 것을 말한다.) **Anatomy of an Update** - SwiftUI는 항상 정확하고 일관된 데이터의 표현을 위해 뷰 계층을 업데이트한다. 내부적으로 어떻게 하는지 알아보자. **Understanding Your Data** - SwiftUI에서 제공해주는 Tool로 데이터에 어떤 게 적합한지 이해해보자. ## 데이터란? SwiftUI에서 데이터는 `First Class Citizen`이다. 여기서의 데이터란 UI를 Drive하는 모든 정보들을 말하는 것이고, 다양한 형태를 가지고 있으며 모든 것이 데이터가 될 수 있다. 예) Toggle State, Message Model을 가지는 객체 등 ## SwiftUI를 이해하기 위한 두가지 원칙 ### 1. View에서 Data를 읽을 때마다 View는 Data에 의존성이 생긴다. - 데이터가 변경될 때마다 뷰는 새로운 값을 반영해야하기 때문에 의존성이 발생한다. ![](https://i.imgur.com/z9dboOm.png) > 예제그림 01) View에 Data 의존성 부여 `PlayerView`에서 `isPlaying`(Bool Type의 Property) 데이터를 읽어야한다고 가정해보자. `isPlaying` 에서 데이터가 변경될 때마다 `PlayerView`를 업데이트해야한다. SwiftUI이전에는 코드로 직접 업데이트를 처리해야 했지만 더 이상 데이터와 뷰의 동기화를 따로 해줄 필요가 없어졌다. SwiftUI는 선언적(Declarative)이기 때문에 Data Dependency(데이터에 대한 의존성)을 지침만 따르면 나머지를 알아서 처리해준다. ### 2. View의 계층에서 읽어들이는 모든 데이터들은 Source of Truth를 가진다. ![](https://i.imgur.com/AUdvZSo.png) > 예제 그림 02) SSOT View에서 읽는 모든 데이터들은 결국 하나의 Source를 가진다는 뜻이다. **NOTE** > SSOT(Single Source of Truth): 데이터가 여러 곳에 존재하지 않고 오직 한 곳에서만 존재한다. 만약 데이터가 중복되어 존재할 경우, 항상 Sink를 맞춰야 한다. 일관적인 UI를 유지하는 것이 어렵고, 버그가 발생할 가능성이 높다. ![](https://i.imgur.com/ons5ExD.png) > 예제 그림 03) 각각 Data를 가지고 있는 경우 Sink가 다를 수 있는 예제 > 예를 들면 위 그림에서는 View 두 개 각각을 위한 isPlaying 데이터가 각각 존재할 때, `Notification`, `KV observing`, 그 외의 다른 이벤트 등으로 하나의 `isPlaying` 값이 변할 경우에는 항상 다른 하나의 `isPlaying` 값도 싱크를 맞춰 변경되어야 한다. 이런 복잡성이 버그를 유발한다. ![](https://i.imgur.com/mopQcDh.png) > 예제 그림 04) 상위 View가 변수를 가지고 있게 해서 하위 뷰의 변수들의 Sink를 맞추는 예제 ```swift final class VideoView: UIView { private var topMenu = VideoTopMenuView(frame: .zero) private var playControl = VideoPlayControlView(frame: .zero) /// 각 서브 뷰에서 isPlaying 변수를 가지고 있을 때, 이 데이터들의 싱크가 맞아야 일관된 UI를 가질 수 있다. var isPlaying: Bool = false { didSet { self.topMenu.isPlaying = self.isPlaying self.playControl.isPlaying = self.isPlaying } } } ``` ## @State ![](https://i.imgur.com/T0rUUzZ.png) > 예제 그림 05) 팟케스트 플레이어 ```swift struct PlayerView: View { let episode: Episode @State private var isPlaying: Bool = false var body: some View { VStack { Text(episode.title) Text(episode.showTitle) .font(.caption) .foregroundColor(.gray) Button(action: { self.isPlaying.toggle() }) { Image(systemName: isPlaying ? "pause.circle" : "play.circle") } } } } ``` @State 프로퍼티 랩퍼를 적용해서 `해당 프로퍼티의 값이 변경될 수 있으며, 데이터 값에 따라 Self(PlayView)의 상태가 변경될 수 있다`를 시스템에 알린다. ![](https://i.imgur.com/bCuxPQN.png) > 예제 그림 06) 부모 뷰가 State 프로퍼티를 가질 때 @State를 사용하면, `isPlaying` 변수를 영구적인 저장공간에 할당하여 시스템이 의존성을 가지게 되어 추적하게 된다. 일반적인 뷰는 시스템에 의해 자주 재생성되지만, @State가 붙으면 뷰가 여러 번 업데이트되어도 계속 저장공간에서 데이터 값을 유지한다. - 뷰 안에서만 `isPlaying`의 상태를 관리한다는 것을 확실히 하기 위해 private으로 처리한다. @State 변수의 값이 변할 때 뷰의 렌더링이 달라져야 한다는 것은 SwiftUI에서 감지하게 된다. ![](https://i.imgur.com/7NrxkYR.png) > 예제 그림 07) 부모 뷰와 하위 뷰 Sink **`isPlaying`의 값이 `True`로 변하면 어떻게 될까?** ![](https://i.imgur.com/wIQYaFN.png) > 예제 그림 08) isPlaying True일 때 유저의 인터랙션에 따라 재생 버튼의 액션이 실행되고, isPlaying 이라는 State 변수 값이 변하고, State가 변경되었으므로 뷰 자신과 자식 뷰들의 Body가 모두 새롭게 랜더링된다. (Button > isPlaying 값 변경 > PlayerView > 자식뷰인 Text, Text, Button 에 각각 전파 > 전체적인 뷰가 갱신됨) 단, 프레임워크에서 오직 변경된 것에 대해서만 다시 렌더링해주기 때문에 이 과정은 효율적으로 수행된다. 이런 Flow를 Framework가 Dependency를 관리해준다고 하는 것이다. **NOTE** > State를 정의할 때마다, 뷰가 소유하는 Source of truth가 새롭게 하나 정의된다는 것을 기억하자. > 뷰는 더이상 연속된 이벤트의 집합이 아니다. 상태를 가지는 함수이다. 이전 방식은 직접적으로 View 계층을 변경하는 방법으로 이벤트에 응답했다. 예) 유저의 인터랙션이 발생할 때마다 직접 서브뷰를 추가/제거하거나 뷰의 알파값을 변경하는 코드를 실행하는 방식. **SwiftUI** ![](https://i.imgur.com/Yg3XCeG.png) > 예제 그림 09) SwiftUI에서 State의 역할 단순히 상태(State)만을 변경하면 된다. State가 Source of truth로써 동작하며, 자연스럽게 뷰가 변경된다. 이 부분이 선언적 구문이 빛나는 부분이다. ### 예제 코드 ```swift // 기존의 완전히 수동적인 방식 class SampleView: UIView { var isHiddenCircle: Bool = false { didSet { self.circle.isHidden = self.isHiddenCircle } } var isHiddenRect: Bool = false { didSet { self.rect.isHidden = self.isHiddenRect } } } // 위를 조금 더 개선한 방식 class SampleView: UIView { var isHiddenCircle: Bool = false { didSet { self.updateAppearance() } } var isHiddenRect: Bool = false { didSet { self.updateAppearance() } } private func updateAppearance() { self.circle.isHidden = self.isHiddenCircle self.rect.isHidden = self.isHiddenRect } } // SwiftUI 방식 // 바로 위 코드와 비교해보면 원리는 같지만 좀 더 선언적인 문법, 효율성을 지님. // 또한 @State & body 규약을 통해 예측 가능한 가독성 있는 코드이기 때문에 // 기존의 코드 룰을 모르는 제3자가 이 뷰를 건드릴 때에도 실수를 방지할 수 있다 // 이렇게 문법적으로 규약을 만들어 놓는 것의 장점이라고 생각한다 struct SampleView: View { @State var isHiddenCircle: Bool = false @State var isHiddenRect: Bool = false var body: some View { Circle() .isHidden(self.isHiddenCircle) Rectangle() .isHidden(self.isHiddenRect) } } ``` ## Binding 예제에서 여러 개의 재사용 가능한 컴포넌트로 UI를 분리해보자. ```swift struct PlayerView: View { let episode: Episode @State private var isPlaying: Bool = false var body: some View { VStack { Text(episode.title) Text(episode.showTitle) .font(.caption) .foregroundColor(.gray) Button(action: { self.isPlaying.toggle() }) { Image(systemName: isPlaying ? "pause.circle" : "play.circle") } } } } // -> Button을 분리하자 struct PlayButton: View { @State private var isPlaying: Bool = false var body: some View { Button(action: { self.isPlaying.toggle() }) { Image(systemName: isPlaying ? "pause.circle" : "play.circle") } } } ``` **문제점** 이렇게 분리하면 PlayButton의 isPlaying 프로퍼티는 또 다른 `Source of truth`를 만든다. - PlayButton의 State 프로퍼티는 SuperView인 PlayView의 State와 항상 싱크가 되도록 구성한다. (isPlaying이라는 상태 데이터는 한 번에 한 곳에만 존재하면 되는 데이터이다.) PlayButton은 자신의 isPlaying을 따로 소유할 필요가 없으며, 단순의 PlayView가 소유하는 isPlaying 프로퍼티를 읽거나 변경할 수 있으면 된다. ```swift struct PlayButton: View { @Binding var isPlaying: Bool var body: some View { Button(action: { self.isPlaying.toggle() }) { Image(systemName: isPlaying ? "pause.circle" : "play.circle") } } struct PlayerView: View { let episode: Episode @State private var isPlaying: Bool = false var body: some View { VStack { Text(episode.title) Text(episode.showTitle) .font(.caption) .foregroundColor(.gray) PlayButton(isPlaying: $isPlaying) } } } ``` **NOTE** > PlayButton은 isPlaying 값의 복사본을 가지는 것이 아니라, 단순히 PlayView(SuperView)의 isPlaying 프로퍼티를 참조하는 것이다. 따라서 프로퍼티 간의 싱크를 신경쓸 필요가 없다. > SuperView입장에서 보면, Binding은 자신의 State 프로퍼티에 대한 접근을 다른 컴포넌트에게 허용하는 것이다. ![](https://i.imgur.com/fkQSE9I.png) > 예제 그림 09) SwiftUI이전의 방식 뷰 컨트롤러의 서브 뷰들이 동일한 프로퍼티를 각각 들고 있는 방식이다. 이런 구조에서는 뷰 컨트롤러에서 데이터의 값이 변경되면 자신의 모든 서브 뷰들에게 새로운 값을 셋팅해주는 과정(데이터 싱크를 맞추는 과정)이 필요하다. ![](https://i.imgur.com/76qXsFH.png) > 예제 그림 10) SwiftUI 방식 데이터 Dependency를 정의하면 프레임워크가 나머지를 알아서 처리한다. ![](https://i.imgur.com/LwK1Rs4.png) > 예제 그림 10) SwiftUI Binding 예제 코드 ```swift // UIKit의 UISlider class SampleView: UIView { var sliderValue: Float = 0 { // UI 싱크를 맞추기 위해 아래와 같은 처리들을 하는 경우가 종종 있었다 didSet { self.slider.value = self.sliderValue } } private let slider = UISlider(frame: .zero) } // SwiftUI의 Slider struct SampleView: View { // 이 값이 변경되면 자동으로 UI도 갱신된다 @State var sliderValue: Float = 0 var body: some View { Slider(value: $sliderValue) } } ``` ## Working With External Data ![SwiftUI](https://hackmd.io/_uploads/rJfMvUQE3.png) > 예제 그림 11) SwiftUI 외부 데이터 SwiftUI에는 데이터 관리를 위한 많은 방법들이 있다. @Environment, ObservableObject에 대해 알아보자. - Timer fired > State 변경 - Notification > State 변경 - 그 외 등등 > State 변경 ![](https://hackmd.io/_uploads/rJn_P8QN3.png) 이처럼 SwiftUI에서는 외부 이벤트라도 결국 State의 변화로 이어진다. 즉, 앞의 예제에서 유저의 interaction(Action)이 발생해 State가 변경되었던 것과 결과적으로 차이가 없다. - 이런 외부 이벤트들을 Publisher라고 한다. Publisher는 Combine Framework로부터 발생된 것이다. - Combine은 시간에 따라 값을 처리하기 위해 선언적인 API ## Publisher 적용 ```swift struct PlayerView: View { let episode: Episode @State private var isPlaying: Bool = false @State private var currentTime: TimeInterval = 0.0 var body: some View { VStack { Text(episode.title) .foregroundColor(isPlaying ? .white : .gray) Text(episode.showTitle) .font(.caption) .foregroundColor(.gray) PlayButton(isPlaying: $isPlaying) Text("\(currentTime, formatter: currentTimeFormatter)") } .onReceive(PodcastPlayer.currentTimePublisher) { newCurrentTime in self.currentTime = newCurrentTime } } } ``` VStack 하단에 onReceive modifire를 추가하였다. 이제 타임스탬프가 변경되면 onReceive 블럭 안에서 currentTime 이 새로운 값으로 변경될 것이다. currentTime 은 State 변수이다. 따라서 State가 변경된 것으로 취급되고, body 를 통해 뷰의 렌더링이 다시 이루어진다. 그러면 currentTIme 에 대한 UI 갱신이 자동으로 이루어진다. 이런 과정을 거치는 동안 수동으로 작성된 코드나 invalid 체크 코드는 필요하지 않다는 것이 장점이다. NOTE >부연설명: onReceive 는 Combine에서 제공해주는 것으로, Publisher가 receive(on:) 메서드를 통해 특정 큐/쓰레드에서 돌도록 지정한 것을 여기서 받아 처리할 수 있게 된다. 위 코드는 현재 타임스탬프가 변경될 때 fire되는 Publisher가 미리 작성되어 있다는 가정 하에 작성된 것이다. ## ObservableObject - ObservableObject 는 이미 소유/관리중인 모델이 있을 경우, 이 모델과 뷰의 동기화를 편하게 만들어주는 프로토콜들이다. - 데이터가 변경되었을 때, 그 상태를 알릴 필요가 있는 모델이라면(=값의 변경을 외부에서 감지할 필요가 있는 모델) ObservableObject 프로토콜을 따르도록 하자. ```swift protocol ObservableObject : AnyObject ``` ```swift class PodcastPlayerStore { var currentTime: TimeInterval var isPlaying: Bool var currentEpisode: Episode func advance() { ... } func skipForward() { ... } func skipBackward() { ... } } ``` - 싱크를 맞추기 위해 사용할 모델을 하나 정의하였다. 이제 이 모델을 ObservableObject 프로토콜에 따르게 해보자. > 이 프로토콜이 요구하는 것은 데이터가 변경되었을 때를 위한 Publisher이며 objectWillChange 프로퍼티에 이 Publisher를 정의해두어야 한다. ```swift class PodcastPlayerStore: ObservableObject { // ... var objectWillChange = PassthroughSubject<Void, Never>() func advance() { self.currentEpisode = nextEpisode self.currentTIme = 0.0 // Notify subscribers that the player changed self.objectWillChange.send() } } ``` 위 코드에서는 objectWillChange 프로퍼티에 PassthroughSubject 라는 Publisher를 하나 정의하였고, 데이터의 변경이 발생하는 advance() 메서드 안에서 이 Publisher의 send() 를 호출해주고 있다. 이 Publisher를 subscribe 하여 데이터의 변경 시점을 정확히 알 수 있게 된다. 즉, 언제 뷰를 업데이트해야 할지 알게 된다는 것이다. ![](https://hackmd.io/_uploads/S1xc2t8XEn.png) > 예제그림 12) ObservedObject > View는 @ObservedObject라는 Property Wrapper를 사용해 ObservableObject Model에 의존할 수 있다. Automatic dependency tracking(모델이 변경될 때 뷰가 알아서 변경됨) 관점에서만 보면 앞서 다루었던 @State 와 다르지 않다. > MVVM 예제코드를 보면 ViewModel이 채택하고 있음 [SwiftUI - MVVM](https://github.com/stevencurtis/SwiftCoding/tree/master/SwiftUI/MVVMNetworking/MVVMNetworking) ```swift struct MyView: View { @ObservedObject var model: PodcastPlayerStore ... } MyView(model: modelInstance) ``` MyView는 PodcastPlayerStore모델의 상태가 변경될 때(ex: 다음 에피소드 재생을 위한 advance 함수가 불렸을 때) 자동으로 그것을 알아채고 뷰를 업데이트할 수 있게 되었다. ## EnvironmentObject SwiftUI의 Environment는 뷰 계층에게 데이터를 넣어주는 캡슐화 방법이다. ![](https://hackmd.io/_uploads/SyXQsLX4n.png) > 예제그림 13) EnvironmentObject `.environmentObject(_:)` 를 통해 PodcastPlayerStore를 Environment로 만들고, MyView 에서는 @Environment Property Wrapper 를 붙여서 이 모델에 대한 의존성을 정의해둘 수 있다. ```swift @frozen @propertyWrapper struct EnvironmentObject<ObjectType> where ObjectType : ObservableObject ``` ```swift struct PlayerView: View { @EnvironmentObject var player: PodcastPlayStore var body: some View { VStack { Text(player.currentEpisode.title) .foregroundColor(isPlaying ? .white : .gray) Text(player.currentEpisode.showTitle) .font(.caption) .foregroundColor(.gray) PlayButton(isPlaying: $player.isPlaying) Text("\(player.currentTime, formatter: playheadTimeFormatter)") } } } ``` > ObservedObject로 사용하던 때와 마찬가지로, player 프로퍼티의 상태가 변경될 때마다 뷰가 자동으로 업데이트 될 것이다. **언제 ObservedObject를 쓰고 언제 EnvironmentObject를 쓰면 좋은 것일까** - 기본적으로는 ObservedObject를 사용하면 된다. 그러나 때때로 모델이 변경되었음을 여러 뷰를 거치며 전달해야 할 상황이 있을 것이다. 간단히 예를 들어 아래와 같은 뷰 계층이 있다고 해보자. ![](https://hackmd.io/_uploads/B1hwpLQNh.png) > 예제 그림 13) EnvironmentObject 특정 모델이 변경되었을 때 초록색 뷰들이 업데이트되어야 한다면, 모델이 변경되었음을 전달하기 위해 실제로는 그 정보가 필요하지 않은 파란색 뷰들을 경유해야 할 것이다. 이런 케이스에서 EnvironmentObject를 사용하면 깔끔하게 처리할 수 있다. ![](https://hackmd.io/_uploads/Sk4_pLmN3.png) ## 어떤 것을 어디에 적용하면 좋을까? 1. 데이터의 생성/관리 주체 관점에서 생각하면 다음과 같이 나눠볼 수 있다. ![](https://hackmd.io/_uploads/HkUja8QE2.png) **@State** - Value Type - 뷰의 로컬 데이터 (내부 프로퍼티) - 뷰에서 소유되고 관리되어야 하는 데이터 - (ex) SwiftUI 프레임워크의 Button의 highlight 여부는 Button 내부에서 @State로 관리되고 있다. Touch 중일 때 highlighted 상태, Touch가 끝났을 때 highlighted 가 아닌 상태라는 것을 외부에서 관리할 필요없이 Button 내부에서만 관리하면 되기 때문이다. **ObservableObject** - Reference Type - 이미 관리(소유)하고 있는 데이터에 적용하면 좋음 - 외부로부터의 데이터를 표현할 때 2. 재사용가능한 컴포넌트를 만드는 경우도 생각해보자. 이 경우에는 데이터의 성격이 Read-only인지 Read-write인지에 따라 다음과 같이 나눠볼 수 있다. ![](https://hackmd.io/_uploads/r1SC6874n.png) **Read-only 데이터** - 재사용을 위한 뷰에서 굳이 데이터를 변경할 필요가 없는 경우 - Swift Property, Environment 등을 사용하기 (데이터 변경 시, 감지하여 알아서 뷰가 업데이트 됨) **Read-write 데이터** - 재사용을 위한 뷰에서도 데이터를 변경할 일이 있는 경우 (ex: SwiftUI의 Toggle) - @Binding을 사용하기 (값을 소유하지 않으면서도 읽고 쓸 수 있음) - 예로 든 Toggle의 경우, on/off 데이터가 어디서 발생되었고 누가 관리하는지 Toggle View 자체에서는 알 필요가 없다. 따라서 isOn 여부를 Binding으로 받고 있는 것이다(참고).