iOS/SwiftUI

#9 State & Data Flow

HaningYa 2020. 10. 22. 10:20
728x90

이전 챕터에서는 UI를 만들기 위해 자주 사용되는 UI component를 사용해보았다. 
이번 챕터에서는 SwiftUI의 또다른 특징인 state: "상태" 에 대해서 배워본다.

MVC: The Mammoth View Controller

UIKit 이나 AppKit에서 MVC 컨셉에 대해서 익숙할 것 이다.
원래는 Model-View-Controller 의 개념이지만 Massive View Controller 라는 별명도 가진다.

MVC에서 View 는 UI를 담당하고 Model 은 데이터, Controller 는 그 둘을 동기화 시켜주는 접착제 역할을 한다.
하지만 그 역할은 자동으로 되는게 아니라 view 가 update 되고 data가 바뀌는 모든 case 를 고려해 명시적으로 코드를 작성해야 한다.

UITextField 라는 이름을 가진 view controller 를 고려해 보면 

class ViewController: UIViewController {
  var name: String?
  @IBOutlet var nameTextField: UITextField!
}

만약 textfield 의 이름을 보여주고 싶을 땐

nameTextField.text = name

이런식으로 선언을 하고 반대로 name property 에 textfield 의 내용을 담고 싶다면

name = nameTextField.text

이런식으로 선언을 해야된다.

만약 둘중 하나의 값을 (name, nameTextField) 바꾼다고 해도 이 둘은 자동으로 서로의 값을 동기화 하지 않기 때문에 일일히 코드로 update 부분을 작성해 주어야 한다. 

이 경우(두 값을 동기화 하는것) 는 간단한 예시기 때문에 name을 computed property 로 만들어 text field 의 text  property 의 proxy(중계자) 역할을 하게 만들면 해결된다.
하지만 만약 model 이 임의의 데이터 구조 또는 하나 이상의 데이터 구조일 때는 위와 같은 해결책으로 model 과 view 를 동기화 하지 못하는 걸 깨닫게 될 것 이다.

model을 제쳐두고 UI 또한 State에 의존한다. 
예를들어 toggle이나 validate 상태에 따라 숨겨져야하는 component를 만든다고 했을 때 정확한 타이밍 맞는 정확한 logic 으로 구현하지 않거나, 사용하는 곳에서 update 하는걸 까먹으면 어떻게 될까?

더구나 AppKit 과 UIKit에 구현된 MVC 패턴은 조금 틀을 벗어난다. 
왜냐하면 controller 와 view 가 분리되있지 않고 view controller 라는 하나의 entity(존재)로 합쳐져 있기 때문이다.

결국 대부분 model, view, controller 가 같은 class 에 있는 view controller가 대부분 존재하게 되며 MVC 패턴에서 말하는 3가지 독립된 엔티티에 대한 아이디어는 무시되게 된다. 그래서 앞서 말했던 MVC의 별명이 Massive View Controller 가 된 것 이다.

요약하자면 SiwftUI 이전에 작업을 할 때는

  • massive view controller 문제는 실제한다.
  • model 과 UI를 동기화 하는 작업을 일일히 해줘야 한다.
  • state 또한 UI 와 동기화 되있지 않다.
  • view 와 subview 로 부터 state 와 model 을 업데이트 할 수 있어야 한다. (반대방향으로도)
  • 쉽게 에러나 버그를 발생할 시킬 수 있는 구조이다.

A functional user interface

SwiftUI 의 장점은 user interface 가 functional(함수형) 하다는 점이다. 
잘못될 state 도 없고 view 가 condition 에 따라 잘 보여지는지도 매번 확인할 필요가 없어지고
state 가 변경되었을 때 일일히 UI의 일부분을 update 하는 것도 기억하지 않아도 된다.

또한 클로져 내에서 [weak self] 를 사용해 순환  참조를 피해야 되는 부담도 덜 수 있다.
view 들이 value type이기 때문에 reference 대신 복사된 값을 capture 하기 때문이다.

함수형일 경우 항상 같은 입력값에 따라 같은 rendering 결과물을 만들고,  input값이 바뀔때 자동으로 update 를 trigger 한다.
알맞는 wire를 연결하는 것은 user interface 가 데이터를 가져오는 게 아닌 user interface 에 data를 보낸다.

여전히 UI 를 구현하고 data를 연결하는 작업에 대해 생각해야 하지만 훨씬 간단해 지고 에러 지양적이고 엘레강스 해진 걸 느낄 수 있다.

SwiftUI 의 장점을 정리하자면

  • Declaritive = user interface 를 구현(implement)하지 않고 선언(declare)한다.
  • Functional = 같은 state가 주어졌을 때 항상 같은 UI를 보여준다. / UI가 state 에 대한 함수 형태인 것 이다.
  • Reactive = state 가 변경될 때 SwiftUI 는 자동으로 UI를 update 해준다.

이번 장은 위 3가지중 마지막 (Reative) 장점에 대해서 집중한다. 
state와 UI 의 관계를 관리하고 state를 view 와 subviews 에 전달할지 생각한다.

Kuchi 앱에서 이전까지 단어가 나오고 3개의 보기중 정답을 선택하고 정답을 확인 하는 alert 까지 구현해 보았다.
이번장에서는 @State 를 이용해 게임을 처음부터 다시 시작할 수 있도록 만든다.

State

아래의 두가지 정보를 담는 counter를 만들 것 이다.

  • 푼 문제의 수
  • 전체 문제의 수

먼저 Practice group 에 ScoreView.swift 라는 SwiftUI 파일을 생성한다.

그리고 ScoreView를 ChallengeView.swift 에서  button 다음에 추가한다.

이런식으로 표시된다.

다음 tap 했을 때 문제의 갯수를 증가시키는 버튼을 ScoreView.swift에 추가할 것 이다.

밑에 HStack 은 무시하세용

  • 버튼을 추가하고
  • action handler 에 numberOfAnswered를 increment 시키게 하고
  • 이전 content 를 button body 에 표시했다.

그런데 에러가 발생하는데 view 의 state를 body 안에서 mutate(변경,수정) 할 수 없다고 한다.
value = immutable?

Embedding the state into struct

만약 별개의 구조체로 프로퍼티를 옯기고 싶다면?
numberOfAnswered를 내부 State struct로 옮겨보자.

  • numberOfAnswered 를 구조체 안에 캡슐화 했다.
  • 해당 struct의 instance 를 새로운 프로퍼티로 추가했다.

다음 numberOfAnswered 값을 state instance 를 통해서 접근해보면

같은 에러가 발생한다. 
사실 당연한게 Struct 또한 value type 이므로 결국 view 내부의 state 를 mutate(변경) 하려고 하는 동일한 행위이기 때문이다.

Embedding the state into a class

value type 을 reference type 에러는 없어진다.

하지만 live view 를 통해 버튼 동작을 보면 제대로 동작하지 않는 걸 볼 수 있다.

print 로 찍히지만 UI 에 반영되지 않는 모습

이게 바로 UIKit 을 사용했을 때 예상되는 결과인 것 이다.
model 이 바껴도 (내부 counter 값을 변경되도) UI에 변경된 값을 반영해주는 작업을 따로 해줘야 한다는 것 이다.

Wrap to class, embed to struct

class 를 벗겨내고 다시 struct 를 사용하고 싶으면 어떻게 해야될까?
왜 그렇게 굳이 해야 하는가에 대해서 궁금할 수도 있는데 이 부분을 읽고나면 이해가 될 것 이다.

방금 전에 struct 가 동작하지 않았던 이유는 구조체는 value type이기 때문이였다. 
value type 값을 바꾸는 것은 Mutability 를 필요로 하지만 body 는 가지고 있는 struct를 mutate 하지 못한다.

mutating 키워드 없이 값을 바꾸려면 mutating property 를 reference type으로 wrapping 하면, 다시말해 class 로 감싸면 된다.

이렇게 value type(사실 어느 타입이든 가능) 을 class 내부에 wrapping 한다. 
그리고 State를 struct 로 다시 만들어 Box instance 의 프로퍼티로 만들어 보자

The real State

Box 말고 SwiftUI의 State를 사용해 보자.

바로바로 화면이 업데이트 된다.

그래서 State에 대한 SwiftUI 문서를 보면

 

Apple Developer Documentation

 

developer.apple.com

SwiftUI에 의해 관리되는 value 를 읽고 쓸 수 있는 property wrapper type이라고 한다.
마치 "State struct 내부에 있는 Box" +" view 가 monitor 할 수 있는 기능" 인 셈이다.

*SwiftUI 는 state 로 선언한 모든 property 의 storage 를 관리한다.
state 값이 변경될 경우 view 는 현재 화면은 invalidate 하고 body를 recompute 한다.
state를 주어진 view 에 대한 single source of truth 로 사용하는 셈이다.

wrapped value 가 바뀔때 SwiftUI는 해당 값을 가지고 있는 view만 re-render 한다.

이쯤되면 State<Value>@State attribute와 $ 연산자가 어떤 관계일지 궁금해질 것 이다.

_numberOfAnsered 를 @State 를 사용해 바꿔보자

엥 오류가 안난다.

저것만 바꿔도 compiler 가 자동으로 _numberOfAnswered 라는 _ prefix를 붙여서 State<Int> type을 만들어 준다!

세가지 부분에서 _numberOfAnswered를 사용했는데

  • button action handler에서 counter 를 증가시킬 때
  • counter 를 Print 하기 위해 button action handler 에서 사용
  • button view 에서 counter 값을 표시하기 위해

세 부분을 모두 선언한 변수와 같은 이름으로 바꿔주면 된다. ( _ prefix 삭제, wrappedValue 삭제)

배운 것은 만약 view 에 property 가 있고 그 property 를 view body에서 사용하면, 해당 propery 의 값이 변경될 때 마다 view 는 변하지 않는 다는 것 이다.
만약 그 property 를 state property 로 만들면 (@State attribute 사용해서) view 는 property 변경사항에 반응하여 연관된 view 를 자동으로 update 시켜준다.

Not everything is reactive

score view는 두개의 프로퍼티가 있다.
numberOfAnswered (state property).
numberOfQuestions 근데 이건 왜 state property 가 아닌가?

numberOfAnswered 는 매번 정답을 제공할 때 마다 증가되는 동적인 값이다.
하지만 numberOfQuestions는 정적이다. 그저 문제의 총 갯수를 표현한다.

그 값이 바뀌지 않기때문에  state 로 선언할 필요가 없을 뿐더러 var 일 필요도 없다. 
let으로 바꿔주고 값은 preview 에서 넣어주자.

Using binding for two-way reactions

state 변수는 UI 업데이트를 시키는 유일한 방법이 아니다.

How binding is (not) handled in UIKit

UIKit/AppKit 에 있는 text view 나 text field 에 대해서 생각해 보면 둘은 text property를 통해 text field에 입력한 사용자의 값을 세팅하고 보여줄 수 있다

다시말해 UI 컴포넌트가 보여지는 data를 들고있다고 이해할 수 있다. (user 가 textfield.text 프로퍼티에 입력한 값)

그 입력되는 값이 변하는걸 알기 위해서는 delegate 를 사용하거나 subscribe 하여 editing event 가 발생했는지를 여부를 판단해야 한다.

만약 사용자가 입력하는 와중에 validation을 적용하고 싶다면 매번 text 가 바뀔 때 마다 call 되는 함수를 만들어 주어 매번 UI를 일일히 업데이트 해줘야 한다. (예를들어 button enabled disabled 등등)

Owning the reference, not the data

SwiftUI는 이 과정을 더 간단하게 만들어 준다. 선언적 접근 방식을 사용하고 Status property 가 변경 될 때 사용자 인터페이스를 자동으로 업데이트하기 위해 Status property의 반응 적 특성을 활용한다.

SwiftUI에서 component 는 본인의 data를 소유하지 않는다.
대신에 다른곳에 저장된 data 에 대한 reference를 가지고 있는다. 
이것은 SwiftUI가 자동으로 model 이 바뀔때 UI를 업데이트 해줄 수 있는 이유이다.

이러한 reference 를 다루기 위해 binding 이라는 걸 사용한다.
Chapter 7에서 TextField 를 다룰 때 state property 를 통해 사용자 이름을 가지고 있고 나중에 environment object로 바꿨었다.
이 방식을 지금 다시 해보겠다.

RegisterView.swift  파일에서 작업한다.

Compiler 가 binding 이 아니라고 에러를 띄운다. Binding이 무엇인가?
공식문서에 따르면 

A binding is a two-way connection between a property that stores data, and a view that displays and changes the data.
A binding connects a property to a
source of truth stored elsewhere, instead of storing data directly.

데이터를 저장하는 property 와 그 데이터를 바꾸고 보여주는 view 사이를 양방향으로 연결해 주는 것을 뜻한다.
binding 은 property 를 다른 곳에 저장된 source of truth에 연결시켜 준다. (데이터를 직접적으로 저장하는게 아니라)

즉 binding 으로 저장된 데이터를 직접 가지고 있는게 아닌 다른곳에 저장된 데이터에 대한 reference 를 가지는 것 이다.

그래서 코드에 그냥 var 로 되있던 name 을 State<String> 타입으로 바꾸고 TextField 의 text 도 name.projectedValue 로 바꿔준다.

그리고 해당 값을 보여주기 위한 Text에는 데이터에 대한 값을 변경하지 않고 보여주기만 하기 때문에 name.wrappedValue로 값을 받아온다.

  1. 사용자가 text를 변경했을 때 TextField 는 name state property 에 연결된 믿단의 데이터를 update 한다.
  2. 그럼 데이터가 수정되는데 그때 그 수정사항은 해당 데이터를 사용하는 (Text) UI 의 업데이트를 trigger 한다.
  3. Text 는 업데이트를 받게되고 변경된 데이터를 name.wrappedValue 를 통해 전달받게 된다.

이제 binding 이 뭔지 감이 올 것이다. State 를 없애고 @State 로 앨레강스하게 바꿔보자

이뿐만 아니라 SwiftUI 는 state property 를 통해 UI 측면에서의 행동들도 선언적으로 바꿀 수 있다.
예를들어

if 문을 통해 글자가 3글자 이하면 Text 가 표시되지 않게 만들어 줄 수 있다.
이 if 또한 name value 가 바뀔 때 마다 조건을 다시 검사한다. 
선언만 해주면 끝이다. subscription 도 필요가 없다. 
짱 편하다.

Cleaning up

방금했던 내용들 다 지우고 원본 상태로 RegisterView 를 바꾸자

Defining the single source of truth

데이터는 단일 항목에서만 소유해야하고 다른 모든 항목은 동일한 데이터에 액세스해야한다. (복사본이 아닌)
value type을 전달하면 사실 그 복제본을 전달하는 것과 같다. 그래서 그 값에 대한 변경사항은 복제본의 생명주기에 제한된다. 원본 데이터에 영향을 주지 않는다는 것 이다. 마찬가지로 원본 데이터에 대한 변경 또한 그 복사본에 대한 값에 변화를 주지 못한다.

이것이 왜 UI 상태를 직접 관리하고 싶지 않은 이유이다. 왜냐하면 매번 state를 변경할 때 마다 자동으로 UI 에 적용되게 하고싶기 때문이다.
데이터가 reference type일 경우 데이터를 옯길 때 마다 그 데이터에 대한 참조를 전달한다. 데이터에 대한 변화(누가 수정했던) 는 다른 곳에서도 볼 수 있다. 

SwiftUI에서는 single source of truth 를 행위(behavior)가 더해진 reference type 이라고 생각할 수 있다.

이전에 ScoreView를 만들때 numberOfAnswered를 선언하고 끝났었다. 이 값이 현재는 ScoreView의 UI 에 대해서 수정을 요구하지 않고 parent view 인 ChallengeView 의 view 를 변화시킨다.
ScoreView 컴포넌트를 독립적으로 본다면 numberOfAnswered 는 완료된 질문의 갯수만 보여주기 때문에 굳이 State 를 사용할 필요가 없었다.

ChallengeView.swift열고 @State var numberOfAnswered = 0 프로퍼티를 추가한다.
지금 당장 생각되는건 이 property 를 ScoreView 에 넘기는 작업만 하면 될 것 같지만 몇가지 더 해야한다.
만약 ScoreView 에 넘겨주는 작업만 한다면 어떻게 될지 테스트 해보자
방금 선언한 numberOfAnswered에서 inline initialization 을 없애고 initializer 를 사용하게 바꿔보자

  • 새로운 state 프로퍼티를 만들었고
  • ScoreView 생성자에 해당 property 를 넘겨주었다.

이제 ChallengeView 에게 추가적인 파라미터를 전달하도록 수정하자.
임시로 테스트 할 수 있게 Button action handler 에 increment logic을 추가하자
그리고 ChallengeView 와 ScoreView 에 각각의 property 값을 확인할 수 있는 Text를 추가해서 보면

둘의 값이 맞지 않는걸 확인할 수 있다.
왜냐하면 @State로 표시된 State<Value> type 은 사실 value type 이기 때문이다. 이것을 method로 전달하면 실제로는 복사본을 전달하는 것 이다.
state propery가 데이터를 가지고 있으므로 state 를 복사해서 전달한다는 말은 즉 데이터의 복사값을 전달한다는 것이다. 
그래서 original value 와 copy value 가 다른 이유이다.

SwiftUI 관점에서 @State property를 복사하는 것은 결국 여러개의 sources of truth 를 가지게 되는 것이다.
source of truth 가 여러개라는 말은 untruth 라고 이해하면 된다.

피자번호에 빗댄 설명

다시 코드로 돌아와서 데이터를 전달하는 대신 reference 를 전달해야 한다.
binding 이 그 reference 이다. ScoreView 에 가서 state property 를 binding 으로 바꿔주자

다시 ChallengeView 로 돌아와서 내부의 ScoreView 의 파라미터를 수정한다.

ScoreView 의 preview 에도 같은 작업을 해준다.

잘 된다.

무엇을 배웠냐면

  1. State 변수를 사용해 맞춘 문제의 갯수를 저장하는 counter 를 만들었다.
  2. binding을 ScoreView 로 전달해 같은 데이터에 대해 참조할 수 있도록 만들었다.
  3. 데이터가 바뀔 때 마다 (binding 으로 바뀌든 State 로 바뀌든) 둘다 변경사항이 적용되도록 만들었다.

ChallengeView : 카드를 보여주고 ScoreView를 childView 로 가진다.
ScoreView : 맞춘갯수와 전체 문제를 보여주는 간단한 view 이다.

ChallengeView
- @State numberOfAnswered
- ScoreView(numberOfAnsered: $numberOfAnswered)

ScoreView
- @Binding var numberOfAnswered : Int
- Text("\(numberOfAnswered)")

ChallengeView 의 State 에 대한 값을 ScoreView 에서 참조하기 위해 Binding 변수를 만들고 ChallengeView 에 $로 연결시켰다.


Cleaning up

했던거 깔끔하게 지우자

The art of observation 

source of truth 가 가지는 데이터를 binding 을 통해 전달해 보았다. 그리고 state 를 통해 본인이 data를 가지도록 해봤다. 
이제 멋있는 UI 를 만들 준비가 모두 끝났다!
땡! 아직 안끝났다.

여러개의 properties 로 구성된 model을 State variable 로써 사용하고 싶다고 생각해 보면
model 자체를 struct 과 같은 value type 으로 구현하면 동작이 되긴 하겠지만 효율적이지 못하다.
왜냐하면 model 내의 하나의 property 를 수정했다 쳐도 내부적으로는 그것을 포함하는 model instance 를 통채로 바꾸기 때문이다. 

만약 model 에서 하나의 property 를 바꾼다고 치면 UI는 해당 property 에 연관된 view 만 update 하길 기대할 것이다.
하지만 위와 같은 방식을 사용하면 model instance 가 통채로 바뀌기 때문에 바뀌지 않은 다른 view 들, 즉 전체가 통채로 refresh 된다.

이런 상황은 성능에 적게나마 영향을 미칠 수 있다. 
이 말이 struct 를 사용하면 안된다는건 아니지만 되도록 연관되지 않은 property 들은 같은 model 에 넣는 걸 지양해야 한다는 것이다.
이렇게 연관없는 property 를 따로 처리하면 해당 property 사용하지 않는 view 의 쓸때없는 refresh 를 막아줄 수 있다.

만약 model 을 class 와 같은 reference type으로 구현한다면, 원하는 대로 동작하지 않을 것이다.
만약 property 가 reference type인 경우 그것은 새로운 reference 를 assign 할 때만 값이 바뀔 것이다.
실제 instance 에 적용되는 변경사항은 property 그 자체를 바꾸지 않고 결국 원하는 UI refresh 도 일어나지 않게된다.

좋은 소식은 3가지 새로운 type이 있다는 것 이다.
위와 같은 상황을 고려할때 custom model 은

  • reference type 이여야 한다.
  • 어떤 properties 가 trigger 되어 UI update 가 발생될지 명시할 수 있어야 한다.

3가지 새로운 타입은

  • class 를 observable 로 선언해라. state properties 와 비슷하게 쓸 수 있도록 해준다.
  • class property 를 observable로 선언해라
  • observable class type의 instance 인 property 를 선언해라(observed)
    이를 통해 view 에서 observable class 를 observed property 로 사용할 수 있게 해준다.

이미 observable objects 에서 사용 가능한 두가지 classes 가 존재한다.

  • UserManager
  • ChallengesViewModel

class 를 observable 하게 만들려면 ObservableObject 를 준수하게 만들어야 된다.
준수하게 되면 Class 는 Publisher 가 될 수 있다.
프로토콜은 objectWillChange property 만 정의하기 때문에 (컴파일러가 자동으로 만듬) 따로 구현할 필요가 없다.

UserManager 를 보면

  1. class 가 ObservableObject를 준수해서 publisher 로 만들어 준다.
  2. @Published attribute로 두개의 프로퍼티를 정의한다. 이 프로퍼티는 view 에서 state 프로퍼티 처럼 동작한다.
  3. isRegistered 는 computed property 이다. computed property 는 만약 다른 published propery 를 참조하면 published 특권을 상속받는다. 그 말은 view 에서 property 를 사용할때 computed value 가 바뀔 경우 UI update를 한다는 점이다.

state property 에 대해서 고려할 점들을 마찬가지로 published property 에도 고려해야 한다.

  • value type 이여야 한다. (basic type or structures)
  • one-struct-for-all 시나리오를 피하기 위해 꼭 structure에 프로퍼티 갯수를 최소한으로 줄여야 한다.

이제 observable class 가 준비되었다면 사용하는건 쉽다.
state 변수를 사용하는 것 과 비슷하다.

ChallengeViewModel 에서 observable class 를 사용해 보겠다.
ViewModel 에는 일본단어와 영어 해석으로 리스트를 가지고 있다.

published property 는 state property 와 비슷하게

  • single source of truth 를 정의한다.
  • binding 가진다.
  • update 될 때 마다 해당 값을 참조하는 UI refresh 를 trigger 한다

이 property 를 사용하는 적절한 곳은 challenge view이다. 

해당 ViewModel 에 대해서 ObservedObject 를 만들어 주고
QuestionView에 ViewModel 에 있는 문제를 공급하고 ChoicesView 에 ViewModel 에 있는 보기를 공급한다.

그리고 문제를 풀었을 때 다음 문제로 넘어가는 코드를 Button 의 action handler 에 작성한다.
(다음문제로 넘기는 함수또한 ViewModel 을 참조한다.)

이 상태에서 실행해 보면 매번 문제와 보기가 바껴서 보여지는 걸 볼 수 있다.

하지만 ChallengeView 는 ChallengeViewModel 이 쓰일 적당한 공간이 아니기 때문에
방금 추가한 코드를 다시 지운다.

아니 그렇다면 challengesViewModel 은 어디 있어야되는거지

PracticeView 는 ChallengeView 를 참조한다. 둘 binding 인 두개의 property 를 포함하고 있으므로 reference data를 다른 곳에 저장해야 한다.
(PracticeView 와 ChallengeView 둘다 challengesViewModel 을 사용해서 (문제, 정답 데이터) ChallengeView 에 놔둘 수 없다고 이해했다.)

ChallengeView 의 목적은 사용자가 challenge 를 통과하지 못했을 때 문제를 보여주고 통과했을 경우 congratulationView 를 보여주는 역할을 한다.
WelcomeView 는 반대로 PracticeView를 참조한다. PracticeView 를 보면 이미 ChallengeViewModel 를 가지고 있는 걸 볼 수 있다.

(이럴 경우 만약 ChallengeView 에서 ViewModel instace 를 만들고 PracticeView 에서도 ViewModel instance 를 만들면 single source of truth 가 깨지는 거니 그래서 environmentObject 가 나오는 것 같다.)

Sharing in the environment

game 진행을 할 수 있는 기능이 없다.
정답을 맞췄을 경우 다음 challenge 로 넘어가는 기능을 만들자

ChallengesViewModel 에서 아래 두 메서드를 확인할 수 있다.

맞춘 문제를 저장한 다음, 다음 challenge로 진행하는 기능을 만드려면 generateRandomChallenge() 를 사용하면 된다.

ChociesView 에서 이미 이 메서드 들으 사용하고 있다. ChociesView 를 보면

  • challengeViewModel 프로퍼티를 가지고 있고 @ObservedObject 로 선언되 있다.
  • generateRandomChallenge()를 Alert Dismiss button handler 에서 invoke 한다.
  • checkAnswer(at:) 에서 saveCorrectAnswer()와 saveWrongAnswer()를 invoke 한다.

하지만 앱은 하나의 문제를 풀면 다음문제로 넘어가지지 않는다.
왜냐하면 ChociesView 에서도 ChallengesViewModel instance 를 만들었고 WelcomeView 에서도 또 ChallengesViewModel instance 를 만들었기 때문이다. 그래서 각각의 수정사항이 서로에게 전달되지 않았다.

하나의 해결방법으로는 challengesViewModel 을 WelcomeView 에서 ChoicesView 로 생성자를 통해 전달하는 게 있겠지만 엘레강스 하지 않다. 더 좋은 방법으로 해보자

대부분 이런 경우 singleton 방식으로 해결할 수 있지만 singleton pattern 은 최적의 해답은 아니다. DI같은 걸 써서 피할 수 있는 필요없는 의존성을 만들기 때문이다.

SwiftUI는 DI가 아니라 가방같은 곳에 object를 넣어서 필요할 때마다 꺼내 쓰는 방법을 제공해 준다. 
이 가방은 environment 라고 불려지며 object sms environment object라고 불린다.

이 패턴은 modifier와 attribute 를 이용한다.

  • environmentObject(_:) 을 사용해서 object를 environment 에 주입한다.
  • @EnvironmentObject를 사용해서 environement 에서 object를 꺼내어 property 에 저장한다.

한번 environment 에 object를 주입하면 view 와 subviews 에서 접근이 가능해 지는데 parent view 에서는 불가능 하다.

일단 rootView 에 주입하기 위해 SceneDelegate.swift 에서 enviroment object 를 넣어주자

이제 StarterView 계층에 있는 모든 View 에서 해당 instance 에 접근이 가능하다.

* 보면 이름을 설정안한 ChallengesViewModel() 로 주입하는 걸 볼 수 있다. instance type 만 명시해주면 되는데
왜냐하면 environment 에는 type 별로 단 하나의 instance 만들 주입할 수 있기 때문이다. 
다른 instance를 넣으면 기존 instance 를 덮어쓴다.

이제 ChallengesViewModel 를 사용하는 모든 곳에 수정해야 한다. (WelcomeView, ChoicesView)
(@ObservedObject 를 @EnvironmentObject 로 수정)

  • @EnvironmentObject attribute 를 사용함으로써 해당 프로퍼티가 view environment 에서 ChallengesViewModel 의 instance를 가져온다라고 명시해준다.
  • 생성된 intance 를 가져오기 때문에 instantiate 가 필요없다.

이제 실행해 보면 다음 문제로 잘 넘어가는 걸 볼수 있다.

하지만 2가지 문제점이 있다.

  1. 맞춘 갯수가 증가하지 않는다.
  2. 5개를 맞춘 뒤 congratulationview 에서 Play Again 을 눌렀을 때 빠져나갈 수 없다.

2번째 문제는 바로 해결할 수 있다.
Practice/CongratulationView.swift에서 사용하는 viewmodel 을 @ObservedObject 가 아닌 @EnvironmentObject로 바꾼다.

1번째 문제는 Practice/ScoreView.swift를 열어서 @State 로 되있는 numberOfAnswered를 @Binding 으로 고쳐서 전달한다. preview 에도 argument 를 전달한다.
(여기서 EnvironmentObject를 쓰면 안되냐 할 수 있는데 필요없는 의존성을 만들고 변수는 한쌍의 숫자만 표시하기 때문에 최대한 멍청하게 만든다.)


ChallengeView 에도 마찬가지로 

진짜 마지막으로 PracticeView 에도 수정

WelcomeView 에서 맞춘 갯수를 보여줘야 하기 때문에 ChallengeViewModel 에 맞춘 갯수를 반환하는 computed property를 추가한다.

그리고 이 값을 WelcomeView 에서 접근할 수 있도록 하면

read only 에 bind 걸었다고 에러난다. 이때 해결 방법은 constant()를 사용하여 immutable value 에 대해 binding 을 걸어준다.

이제 정답 counter 도 잘 동작한다.

Understanding environment properties

SwiftUI는 environment 를 다룰 수 있는 또다른 흥미로운 방법을 제공한다.
이전 챕터에서 environmental object를 주입하고 view 계층 내에서 언제든 뽑아 쓸수 있다고 배웠다.

SwiftUI 는 자동으로 같은 environment를 system-managed environment value를 통해 생성할 수 있다.

developer.apple.com/documentation/swiftui/environmentvalues

 

Apple Developer Documentation

 

developer.apple.com

예를들어 어떤 color scheme(dark, light)를 사용하는지 알 수 있을 뿐만 아니라 값을 바꿔서 UI를 업데이트 시킬 수 도 있다.

Kuchi에서 device 가 landscape mode 일때 view 를 수정해 본다.

그러기 위해서 먼저 device orientation 값을 받아와 그에따라 반응형으로 UI가 업데이트되야 한다.
아쉽게도 명시적으로 그런 property 는 존재하지 않는다.
verticalSizeClass를 사용해서 (enum type) device orientation 이 .compact 인지 .regular 인지 알 수 있다.

프로퍼티를 읽고 subscribe 하기 위해서 새로운 @Environment attribute를 사용해 property key path를 전달 할 수 있다.

ChallengeView 로 가서 

프로퍼티 이름은 맘대로 할 수 있으나 keypath 에 있는 원래 이름으로 하는게 헷갈리지 않고 좋다. 
따로 type은 명시하지 않아도 된다.

그리고 orientation 에 맞게 View 가 바뀌도록 body 쪽 코드를 수정한다.

  • 잠재적으로 여러개의 view 를 반환할 수 있으니 @ViewBuilder attribute 가 필요하다.
  • vertical class 가 compact 인지 확인한다. 만약 그렇다면 device 는 landscape mode 라는 뜻이다.
  • landscape mode 일 경우 VStack을 통해 점수를 바닥에 표시한다.
  • HStack으로 QuestionView 와 ChoicesView 를 수평으로 표시한다.
  • else는 원래 UI 코드이다.

뷰 계층에 속안 어느 view 에서도 .environment(_:_:) modifier를 통해서 값을 변경할 수 있다.

ChallengeView의 parent 인 WelcomeView 에서 modifier 변경으로 확인해 보겠다.

화면에서 알 수 있듯이 PracticeView와 하위 subview 들에게 vertical size class 를 compact로 강제하고 있는 셈이다.

Creating custom environment properties

Environment properties는 따로 하나 만들어서 쓰고싶을 정도로  참 유용하다. 
만들 수 있다ㅇㅇ

두 단계로 custom environment property 를 만들 수 있다.

  1. property key로 사용할 구조체 타입을 만들어 EnvironmentKey 를 준수하게 한다.
  2. EnvironmentValues extension 에 새롭게 computed property 를 추가하여 subscript operater 를 사용해 값을 읽고 쓸 수 있게 만든다.

ScoreView를 보면 맞춰야할 문제 수가 ChallengeViewModel 에 있는 값이 아닌 constant 로 전달되는 걸 볼 수 있다.
이 값에 custom environment properties 를 적용해 보자

ChallengeViewModel 에 구조체를 선언한다.

이 정의는 무엇을 뜻하냐면

  • subscript operator 를 사용하기 위한 key
  • property 에 assign 될 default 값

그리고 실제 property 를 EnvironmentValues extension 에 정의한다.

새로운 property 를 만들기 위해선

  1. EnvironmentValues extension을 만든다.
  2. questionsPerSession computed property 를 추가한다.
  3. QuestionsPerSessionKey type을 통해 property 에 reading/writing 을 한다.

이제 ChallengesViewModel 에 numberOfQuestions를 추가한다. (read-only)

그리고 generateRandomChallenge() 에 constant 5를 env property 로 바꿔준다.

WelcomeView 에서 custom environment 를 넣어준다.

이제 프로퍼티는 다 만들었고 ChallengeView 에서 써보자

이미 있던 verticalSizeClass 와 동일하게 사용할 수 있다.

마지막으로 화면에 표시한다.

Key Point

  • @State 를 통해 데이터를 가지는 property를 선언되는 view 가 가질 수 있게 하고 값이 바뀔때 자동으로 해당 값을 사용하는 UI부분을 다시 rendering 할 수 있다.
  • @Binding을 통해 state 와 비슷하지만 다른곳에 저장되는 데이터를 사용할 수 있다. 
    (조상 view 의 state property 나 observable object 에 있는)
  • @ObservedObject를 사용해 property를 만들 수 있다.
    ObservableObject 를 따르는 class instance 를 만들어 하나 이상의 @Published property 를 정의할 수 있다.
    이들은 state variable 과 비슷하게 동작하지만 view 가 아닌 class 에 있다는게 차이점이다.
  • @EnvironmentObject를 사용해 observable object를 가방같은 곳에 넣을 수 있다.
    주입된 view 의 후손들은 전부 observable object를 꺼내서 사용할 수 있다.
  • @Environment 는 system environment value (ColorScheme-dark.light, local-) 과 같은 값에 접근할 수 있게 해준다.
    binding 의 장점인 reactivity 를 가지는 environement property 를 만들 수 있다.
  • @Environment 를 사용해 custom environment properties 를 만들 수 있다.

참고링크 

  • SwiftUI documentation

 

Apple Developer Documentation

 

developer.apple.com

State and data flow reference documentation

 

 

Apple Developer Documentation

 

developer.apple.com

Combine documentation

 

 

Apple Developer Documentation

 

developer.apple.com

SwiftUI Attributes Cheat Sheet

 

Jared Sinclair | When Should I Use @State, @Binding, @ObservedObject, @EnvironmentObject, or @Environment?

SwiftUI introduced a laundry list of new property wrappers that your code can use to bridge the gap between program state and your views: @State @Binding @ObservedObject @EnvironmentObject @Environment That’s only a partial list. There are other property

jaredsinclair.com

 

728x90