ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Chapter 8: State & Data Flow — Part I
    Raywenderlich/SwiftUI by Tutorials 2021. 4. 12. 10:22

    Version

    Swift 5.3, iOS 14, Xcode 12

     

    이전 장(previous chapters)에서는 가장 일반적인(common) UI 구성 요소(components)를 사용하여 사용자 인터페이스(user interface)를 구축(build up)했다. 이 장(chapter)에서는 SwiftUI의 다른 면(side)인 상태(chapter)에 대해 알아본다.

     

    MVC: The Mammoth View Controller

    UIKit 또는 AppKit으로 작업한 경험이 있다면, 이 섹션(section)의 제목(title)에도 불구하고 Model View Controller를 나타내는 MVC 개념(concept)에 익숙(be familiar with)할 것이다. 막말로(vulgarly), Massive View Controller로도 알려져(known as) 있다.

    MVC에서 뷰(View)는 사용자 인터페이스(user interface)이고, 모델(Model)은 데이터이며, 컨트롤러(Controller)는 모델(model)과 뷰(view)를 동기화(sync)를 유지(keeps)하는 접착제(glue)이다. 그러나 이 접착제(glue)는 자동(automatic)이 아니다. 명시적으로(explicitly) 코딩해야 하며, 모델(model)이 변경(changes)될 때 뷰(view)를 업데이트(updating)할 수 있는 모든 경우(case)를 다루어야(cover) 한다.

    nameUITextField(macOS에서는 NSTextField)가 있는 뷰 컨트롤러(view controller)를 생각(consider)해본다:

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

    텍스트 필드(text field)에 name을 표시(displayed)하려면, 다음과 같은 구문(statement)을 사용하여 수동으로(manually) 복사(copy)해야 한다:

    nameTextField.text = name

    마찬가지로(likewise), 텍스트 필드(text field)의 내용(contents)을 name 속성(property)에 복사(copy)하려면, 다음과 같은 구문(statement)을 사용하여 수동으로(manually) 수행해야 한다:

    name = nameTextField.text

    둘 중 하나의 값(value)을 변경(change)해도, 다른 하나는 자동으로(automatically) 업데이트(update)되지 않는다. 코드를 사용하여 수동으로(manually) 수행해야 한다.

    이것은 단순한 예제(example)라 name을 computed property로 만들어 텍스트 필드(text field)의 text 속성(property)에 대한 프록시(proxy)로 사용하면 문제를 해결할 수 있다. 그러나 모델(model)이 임의(arbitrary)의 데이터 구조(data structure)이거나 둘 이상의 데이터 구조(data structure)라면, 이러한 접근 방식(approach)으로는 모델(model)과 뷰(view)를 동기화(sync) 상태로 유지(keep)할 수 없다는 것을 알게 된다(realize).

    모델(model) 외에도 UI는 상태(state)에 따라 달라진다. 예를 들어(for instance) 토글(toggle)이 꺼져 있으면 숨겨야(hidden) 하는 구성 요소(component)나, 텍스트 필드(text field)의 내용(content)이 비어(empty) 있거나 유효성(validated)이 확인되지 않은 경우 비활성화(disabled)되는 버튼(button)을 생각해(consider) 본다. 그리고 적시에 올바른(correct) 논리(logic)를 구현(implement)하는 것을 잊었거나, 논리(logic)가 변경(changes)되었지만 사용하는 모든 곳(everywhere)에서 업데이트(update)하지 않는 경우 어떻게 되는지 생각해 본다(consider).

    불에 연료를 추가하기 위해 AppKit과 UIKit에서 구현(implemented)된 model view controller 패턴(pattern)은 뷰(view)와 컨트롤러(controller)가 별도(separate)의 엔티티(entities)가 아니기 때문에 다소(bit) 독특하다(unconventional). 대신 뷰 컨트롤러(view controller)라고 하는 단일 엔티티(entity)로 결합(combined)된다.

    결국(in the end), 별도의 엔티티(entities)라는 생각을 지워버리고 동일한 클래스(class) 내에서 모든 것(모델, 뷰, 컨트롤러)을 결합(combine)하는 뷰 컨트롤러(view controllers)를 발견하는 것은 드문 일이 아니다. 이로 인해 Model View Controller의 "Model" 용어(term)가 "Massive"로 대체되어 Massive View Controller로 알려진 완전히 새로운(brand new) 패턴(pattern)이 되었다.

    요약하자면(sum up), SwiftUI 이전에는 다음과 같았다:

    • 거대한 뷰 컨트롤러(massive view controller) 문제(problem)는 현실이다.
    • 모델(model)과 UI를 동기화(sync) 상태로 유지(keeping)하는 것은 수작업(manual) 과정(process)이다.
    • 상태(state)가 항상 UI와 동기화(sync)되는 것은 아니다.
    • 뷰(view)에서 하위 뷰(subviews)로 또는 그 반대로(vice versa), 상태(state)와 모델(model)을 업데이트(update)할 수 있어야 한다.
    • 이 모든 것은 오류가 발생하기 쉽고(error-prone), 버그(bugs)가 있을 수 있다.

     

    A functional user interface

    SwiftUI의 장점(beauty)은 사용자 인터페이스(user interface)가 함수적(functional)으로 작동한다는 것이다. 일을 엉망으로 만들(mess up) 수 있는 중간 상태(intermediate state)가 없으며, 뷰(view)가 특정(certain) 조건(conditions)에 따라 표시(display)되어야 하는지 여부를 결정(determine)하기 위해 여러 번(multiple) 검사(checks)할 필요가 없고, 상태(state) 변경(change)이 있을 때 사용자 인터페이스(user interfac)의 일부를 수동(manually)으로 새로 고칠(refresh) 필요가 없다. 

    또한, 클로저(closures)에서 [weak self]를 사용하여 순환 참조(circular references)를 피해야(avoid)하는 부담에서 해방(freed)된다. 뷰(views)는 값 유형(value types)이므로, 참조(references)가 아닌 복사본(copies)을 사용하여 캡처(captures)한다.

    함수형(functional)이기 때문에 렌더링(rendering)은 동일한 입력(input)에 대해 항상 동일한 결과를 생성(produces)하며, 입력(input)을 변경(changing)하면 자동으로(automatically) 업데이트(update)가 발생(triggers)한다. 올바르게 연결(connecting)하면 사용자 인터페이스(user interface)는 데이터를 가져와야(pull) 하는 것이 아니라, 사용자 인터페이스(user interface)로 데이터가 전달(pushes)된다.

    그렇다고 이제 새로운 직업(job)을 찾고 경력(careers)을 바꿔야 한다는 의미는 아니다. 여전히 사용자 인터페이스(user interface) 구현(implement)과 데이터와 UI 연결(link)을 제어(control)해야 한다. 단지, 이제 훨씬 더 간단(simpler)하고 오류 발생률(error-prone)이 훨씬 더 낮을 뿐이다. 더 우아하다(elegant)는 것은 말할 것도 없다.

    SwiftUI에는 많은 긍정적인(positive) 측면(aspects)이 있다. 그 중에서 주된(primarily) 내용은 다음과 같다:

    • 선언적(Declarative): 사용자 인터페이스(user interface)를 구현(implement)하지 않고, 선언(declare)한다.
    • 함수적(Functional): 동일한 상태(state)가 주어지면, 렌더링(rendered)된 UI는 항상 동일하다. 즉(in other words), UI는 상태(state)의 함수(function)이다.
    • 반응적(Reactive): 상태(state)가 변경(changes)되면, SwiftUI가 자동으로(automatically) UI를 업데이트(updates) 한다.

    이 장(chapter)에서는 주로 마지막 측면(aspect)인 상태(state)와 UI 간의 관계(relationship) 관리(managing) 및 뷰(view)에서 하위 뷰(subviews)로 상태(state)를 전파(propagate)하는 방법에 중점(focuses)을 둔다.

    이제 시작(starter) 프로젝트(project)를 열고 빌드(build)하여 실행(run)한다. 이 장(chapter)과 함께 제공되는 시작(starter) 프로젝트(project) 또는 이전 장(previous chapter)에서 개발한(developed) 프로젝트(project)의 사본(copy)을 사용할 수 있다.

    새 설치(installation) 또는 이전 장(previous chapter)의 버전(version)을 사용하는지에 따라 앱(app)은 registration view 또는 welcome view로 시작된다.

    아래 그림에서 일본어 단어를 표시하는 첫 번째 뷰(view)인 challenge view에 도달할 때까지 계속 진행(proceed)한다. 탭(tap)하면 두 번째뷰(view)와 같이 답변(answer)에 대한 세 가지 옵션(options) 목록(list)이 표시(display)된다. 잘못된 옵션(option)을 누르면(tap) 오류(error) 메시지(message)가 표시된다. 그렇지 않으면(otherwise), 세 번째 뷰(view)에 표시된 것 처럼 정답(correct answer)을 선택했다는 알림(alert)이 나타난다.

    Initial Challenge View

     

    이게 전부이다. 앞으로(forward) 진행해 또 다른 문제(challenge)를 푸는 옵션(option)이 없다. 그것을 고치기(fix) 위해 @State를 사용할 것이다.

     

    State

    지금까지 이 책을 따라 읽었다면, 이미 @State 속성(attribute)을 접한(encountered) 적이 있으며 그 속성의 용도와 사용 방법에 대해 간략히 배웠다. 이제는 좀 더 자세히 알아볼 때이다.

    Note: 이제 이 장(chapter)의 개념(concepts) 중 일부를 이해(understand)하기 위해 몇 가지를 시도해 본다. 끝까지 진행해 본다면(bear with it), 그 이유는 결국 분명해질 것이다.

    가장 먼저 할 일은 몇 가지 카운터(counters)를 추가(add)하여 추적(track)하는 것이다:

    • 답변한 질문의 수(The number of answered questions)
    • 총 도전 횟수(The total number of challenges)

    Practice 그룹(group)에서 새 SwiftUI 파일(file)을 만들고, 이름을 ScoreView.swift로 지정한다.

    다음으로 두 가지 속성(properties)을 추가하여 답변(answers)과 질문(questions) 수(number)를 추적(track)한다:

    var numberOfAnswered = 0
    var numberOfQuestions = 5

    그런 다음 자동 생성된(auto-generated) body를 다음으로 바꾼다(replace):

    var body: some View {
      HStack {
        Text("\(numberOfAnswered)/\(numberOfQuestions)")
          .font(.caption)
          .padding(4)
        Spacer()
      }
    }

    Xcode에서 미리보기(preview)를 재개(resume)한다. 다음과 같아야 한다:

    Score View

     

    이제 버튼(button) 뒤에 이 새 뷰(view)를 추가(adding)하여, ChallengeView에 포함(embed)시킨다:

    Button(action: {
      self.showAnswers = !self.showAnswers
    }) {
      QuestionView(question: challengeTest.challenge.question)
        .frame(height: 300)
    }
    // -Insert this-
    ScoreView()

    미리보기(preview)는 다음과 같다:

    Challenge Score View

     

    총 도전(challenges) 횟수와 비교(compared)하여 계산된(calculated) 현재(current) 진행 상황(progress)을 표시(display)하는 ScoreView로 돌아간다. 지금은 진행 상황(progress)만 시뮬레이션(simulate)하려 한다. 탭(tap)할 때 도전 횟수(number of challenges)를 증가(increments)시키는 버튼(button)을 추가한다.

    body 구현(implementation)을 다음으로 대체(replace)한다:

    var body: some View {
      Button(action: { //Button을 추가한다.
        self.numberOfAnswered += 1
        //action handler에서는 numberOfAnswered를 증가시킨다.
      }) { //Button의 body에 이전에 구현한 content를 포함한다.
        HStack {
          Text("\(numberOfAnswered)/\(numberOfQuestions)")
            .font(.caption)
            .padding(4)
              
            Spacer()
        }
      }
    }

    미리보기(preview)를 재개(resume)하려고, 시간을 낭비(waste time)하지 않는다. 미리보기(preview)는 작동하지 않으며, 컴파일(compile) 되지도 않는다.

    Score View error

     

    간단히 말해, body 내부(inside)에서 속성(properties)을 수정(modifying)하여, 뷰(view)의 상태(state)를 변경할 수 없다.

     

    Embedding the state into a struct

    속성(properties)을 별도(separate)의 구조(structure)로 이동해야 한다. numberOfAnswered를 내부(internal) State 구조체(struct)로 이동하고, 뷰(view)의 속성(property)으로 만든다:

    struct ScoreView: View {
      var numberOfQuestions = 5
        
      struct State {
        var numberOfAnswered = 0
        //numberOfAnswered를 구조체로 Encapsulate한다.
      }
        
      var state = State()
      //해당 구조체의 인스턴스를 새 property로 추가한다.
        
      var body: some View {
        ...
      }
    }

    다음으로 HStack 내부의 텍스트(text)를 업데이트하여, 속성(property)의 새 위치(location)를 반영(reflect)한다:

    Text("\(state.numberOfAnswered)/\(numberOfQuestions)")

    그리고 버튼(button)의 동작(action)을 수정한다:

    self.state.numberOfAnswered += 1

    그러나 컴파일(compile)하려 하면 동일한 오류(error)가 발생한다. 불행하게도(unfortunately), 이것도 효과가 없다. 구조체(struct)가 값 유형(value type)이고, 여전히 뷰(view)의 내부 상태(internal state)를 변경(mutate)하려고 하기 때문에 이는 놀라운(surprising) 일이 아니다.

     

    Embedding the state into a class

    그러나 값 유형(value type)을 참조 유형(reference type)으로 대체(replacing)하면, 상황이 크게(considerably) 변한다. State를 클래스(class)로 만들어 본다:

    class State {
      var numberOfAnswered = 0
    }

    이제 오류(error)가 사라지고(disappears), 미리보기(preview)를 재개(restore)할 수 있다. 실시간 미리보기(live preview)를 활성화해 본다:

    Score view live preview

     

    이제 뷰(view)를 탭(tap)하면, 시각적으로(visually) 반응(reacts)하지만 표시된(displayed) 텍스트(text)는 변경(change)되지 않는다. 0/5으로 고정(anchored)되어 있다.

    numberOfAnswered를 증가(increment)시킨 후, 버튼(button)의 동작 처리기(action handler)에 print 문(statement)을 추가한다:

    self.state.numberOfAnswered += 1
    print("Answered: \(self.state.numberOfAnswered)")

    앱(app)을 실행(run)하고 텍스트(text)를 탭(tap)할 때마다, 콘솔(console)에 새 값(new value)이 표시(displays)되는 것을 볼 수 있다. 이는 상태(state)가 업데이트(updates)되지만, 뷰(view)는 그렇지 않음을 의미한다.

    Note: 이 단계에서는 시뮬레이터(simulator)에서 실행(run)하거나 디버그 미리보기(Debug Preview)를 사용하여 print 문(statement)의 출력(output)을 확인해야 한다.

    이것은 실제로 UIKit을 사용하는 경우 예상되는(expected) 동작(behavior)이다. 모델(model)이 변경(changes)되면 사용자 인터페이스(user interface)의 관련(relevant) 부분을 업데이트(update)하는 것은 개발자의 책임(responsibility)이다.

     

    Wrap to class, embed to struct

    여전히 작동하지 않으므로, 여기에 해결해야할 과제(challenge)가 있다. 클래스(class)를 제거(get rid of)하고 구조체(struct)를 다시 사용한다.

    왜 해야 하는지 궁금하다면, 이 장(chapter)의 unconventional 섹션(section)을 읽으면 분명해질 것이다.

    기억한다면, 구조체(struct)가 이전에 작동하지 않은 이유는 구조체(struct)가 값 유형(value type)이기 때문이다. 값 유형(value type)을 수정(modifying)하려면 가변성(mutability)이 필요하지만, body은 이를 포함(contains)하는 구조체(struct)를 변경(mutate)할 수 없다.

    변경(mutating)하지 않고 업데이트(update)하려면, mutating 속성(property)을 참조 유형, 즉 클래스(class)로 래핑(wrap)하면 된다. 따라서 ScoreView 앞에 다음을 추가한다:

    class Box<T> {
      var wrappedValue: T
      init(initialValue value: T) { self.wrappedValue = value }
    }

    이렇게 하면, 클래스(class) 내(inside)에서 값 유형(value type, 실제로 모든 유형)을 래핑(wrap)할 수 있다. 이제 State를 다시 구조체(struct)로 만들고, 해당 속성(property)을 Box<Int>의 인스턴스(instance)로 만든다:

    struct State {
      var numberOfAnswered = Box<Int>(initialValue: 0)
    }

    이제 numberOfAnswered를 수정(modifying)하지 않고, Box에 포함된(contained) value을 변경(mutate)할 수 있기 때문에 작동한다. 다른 인스턴스(instance)를 가리키는 경우에만 변경(mutate)하는데, 속성(property)이 가리키는 인스턴스(instance)를 업데이트(update)한다.

    이제 Box 인스턴스(instance) 자체가 아닌 BoxwrappedValue 속성(property)을 사용해야하므로, Xcode는 여전히 두 가지 컴파일(compilation) 오류(errors)를 표시한다. 이를 수정해야 한다. Button의 동작(action) 클로저(closure)에서 다음과 같이 증가(increment) 구문(statement)을 업데이트(update)한다:

    self.state.numberOfAnswered.wrappedValue += 1

    여기서는 numberOfAnsweredwrappedValue를 증가(increment)시킨다. 마찬가지로 다음에 오는 print 구문(statement)을 업데이트(update)한다:

    print("Answered: \(self.state.numberOfAnswered.wrappedValue)")

    그리고 마지막으로 HSatck 내부(inside)의 Text를 변경한다:

    Text("\(state.numberOfAnswered.wrappedValue)/\(numberOfQuestions)")

     

    The real State

    이제 공식적으로(officially) 이 모든 논의(discussion)의 요점(point)을 정리한다.

    State를 SwiftUI의 유사한 구조체(struct)로 대체해야 할 때이다. 이전에 추가한(added) Box를 삭제(delete)한 다음, State 구조체(struct)와 state 속성(property)을 다음 속성(property)으로 바꾼다(replace):

    var _numberOfAnswered = State<Int>(initialValue: 0)

    밑줄(underscore)을 접두사(prefixing)로 붙여 속성(property) 이름을 변경했다. 그 이유는 곧 알게 된다(the reason why will be revealed soon).

    numberOfAnswered의 이름을 _numberOfAnswered로 바꾸고(renaming), state를 제거(removing)하여 컴파일 오류(compilation errors)를 수정(fix)한다. 이제 ScoreView는 다음과 같다:

    struct ScoreView: View {
      var numberOfQuestions = 5
    
      var _numberOfAnswered = State<Int>(initialValue: 0)
    
      var body: some View {
        Button(action: {
          self._numberOfAnswered.wrappedValue += 1
          print("Answered: \(self._numberOfAnswered)")
        }) {
          HStack {
            Text("\(_numberOfAnswered.wrappedValue)/\(numberOfQuestions)")
              .font(.caption)
              .padding(4)
            Spacer()
          }
        }
      }
    }

    빌드(build)하고 실행(run)한 다음, ChallengeView로 이동한다. score view를 탭(tap)할 때마다 카운터(counter)가 업데이트(updates)된다.

    Score view updating

     

    공식(official) SwiftUI 문서(documentation)에는 State를 다음과 같이 설명한다(apple.co/2WrfKzk):

    SwiftUI에서 관리(managed)하는 값(value)을 읽고(read) 쓸(write) 수 있는 래퍼 유형 속성(property wrapper type)

    이전에 생성한 State 구조체(struct) 내부(inside)의 Box와 비슷하지만, 이것을 포함하는(contains) 뷰(view)가 이를 모니터링(monitor)할 수 있는 추가적인 기능(capability)이 있다.

    SwiftUI는 상태(state)로 선언(declare)한 모든 속성(property)의 저장소(storage)를 관리(manages)한다. 상태 값(state value)이 변경(changes)되면, 뷰(view)는 외관(appearance)을 무효화(invalidates)하고 body를 다시 계산(recomputes)한다. 주어진 뷰(view)에 대한 단일 진실 공급원(single source of truth)으로 상태(state)를 사용한다.

    단일 진실 공급원(single source of truth)이라는 용어(term)를 기억해야 한다. 곧 다시 만나게 될 것이다.

    래핑된 값(wrapped value)이 변경(changes)되면, SwiftUI는 해당 값(value)을 사용하는 뷰(view)의 부분(portion)을 다시 렌더링(re-renders)한다.

    이전 장(chapters)에서 상태 변수(state variables)를 사용했다. 이제 State<Value>, @State 속성(attribute), $ 연산자(operator)의 관계(relationship)가 궁금할 것이다.

    _numberOfAnswered를 다음으로 바꾼다(replace):

    @State var numberOfAnswered = 0

    이게 더 친숙하다. 이제 컴파일(compile)하고 실행(run)할 수 있으며, 제대로 작동하는지 확인할 수 있다.

    @State 속성(attribute)으로 선언된(declared) 속성(property)은 속성 래퍼(property wrapper)이며, 컴파일러(compiler)는 이름 앞에 밑줄(underscore)을 추가한 _numberOfAnsweredState<Int> 형식(type)의 실제 구현(implementation)을 생성(generates)한다.

    body에서 이 속성(property)을 여전히 참조(referencing)하고 있음을 확인하여 이를 증명(prove)할 수 있다:

    var body: some View {
      Button(action: {
        self._numberOfAnswered.wrappedValue += 1
        //button의 action handler에서 counter를 증가시킨다.
        print("Answered: \(self._numberOfAnswered.wrappedValue)")
        //button의 action handler에서 counter를 출력한다.
      }) {
      HStack {
        Text("\(_numberOfAnswered.wrappedValue)/\(numberOfQuestions)")
        //button의 embedded view에서 총 질문에 대한 답변 수를 표시한다.
          .font(.caption)
          .padding(4)
        Spacer()
        }
      }
    }

    _numberOfAnswered는 위와 같이 세 곳에서 사용하고 있다.

    이제 각각을 선언(declared)한 실제 속성(property)인 numberOfAnswered로 바꿀(replace) 수 있다. 속성(property)을 그대로(as-is) 참조(reference)한다. 처음 두 경우(case)는 다음으로 바꾼다(replace):

    self.numberOfAnswered += 1
    print("Answered: \(self.numberOfAnswered)")

    컴파일러(compiler)는 이를 실제 구문(statements)으로 변환(translate)하여, numberOfAnsweredwrappedValue을 증가시키고(increase) 읽는다(read).

    세 번째 경우(case)도 동일하다. 다음으로 대체(replacing)한다:

    Text("\(numberOfAnswered)/\(numberOfQuestions)")

    앱(app)을 컴파일(compile)하고 실행(run)한다. ChallengeView로 이동(navigate)해도, 시각적(visual) 또는 동작(behavioral) 변화(change)를 알아차리지(notice) 못할 것이다. 즉, 교체(replacement)가 제대로 작동했음을 의미한다.

    이제 테스트(testing) 목적(purposes)으로 추가한 변경 사항(changes)을 롤백(roll back)해야 한다. 버튼(button)을 제거하고, HStack으로 구성된(consists of) body만 남겨 둔다:

    var body: some View {
      HStack {
        Text("\(numberOfAnswered)/\(numberOfQuestions)")
          .font(.caption)
          .padding(4)
        Spacer()
      }
    }

    배운 것을 정리해 보면 다음과 같다. 뷰(view)에 속성(property)이 있고 뷰(view) body에서 해당 속성(property)을 사용하는 경우, 속성 값(property value)이 변경(changes)되어도 뷰(view)에 영향을 주지 않는다(unaffected).

    @State 속성(attribute)을 적용하여 속성(property)을 상태 속성(state property)으로 만들면, SwiftUI와 컴파일러(compiler)가 내부적으로(under the hood) 수행하는 몇 가지 작업(magic) 덕분에, 뷰(view)는 속성(property) 변경(changes)에 반응(reacts)하여 해당 속성(property)을 참조(references)하는 뷰(view) 계층 구조(hierarchy)의 관련(relevant) 부분(portion)을 새로 고친다(refreshing).

     

    Not everything is reactive

    score view는 두 가지 속성(properties)을 정의(defines)한다. 이미 numberOfAnswered로 작업한 적이 있으며, 이는 상태 속성(state property)으로 전환(turned into) 되었다. 하지만, 다른 하나인 numberOfQuestions는 상태 속성(state property)이 아니다.

    numberOfAnswered는 동적(dynamic)이므로, 뷰(view)의 수명(life)동안 값(value)이 변경(changes)된다. 실제로, 사용자가 정답(correct answer)을 맞출 때마다 증가(increments)한다. 반면에(on the other hand), numberOfQuestions는 동적(dynamic)이 아니다. 그것은 총 질문 수(total number of questions)를 나타낸다.

    값(value)은 변경(changes)되지 않으므로, 상태 변수(state variable)로 만들 필요가 없다. 또한(moreover), var일 필요도 없다. 불변(immutable)으로 바꾸고, 생성자(initializer)로 초기화(initialize)할 수 있다.

    선언(declaration)을 다음으로 바꾼다(replace):

    let numberOfQuestions: Int

    다음으로 새 매개 변수(parameter)를 제공(providing)하여 미리보기(preview) 뷰(view)를 업데이트(update)해야 한다:

    ScoreView(numberOfQuestions: 5)

    또한, ChallengeView에서 뷰(view)를 참조(reference)하는 다른 위치에도 동일한 변경 사항(change)을 적용(apply)한다. 컴파일러(compiler)가 정확한 행(line)을 찾는 데 도움을 줄 것이다.

     

    Using binding for two-way reactions

    상태 변수(state variable)는 값(value)이 변경(changes)될 때 UI 업데이트(update)를 실행(trigger)하는 데 뿐만 아니라, 다른 작업에서도 유용하게 사용할 수 있다.

     

    How binding is (not) handled in UIKit

    UIKit/AppKit의 텍스트 필드(text field)나 텍스트 뷰(text view)에 대해 잠시 생각해 본다. 둘 다 text 속성(property)을 노출(expose)하여 텍스트 필드(text field)/텍스트 뷰(text view)에 표시되는 값(value)을 설정하고, 사용자가 입력한(enters) 텍스트(text)를 읽는 데 사용할 수 있다.

    UI 구성 요소(component)가 표시(displays)되거나 사용자가 입력한(enters) 데이터를 text 속성(property)으로 소유(owns)하고 있다고 말할 수 있다.

    해당 값(value)이 변경(changes)될 때 알림(notification)을 받으려면 델리게이트(delegate, 텍스트 뷰)를 사용하거나, 편집 이벤트(editing changed event)가 발생할 때 알림을 받도록(notified) 구독(subscribe, 텍스트 필드)해야 한다.

    사용자(user)가 텍스트(text)를 입력(enters)할 때 유효성 검사(validation)를 구현(implement)하려면, 텍스트(text)가 변경(changes)될 때마다 호출(called)되는 메서드(method)를 제공(provide)한 다음 UI를 수동으로(manually) 업데이트(update)해야 한다. 예를 들어(for example), 버튼(button)을 활성화(enable) 또는 비활성화(disable)하거나, 유효성 검사(validation) 오류(error)를 표시할 수 있다.

     

    Owning the reference, not the data

    SwiftUI는이 과정(process)을 더 단순화(simpler)한다. 선언적(declarative) 접근 방식(approach)을 사용하고 상태(state) 속성(properties)의 반응적(reactive) 특성을 활용(leverages)하여 상태(state) 속성(properties)이 변경(changes)될 때 사용자 인터페이스(user interface)를 자동으로(automatically) 업데이트(update)한다.

    SwiftUI에서 구성 요소(components)는 데이터를 소유(own)하지 않고, 대신 다른 곳(elsewhere)에 저장(stored)된 데이터에 대한 참조(reference)를 보유(hold)한다. 이를 통해 SwiftUI는 모델(model)이 변경(changes)될 때, 사용자 인터페이스(user interface)를 자동으로(automatically) 업데이트(update)할 수(enables) 있다. 모델(model)을 참조(reference)하는 구성 요소(components)를 알고 있기 때문에 모델(model)이 변경(changes)될 때, 사용자 인터페이스(user interface)의 어느 부분(portion)을 업데이트(update)해야 하는지 파악할 수 있다(figure out).

    이를 위해 참조(references)를 처리(handle)하는 정교한(sophisticated) 방법인 바인딩(binding)을 사용한다.

    6장(chapter), "Controls & User Input"의 Kuchi 앱(app)에서 TextField를 사용했다. 상태(state) 속성(property)을 사용하여 사용자 이름을 유지(hold)했으며, 나중에 환경 객체(environment object)로 대체(replaced)했다.

    이번에는 텍스트 필드(text field)에만 집중하여, 해당 양식(form)을 다시 작성한다.

    Welcome 폴더(folder)에서 RegisterView.swift를 열고, 확장(extension)을 포함한 RegisterViewRegisterView_Previews를 주석 처리(comment out)하여 나중에 다시 시작(resume)할 수 있도록 한다. 그리고 다음의 간단한 코드를 추가(add)한다:

    struct RegisterView: View {
      @ObservedObject var keyboardHandler: KeyboardFollower
      var name: String = ""
    
      init(keyboardHandler: KeyboardFollower) {
        self.keyboardHandler = keyboardHandler
      }
    
      var body: some View {
        VStack {
          TextField("Type your name...", text: name)
            .bordered()
    
        }
        .padding(.bottom, keyboardHandler.keyboardHeight)
        .edgesIgnoringSafeArea(
          keyboardHandler.isVisible ? .bottom : [])
        .padding()
        .background(WelcomeBackgroundImage())
      }
    }
    
    struct RegisterView_Previews: PreviewProvider {
      static var previews: some View {
        RegisterView(keyboardHandler: KeyboardFollower())
      }
    }

    이렇게 하면 컴파일러(compiler)는 nameBinding<String>이 아니라고 오류를 발생시킨다(complain). 공식(official) 문서(documentation)에 따르면 바인딩(binding)은 다음과 같다:

    바인딩(binding)은 데이터를 저장(stores)하는 속성(property)과 데이터를 표시(displays)하고 변경(changes)하는 뷰(view)간의 양방향 연결(two-way connection)이다. 바인딩(binding)은 데이터를 직접(directly) 저장(storing)하는 대신, 다른 곳(elsewhere)에 진실 공급원(source of truth)에 속성(property)을 연결(connects)한다.

    이전에 구성 요소(component)가 데이터를 소유(own)하지 않는다 했을 때, 다른 곳(elsewhere)에 저장(stored)되어 있는 데이터에 대한 참조(reference)를 보유(holds)하고 있다고 했다. 곧, 진실 공급원(source of truth)이 무엇인지 알게 될 것이다(find out).

    따라서, 상태(state) 속성(property)은 projectedValue에 바인딩(binding)을 포함(contains)한다. 여기서 이를 수정(fix)하려면 name 속성(property)의 유형(type)을 State<String>으로 변경한다:

    var name: State<String> = State(initialValue: "")

    다음으로, 텍스트 필드(text field)에서 이 속성(property)을 참조(reference)한다:

    TextField("Type your name...", text: name.projectedValue)

    이제 컴파일 오류(compilation error)가 사라진다(disappears). 실시간 미리보기(live preview)를 활성화(enable)하면, 텍스트 필드(text field)와 상호 작용(interact)하고, 텍스트(text)를 입력(input)할 수 있다.

    그러나(however) 실제로 작동한다는 증거(proof)가 없으므로, TextField 뒤에 name을 표시(displays)하는 Text 구성 요소(component)를 추가한다:

    Text(name.wrappedValue)

    텍스트(text)를 수정(modifying)하지 않고 표시(display)하기만 하면되기 때문에, 여기서는 바인딩(binding)이 필요하지 않다. 따라서 wrappedValue을 사용한다.

    실시간 미리보기(live preview)를 다시 시작(resume)한다. 이제 텍스트(text)를 입력(type)하면 TextField 아래의 Text 구성 요소(component)에 복제(replicates)된다:

    State and binding

     

    이는 다음을 의미한다:

    1. 사용자(user)가 텍스트(text)를 수정(modifies)하면 TextFieldname 상태(state) 속성(property)의 바인딩(binding)을 사용하여 기본(underlying) 데이터를 업데이트(updates)한다.
    2. 데이터가 변경(changes)되면, name 상태(state) 속성(property)이 데이터를 참조(reference)하는 모든 UI 구성 요소(components)에 대한 업데이트(update)를 실행(triggers)한다.
    3. Text 뷰(view)는 업데이트(update) 요청(request)을 수신(receives)하고, namewrappedValue에 포함(contains)된 값(value)을 다시 출력(reprinting)하여 콘텐츠(content)를 업데이트(updates)한다.

    이제 바인딩(binding)이 무엇이며 어디에 속하는지 살펴 보았으므로 State 속성(property) 선언(declaration)을 제거(get rid of)하고, 해당(corresponding) 속성(attribute)으로 정의(defined)된 더 매력적인(fascinating) 대응책(counterpart)을 사용하는 것이 좋다.

    name 속성(property) 선언(declaration)을 다시 한 번 다음으로 바꾼다(replace):

    @State var name: String = ""

    $ 연산자(operator)를 사용하여 바인딩(binding)에 접근(access)하므로, 텍스트 필드(text field)의 name.projectedValue$name으로 간단히 바꿀(replace) 수 있다:

    TextField("Type your name...", text: $name)

    값(value)만 참조(reference)하려면 래퍼(wrapper) 대신 원시 속성명(raw property name)을 값(value)처럼 대신 사용한다.

    Text(name)

    기능적(functional) 변경(changes)없이 다른 구문(syntax)만 사용했기 때문에, 실시간 미리보기(live preview)에서 뷰(view)를 확인(test)할 때 어떤 차이점(difference)도 느끼지 못할 것이다.

    State and binding

     

    SwiftUI의 아름다움(beauty)은 여기서 끝나지 않는다. 상태(state) 속성(property)을 사용하여 사용자 인터페이스(user interface)의 동작(behavior) 또는 모습(aspect)을 선언적으로(declaratively) 변경(change)할 수 있다.

    예를 들어(for example), 길이(length)가 3자(characters) 미만(less than)인 텍스트(text)를 숨기려면 다음과 같이 if 문(statement)으로 둘러싸서(surround) 구현할 수 있다:

    if name.count >= 3 {
      Text(name)
    }

    해당 표현식(expression)은 name이 변경(changes)될 때 자동으로(automatically) 재평가(re-evaluates)된다. 이를 선언(declaring)하는 것 외에, 변경된(changed) 이벤트(event)를 구독(subscription)하거나, 수동으로(manually) 논리(logic)를 실행(execute)하는 등의 어떠한 다른 작업도 수행할 필요가 없다. 간단히 선언(declare)만 하면 SwiftUI가 처리(take care of)한다.

     

    Cleaning up

    다음 주제(topic)로 이동하기 전에 RegisterView.swift에 추가한 코드를 삭제(delete)하고, 이 섹션(section)의 시작 부분에서 주석 처리(commented out)한 코드를 복원(restore)한다.

     

    Defining the single source of truth

    이 책을 포함하여 사람들이 SwiftUI에 대해 논의(discuss)하는 모든 곳에서 이 용어(term)를 들을 수 있다. 이는 데이터는 단일(single) 엔티티(entity)에서만 소유(owned)해야 하며, 다른 모든 엔티티(entity)는 복사본(copy)이 아닌 동일한 데이터에 접근(access)해야 한다는 원칙이다.

    값(value)과 참조(reference) 유형(types)간에 유사점(similarities)을 찾는 것은 자연스러운 일이다. 값(value) 유형(type)을 전달(pass)하면 실제로 사본(copy)을 전달(pass)하므로, 변경 사항(change)은 사본(copy)의 수명(lifetime)으로 제한(limited to)된다. 원본(original)에는 영향(affect)을 주지 않는다. 마찬가지로(likewise), 원본 데이터에 대한 변경 사항(changes)은 전파(propagate)되지 않으며, 사본(copy)에 영향(affect)을 주지 않는다.

    이는 상태(state)를 변경(change)할 때 해당 변경 사항(change)이 사용자 인터페이스(user interface)에 자동으로(automatically) 적용(apply to)되기 원하기 때문에, UI 상태(state)를 처리(handle)할 때 원하는 방법이 아니다. 데이터가 참조(reference) 유형(type)이라면, 데이터를 이동(move)할 때마다 실제로는 데이터에 대한 참조(reference)를 전달(passing)한다. 데이터에 대한 모든 변경 사항(change)은 누가 실제로 변경(change)했는지에 관계없이(regardless of), 데이터에 접근(access)하는 모든 곳에서 확인할 수 있다.

    SwiftUI에서는 단일 진실 공급원(single source of truth)을 첨부된(attached) 동작(behavior)이 있는 참조(reference) 유형(type)으로 생각할 수 있다.

    이전에 ScoreView를 만들 때, numberOfAnswered라는 상태 속성(state property)을 사용했다. 답변된 질문의 수(number of answered questions)는 이 뷰(view)에서 결정(determined)되거나 변경(changed)되지 않는다. 이러한 작업은 간접적(indirectly)이더라도, 상위 뷰(parent view)인 ChallengeView에서 발생한다.

    ScoreView를 왜 사용하는지, 그리고 왜 상태(state)가 없는지 알지 못하는(unaware) 자체 독립 구성 요소(independent component)로 간주(consider)한다. 여기서는 전체 답변 수(total number of answers)에 대한 완료된 답변 수(number of completed answers)를 표시하는 데만 사용한다.

    ChallengeView.swift를 열고, showAnswers 바로 뒤에 새 상태 속성(state property)을 추가한다:

    @State var numberOfAnswered = 0

    이제 이 속성(property)을 ScoreView에 전달(pass)하기만 하면 된다고 생각할 수 있다. 실제로 그렇게해야 하지만, 그게 유일한 작업은 아니다.

    속성(property)만 전달(pass)하면 어떻게 되는지 확인(test)해 본다. ScoreView.swift에서 생성자(initializer)를 사용할 수 있도록, 강제로 numberOfAnswered의 인라인 초기화(inline initialization)를 제거(remove)한다.

    @State
    var numberOfAnswered: Int

    동시에 미리보기(preview)를 업데이트(update)하려면, 새 매개 변수(parameter)를 제공(provide)해야 한다. 구현(implementation)을 다음으로 바꾼다(replace):

    struct ScoreView_Previews: PreviewProvider {
      @State static var numberOfAnswered: Int = 0
      //새 state property를 생성한다.
      
      static var previews: some View {
        ScoreView(
          numberOfQuestions: 5, 
          numberOfAnswered: numberOfAnswered
        )
        //새 property를 ScoreView의 생성자에 전달한다.
      }
    }

    이제 추가(additional) 매개 변수(parameter)도 전달(pass)하도록, ChallengeView를 업데이트(update) 해야한다. ScoreView를 사용하는 행(line)을 다음으로 바꾼다(replace):

    ScoreView(
      numberOfQuestions: 5, 
      numberOfAnswered: numberOfAnswered
    )

    지금까지는 이것이 작동하는지 확인(test)할 수 있는 방법이 없었고, 그렇게 해서도 안 됐다. ChallengeView에는 버튼(button)과 작업 처리기(action handler)가 있다. 다음 행(line)을 추가하여, 버튼(button)의 작업(action) 섹션(section)에 속성(property)을 일시적으로(temporarily) 증가(increment)시킨다:

    self.numberOfAnswered += 1

    다음으로 ScoreView 뒤에 counter 값(value)을 보여주는 텍스트 뷰(text view)를 추가한다:

    Text("ChallengeView Counter: \(numberOfAnswered)")

    ScoreView.swift의 spacer 바로 전에도 똑같은 작업을 한다:

    Text("ScoreView Counter: \(numberOfAnswered)")

    이제 ChallengeView로 돌아가 실시간 미리보기(live preview)가 활성화(active)되어 있는지 확인(ensure)한다. 화면(screen)의 상단 절반(upper half)을 반복해서(repeatedly) 누르면, ChallengeView 카운터(counter)는 증가(increments)하지만 ScoreView의 카운터(counter)는 증가(increments)하지 않는다.

     

    @State로 표시된 속성(property)은 실제로는 값(value) 형식(type)인 State<Value> 이다. 메서드(method)에 이를 전달(pass)하면 실제로 사본(copy)을 전달한다.

    상태 속성(state property)이 데이터를 소유(owns)하고 데이터 사본(copy)을 전달하므로, 원본(original)과 사본(copy)의 수명(lives)은 다르다.

    SwiftUI 용어(terms)로, @State 속성(property)을 복사(copying)하면 다중 진실 공급원(multiple sources of truth)을 갖게 된다. 혹은 개념(concept)을 더 잘 이해(understand)하는 데 도움이 된다면, 다중 거짓 공급원(multiple sources of untruth)를 갖게 된다고 생각할 수 있다. 모든 상태 속성(state property)에는 상대적인 진실(relative truth)이 있으며, 어느 시점에서 다른 공급원(sources)의 진실(truth)과 일치(match)하지 않을 것이다.

    개념(concept)을 명확히(clarify)하기 위한 다음 예제(example)가 있다. 좋아하는 배달 피자 전화 번호(phone number)를 가족들과 공유(share)하고 싶다면, 스티커(sticker) 메모(notes)에 적어 각 가족 구성원에게 하나씩 나눠 줄 수 있다.

    이는 다중 진실 공급원(multiple sources of truth)을 만드는 것이다. 전화 번호(phone number)가 변경(changes)되면, 모든 사람이 다 알 지는 못할 것이다.

    메모(note)에 전화 번호(phone number) 대신, "전화 번호(phone number)를 냉장고(fridge)에 걸어(hanging) 뒀다."라고 적을 수 있다. 이제 냉장고(fridge)의 메모(note)는 모든 사람이 업데이트(update)할 수 있고 번호가 최신 상태(up to date)임을 확신하기 때문에 단일 진실 공급원(single source of truth)이다.

    코드로 돌아간다. 데이터를 전달(passing)하는 대신, 데이터에 대한 참조(reference)를 전달(pass)해야 한다. 바인딩(binding)은 여기서 필요한 참조(reference)이다. 따라서 ScoreView로 이동하여, 상태 속성(state property)을 대신 바인딩(binding)으로 업데이트(update)한다:

    @Binding
    var numberOfAnswered: Int

    ScoreView가 두 번째 매개 변수(parameter)에 바인딩(binding)을 예상하기 때문에 ChallengeView와 미리보기(preview) 모두 오류(errors)를 보고(report)한다. 먼저 ChallengeView를 처리(handle)한다.

    텍스트 필드(text field)에 대한 이전 예제(previous example)에서와 마찬가지로, 속성 이름(property name) 앞에 $ 연산자(operator)를 추가하여 바인딩(binding)을 얻는다. 따라서 구문(statement)을 다음으로 바꾼다(replace):

    ScoreView(
      numberOfQuestions: 5,
      numberOfAnswered: $numberOfAnswered
    )

    ScoreView의 미리보기(preview)에서 동일한 변경(change)을 반복(repeat)해야 한다. 완료(done)되면, 실시간 미리보기(live preview)에서 ChallengeView를 사용해 본다. 이제 탭(tap)하면, 두 카운터(counters)가 모두 업데이트(update)된다:

    State and Binding 2

     

    지금까지 한 작업(achieved)은 다음과 같다.

    1. 상태 변수(state variable)를 사용하여 답변된 질문 수(number of answered questions)를 추적(tracks)하는 카운터(counter)를 저장(store)했다.
    2. 동일한 기본(underlying) 데이터에 접근(access)할 수 있도록, ScoreView에 바인딩(binding)을 전달(passed)했다.
    3. 상태 속성(state property)이나 바인딩 속성(binding property)을 사용해 데이터를 변경(change)하면, 해당 데이터를 참조(references)하는 모두가 해당 변경 사항(change)을 사용할(available) 수 있도록 설정하였다.

    Cleaning up

    위 섹션(section)에서 이제 제거(remove)할 수 있는 임시(temporary) 코드를 추가했다.

    ChallengeView에서:

    1. 곧 다시 작업할 numberOfAnswered를 제거(remove)한다:

    @State var numberOfAnswered = 0

    2. 버튼(button)의 작업 처리기(action handler)에서 증가(increment) 문(statement)을 제거한다:

    self.numberOfAnswered += 1

    3. ScoreView에 대해 단일 매개 변수(parameter) 생성자(initializer)를 다시 사용한다:

    ScoreView(numberOfQuestions: 5)

    4. numberOfAnswered 값(value)을 출력(prints)하는 텍스트 컨트롤(text control)을 제거(remove)한다:

    Text("ChallengeView Counter: \(numberOfAnswered)")

     

    ScoreView에서:

    1. 바인딩(binding) 대신 numberOfAnswered를 다시 상태 속성(state property)으로 다시 만든다:

    @State var numberOfAnswered: Int = 0

    2. numberOfAnswered를 출력(prints)하는 다른 텍스트 컨트롤(text control)을 제거(remove)한다:

    Text("ScoreView Counter: \(numberOfAnswered)")

    3. 미리보기(preview) 구조체(struct)에서 ScoreView의 생성자(initializer)에 전달(passed)된 두 번째 매개 변수(parameter)를 제거(remove)한다:

    ScoreView(numberOfQuestions: 5)

    그리고 이게 전부이다(that’s all). 이 임시(temporary) 코드를 사용하여 @State@Binding의 차이점과 단일 진실 공급원(single source of truth) 개념(concept)과 어떻게 관련이 있는지 더 잘 이해하게 됐다.

     

    Key points

    이것은 진지(intense)하고 이론적인(theoretical) 장(chapter)이었다. 그러나 결론적으로 어떻게 작동하는지 이해하면 개념(concepts)은 간단하다. 그렇기 때문에 차이점(differences)을 확인하고 더 깊이(deeper) 이해하기 위해 다양한 접근 방식(approaches)을 시도했다. 여전히 복잡(complicated)해 보이더라도 걱정하지 마라. 연습(practice)을 하면 커피를 마시는 것만큼 쉬워질 것이다.

    배운 내용을 요약(summarize)하면 다음과 같다:

    • @State를 사용하여 선언한(declare) 뷰(view)에서 소유한(owned) 데이터로 속성(property)을 만든다. 속성(property) 값(value)이 변경(changes)되면, 이 속성(property)을 사용하는 UI가 자동으로(automatically) 다시 렌더링(re-renders)된다.
    • @Binding을 사용하면 상태 속성(state property)과 유사한(similar to) 속성(property)을 만들지만, 상태 속성(state property) 또는 상위 뷰(ancestor view)의 관찰 가능한 객체(observable object) 등 다른 곳(elsewhere)에서 저장(stored)되고 소유(owned)하고 있는 데이터를 사용한다.

    이것은 상태(state)와 데이터 흐름(data flow)과 관련된 내용(concerns)의 ​​절반에 불과하다. 다음 장(chapter)에서는 사용자 정의(own) 참조 유형(reference types)을 관찰할 수(observable) 있게 하는 방법과 환경(environment)을 사용하는 방법에 대해 알아 본다.

Designed by Tistory.