-
Chapter 9: State & Data Flow — Part IIRaywenderlich/SwiftUI by Tutorials 2021. 6. 18. 16:53
Version
Swift 5.3, iOS 14, Xcode 12
이전 장(chapter)에서 분명(transparent)하고 사용하기 쉬운
@State
와@Binding
을 사용하는 방법을 배웠다.이 장(chapter)에서는 사용자 정의 유형(your own types)을 효율적인 반응적(efficiently reactive) 또는 반응적인 효율적(reactively efficient)으로 만들 수있는 다른 도구(tools)에 대해 알아 본다.
들어가기 전(before diving into it)에, 프로젝트(project)에 대해 언급할 것이 있다. 이 장(chapter)과 함께 제공되는 시작 프로젝트(starter project)를 사용할 수 있지만, 이는 이전 장(previous chapter)의 최종 프로젝트(final project)와 정확히 같기(exact copy)때문에 원하는 경우 작업한 내용을 재사용(reuse)할 수도 있다. 변경(change)이 필요하지 않다.
The art of observation
따라서 바인딩(binding)을 사용하여 진실 공급원(source of truth)이 소유(owns)한 데이터를 전달하고, 데이터 자체를 추가로(additionally) 소유(own)하기 위해 상태(state)를 사용한다. 하지만, 이것이 멋진 (awesome)사용자 인터페이스(user interface)를 만들기 위해 필요한 모든 것은 아니다.
여러 속성(properties)으로 구성된 모델(model)이 있고, 이를 상태 변수(state variable)로 사용하려는 경우를 고려(consider)한다. 모델(model)을 구조체(struct)와 같은 값 유형(value type)으로 구현(implement)하면, 제대로(properly) 작동하지만 효율적(efficient)이지 않다.
실제로 구조체(struct) 인스턴스(instance)가 있고 그 속성(properties) 중 하나를 수정(modify)하면, 전체(entire) 인스턴스(instance)를 속성(property)이 업데이트(updated)된 복사본(copy)으로 대체(replace)한다. 즉(in other words), 전체 인스턴스(entire instance)가 변경(mutates)된다.
모델(model)의 속성(property)을 변경(change)하면, 해당 속성(property)을 참조(references)하는 UI만 새로 고침(refresh)되어야 한다. 실제로는(iun reality) 전체(whole) 구조체(struct) 인스턴스(instance)를 수정(modified)했으므로, 업데이트(update)는 구조체(struct)를 참조(reference)하는 모든 곳에서 새로 고침(refresh)을 실행(trigger)한다.
사용 사례(use case)에 따라 이는 영향(impact)이 적을 수도 있지만, 성능(performance)에 상당한(considerably) 영향을 미칠 수도(affect) 있다.
그렇다고 구조체(structs)를 사용해서는 안된다는 의미가 아니다. 단지, 같은 모델(model)에 관련없는(unrelated) 속성(properties)을 넣지 않아야(avoid) 한다는 의미이다. 이렇게 하면 속성(property) 값(value)을 업데이트(updating) 할 때, 해당 속성(property)을 사용하지 않는 UI가 업데이트(update)되는 경우를 방지(prevents)할 수 있다.
참조 유형(reference type, 클래스)으로 모델(model)을 구현(implement)하면, 실제로 이와 같은 일이 일어나지 않는다. 속성(property)이 참조 유형(reference type)이면, 새 참조(reference)를 할당(assign)하는 경우에만 변경(mutates)된다. 실제 인스턴스(instance)를 변경(change)해도 속성(property) 자체는 변경(change)되지 않으므로, UI 새로 고침(refresh)이 실행(trigger)되지 않는다.
Making an Object Obsevable
좋은 소식은 이를 해결(rescue)할 네 가지 새로운 유형(types)이 있다는 것이다. 위에 설명한(expressed) 사항을 고려할 때, 사용자 정의(custom) 모델(model)은 다음과 같아야 한다:
- 참조 유형(reference type)이어야 한다.
- UI 업데이트(updates)를 실행(trigger)해야 하거나 실행(trigger)하지 않는 속성(properties)을 지정(specify)할 수 있다.
다음과 같은 세 가지 새로운 유형(types)이 필요하다:
- 관찰 가능(observable)한 클래스(class)를 선언(declare)한다. 이렇게 하면 상태 속성(state properties)과 유사하게(similarly) 사용할(enables) 수 있다.
- 관찰 가능(observable)한 클래스 속성(class property)을 선언(declare)한다.
- 관찰 가능한 클래스 유형 인스턴스인 속성(property that’s an instance of an observable class type)을 선언(declare)한다. 이렇게 하면 관찰 가능한 클래스(observable class)를 뷰(view)에서 관찰된 속성(observed property)으로 사용할 수 있다.
관찰 가능한 객체(observable objects)로 사용할 수 있는 두 개의 클래스(classes)인
UserManager
와ChallengesViewModel
이 이미 있다.클래스(class)를 관찰 가능(observable)하게 만들려면,
ObservableObject
를 준수(conform)해야 한다. 클래스(class)는 publisher가 된다. 프로토콜(protocol)은 자동으로 생성(synthesizes)되는 하나의objectWillChange
속성(property)만 정의(defines)한다. 즉, 구현(implement)할 필요(required)가 없다. 컴파일러(compiler)가 대신 수행한다.Profile/UserManager.swift를 열고 클래스(class) 선언(declaration)을 확인한다:
final class UserManager: ObservableObject { //class는 ObservableObject를 준수하므로 publisher가 된다. @Published var profile: Profile = Profile() @Published var settings: Settings = Settings() @Published var isRegistered: Bool ... //@Published attribute로 선언된 세 개의 속성을 정의한다. //이러한 속성은 view의 state property처럼 작동한다. }
상태 속성(state properties)에 대해 수행한 것과 동일한 고려 사항(considerations)이 게시된 속성(published properties)에도 적용(apply to)된다:
- 기본(basic) 데이터 유형(types) 또는 구조체(structures)와 같은 값 유형(value types)이어야 한다.
- 구조체(structures)를 사용하는 경우, 포함(contain)된 속성(properties) 수를 최소한(minimum)으로 제한(limit)하여 모든 경우에 대한 하나의 구조체(one-struct-for-all scenarios)를 피하는(avoiding) 것이 좋다.
관찰 가능한 클래스(observable class)는 사용이 매우 간단하다. 상태 변수(state variable)를 사용하는 것과 같다.
Observing an Object
앞서 언급했듯이(as mentioned earlier) 프로젝트(project)에는 Practice/ChallengesViewModel.swift에 또 다른 관찰 가능한 클래스(observable class)가 있다. 그 목적(purpose)은 일본어 단어(word), 영어 번역(translation), 잠재적 답변(answers) 목록(list)으로 구성된(consist of) 문제(challenges)를 정의(define)하고 제공(serve)하는 것이다. 정답(answer)은 하나뿐이다.
현재 활성화(currently-active)된 문제(challenge)를 포함하는 속성(property)이 있다:
@Published var currentChallenge: ChallengeTest?
보다시피 이는 상태 속성(state property)과 같은 게시된 속성(published property)이다.
- 단일 진실 공급원(single source of truth)을 정의(defines)한다.
- 바인딩(binding)이 있다.
- 업데이트(updated) 될 때마다, 이를 참조(references)하는 UI 새로 고침(refresh)을 실행(triggers)한다.
이 속성(property)을 사용할 가장 자연스러운 장소는 challenge view이다. 나중에는 이것이 완전히(entirely) 사실(true)이 아니라는 것을 알게(realize) 될 것이다. 사실, 뷰(view)에는 이미 challenge 속성(property)이 포함(contains)되어 있다. 하지만 지금은 그대로 진행한다.
ChallengeView.swift를 열고, 기존의
challengeTest
뒤에 이 속성(property)을 추가한다:@ObservedObject var challengesViewModel = ChallengesViewModel()
다음으로, 두 개의
challengeTest
항목을challengeViewModel.currentChallenge!
로 바꾼다(replace). 첫 번째는QuestionView
를 사용하는 곳이다:QuestionView(question: challengesViewModel.currentChallenge!.challenge.question)
두 번째는 몇 줄(lines) 아래의
ChoicesView
를 사용하는 곳이다:ChoicesView( challengeTest: challengesViewModel.currentChallenge!)
앱(app)을 실행(run)하고 challenge view로 이동(navigate)한다. 차이(difference)를 알아채지(notice) 못할 것이다. 뷰(view)의 위쪽 절반(upper half)을 탭하면(tapping), 이전과 같이 choices view가 전환(toggle)된다.
그러나 게시된 속성(published property)에 일시적으로(temporarily) 변경 사항(change)을 적용(apply)할 수 있다. 버튼(button)의 작업 처리기(action handler)에서 뷰 모델(view model)의 메서드(method)를 호출하여 다음 문제(challenge)로 진행(advance)한다:
Button(action: { self.showAnswers.toggle() self.challengesViewModel.generateRandomChallenge() //generateRandomChallenge()는 새로운 무작위 challenge를 선택하여 currentChallenge에 넣는다. }) { QuestionView(question: challengesViewModel.currentChallenge!.challenge.question ) .frame(height: 300) }
generateRandomChallenge()
는 새로운 무작위 문제(challenge)을 선택(picks)하고currentChallenge
에 넣는다(puts). 속성(property)이 변경(changes)되었으므로 UI 새로 고침(refresh)을 실행(triggers)한다. 이제 앱(app)을 실행(run)하고, 뷰(view)의 위쪽 절반(upper half)을 탭(tap)하면 새로운 문제(challenge)로 전환(switch)된다.Current selection ChallengesViewModel
인스턴스(instance)인challengeViewModel
속성(property)에@ObservedObject
속성(attribute)을 추가하여 이를 얻었다(obtained). 앞서 언급했듯이(as mentioned earlier), 이 클래스(class)는 코드에서 참조(reference in)하는currentChallenge
라는@Published
속성(property)을 정의(defines)한다(이를QuestionView
및ChoicesView
에 전달한다).버튼 탭 처리기(button tap handler)에서
generateRandomChallenge()
를 호출(call)하면 해당 속성(property)이 변경(mutated)되어 이를 참조(referenced)하고 있는 곳(places)이 다시 그려지게(redraw) 되므로,QuestionView
와ChoicesView
가 모두 새로 고쳐져(refreshed) 새로운 문제(challenge)가 표시(displayed)된다.그러나
ChallengeView
는ChallengeViewModel
이 위치할(reside) 올바른 장소가 아니므로, 더 적절한 장소로 옮기는 것이 좋다. 위에서 변경한 사항(changes)을 다음과 같이 실행 취소(undo)한다:challengeViewModel
을 삭제(deleting)한다.- 두 개의
challengeViewModel.currentChallenge!
를challengeTest
로 대체(replacing)한다. - 버튼(button)의 작업 처리기(action handler)에서
self.challengesViewModel.generateRandomChallenge()
를 삭제(deleting)한다.
또는(alternately), 모든 변경 사항(changes)이 취소(undo)될 때까지 Command-Z를 반복해서(repeatedly) 누른다.
Note: 한 가지 접근 방식(approach)과 다른 접근 방식의 차이점(differences)을 확인하기 위해 이렇게 했다. 돌아 가게해서 미안하지만 이렇게 하면 다음 설명이 명확(clarifies)해진다.
그렇다면
challengeViewModel
은 어디로 가야하는지 생각해 본다.PracticeView
는ChallengeView
를 참조(references)한다. 둘 다 바인딩(bindings)된 두 개의 속성(properties)을 이미 포함(contains)하고 있으므로, 다른 곳(elsewhere)에 저장된(stored) 데이터를 참조(reference)한다.이 뷰(view)의 목적(purpose)은 사용자가 모든 문제를 완료(completed)하지 않은 경우, 문제(challenge)를 표시(display)하는 것이다. 그렇지 않으면(otherwise), congratulations view가 표시된다.
WelcomeView
는 차례로PracticeView
를 참조(references)한다. 이미ChallengesViewModel
의 인스턴스(instance)인challengeViewModel
속성(property)이 포함(contains)되어 있음을 알 수 있다. 또한 게시된 속성(published properties)이 상태 속성(state properties)처럼 동작할 수 있도록@ObservedObject
으로 선언(declared)된다.Sharing in the environment
이 장(chapter)에서 이미 앱(app)을 사용해 보았으므로, 게임 진행률(progress)이 업데이트 되지 않는 것을 알아챘을(noticed) 것이다.
문제(challenge)에서 정답(correct answer)을 선택하면, 확인 알림(confirmation alert)을 받는 것 외에는 별다른 일이 발생하지 않는다. 앱(app)은 다음 문제(challenge)로 진행되어야(advance) 한다. 다음에 이를 수정(fix)하게 될 것이다.
ChallengesViewModel.swift를 열면 정답(correct)과 오답(incorrect)을 기록(log)하는 두 가지 메서드(methods)를 확인할 수 있다:
func saveCorrectAnswer(for challenge: Challenge) { correctAnswers.append(challenge) } func saveWrongAnswer(for challenge: Challenge) { wrongAnswers.append(challenge) }
정답(correct answer)을 저장(saving)한 후 다음 문제(challenge)로 넘어간다. 이 목적(goal)에 적합한 다른 메서드(method)인
generateRandomChallenge()
가 있다.이제 이러한 메서드들(methods)을 사용해야 한다. 사용자가 옵션(options) 중 하나를 선택하는 뷰(view)인
ChoicesView
는 이미 이를 사용하고 있다.뷰(view) 구현(implementation)을 살펴보면 다음을 알 수 있다:
@ObservedObject
로 선언(declared)된challengeViewModel
속성(property)이 있다.- Alert dismiss 버튼(button)의 처리기(handler)에서
generateRandomChallenge()
를 호출(invoke)한다. checkAnswer(at:)
에서saveCorrectAnswer()
와saveWrongAnswer()
를 모두 호출(invoke)한다.
하지만 앱(app)이 예상대로(expected) 작동하지 않는다. 하나의 문제(challenge)를 완료(completed)하면, 다음으로 진행(advance)되지 않는다.
이유는 간단하다. 여기서
ChallengesViewModel
인스턴스(instance)를 만들고,WelcomeView
에서도 만들기 때문이다. 따라서 두 개의 서로 다른 인스턴스(instances)이고, 하나의 변경 사항(change)이 다른 인스턴스로 전파(propagate)되지 않는다.한 가지 가능한 해결책(solution)은 생성자(initializers)를 사용해
WelcomeView
에서ChoicesView
로challengeViewModel
을 전달(pass)하는 것이지만, 이는 우아(elegant)하지 않다. 다행히(fortunately), 더 좋은 방법이 있다.이것은 싱글톤(singleton)을 사용할 수 있는 전형적인 경우(typical case)이다. 그러나 내밀하게 말하자면(confidentially speaking), 싱글 톤(singleton) 패턴(pattern)이 사용하기 가장 좋은 패턴(pattern)은 아니다. 의존성 주입(dependency injection)과 같이, 다른 패턴(patterns)에서는 쉽게 방지할(avoid) 수 있는 불필요한(unnecessary) 종속성(dependencies)이 만들어진다.
Environment and Objects
SwiftUI는 이를 해결(achieve)하는 방법을 제공(provides)한다. 의존성 주입(dependency injection)이 아니라, 가방같은 것에 객체(object)를 넣고 필요할 때마다 꺼내는(retrieve) 방식이다. 이 가방(bag)을 환경(environment)이라고 하고, 객체(object)를 환경 객체(environment object)라고 한다.
이 패턴(pattern)은 가장 널리(popular) 사용되는 SwiftUI의 두 가지 방식인 수정자(modifier)와 속성(attribute)을 사용한다.
environmentObject(_:)
를 사용하여 환경(environment)에 객체(object)를 주입(inject)한다.@EnvironmentObject
를 사용하여 환경(environment)에서 객체(object, 실제로는 객체에 대한 참조)를 가져와(pull) 속성(property)에 저장(store)한다.
객체(object)를 환경(environment)에 주입(inject)하면 뷰(view)와 해당 뷰(view)의 하위 뷰(subviews)에서 접근(accessible)할 수 있지만, 뷰의 상위 뷰(parent)와 그 이상(above)에서는 접근(accessible)할 수 없다.
확실히 하기 위해(just to be sure), 지금은 루트 뷰(root view)에 주입(inject)한다. KuchiApp.swift을 열고
body
에서StarterView
를 인스턴스화(instantiates)하는 위치(locate)를 찾는다.UserManager
의 인스턴스(instance)인 다른 객체(object)가 환경(environment)에 주입(injected)된 것을 확인할 수 있다.수정자(modifier)를 추가하여
ChallengesViewModel
인스턴스(instance)를 주입(inject)한다:var body: some Scene { WindowGroup { StarterView() .environmentObject(userManager) //ChallengesViewModel 인스턴스를 생성하고 environment에 주입한다. //StarterView의 hierarchy에 있는 모든 view가 해당 인스턴스에 접근할 수 있다. .environmentObject(ChallengesViewModel()) } }
Note: 이름이 지정되지 않은(unnamed) 인스턴스(instance)를 환경(environment)에 주입(injecting)했다.
@EnvironmentObject
를 사용하여 가져올 때는 인스턴스(instance) 유형(type)만 지정(specify)하면 된다. 이는 유형(type)당 하나의 인스턴스(instance)만 환경(environment)에 주입(inject)할 수 있다는 것을 의미하므로 기억해야할 중요 사항(important)이다. 다른 인스턴스(instance)를 주입(inject)하면, 첫 번째 인스턴스를 대체(replace)한다.이제
ChallengesViewModel
을 사용하는 모든 곳에서 이를 변경(change)해야 한다.WelcomeView
에서 아래 속성(property)을:@ObservedObject var challengesViewModel = ChallengesViewModel()
다음으로 바꾼다:
@EnvironmentObject var challengesViewModel: ChallengesViewModel
@EnvironmentObject
속성(attribute)을 사용하여, 이 속성(property)이 뷰(view)의 환경(environment)에서 가져온ChallengesViewModel
인스턴스(instance)로 초기화(initialized)되어야 함을 지정한다.- 속성(property)이 기존 인스턴스(existing instance)로 초기화(initialized)되므로 더 이상 인스턴스화(instantiate)할 필요가 없다.
ChoicesView
에서 동일한 속성(property) 교체(replacement)를 수행한다.이제 앱(app)을 빌드(build)하고 실행(run)하여 확인(test)한다. 정답(correct answer)을 제공(provide)하면, 다음 문제(challenge)로 진행된다(advances).
Challenge sequence 그러나 두 가지 문제(issues)가 있다:
- 답변된 문제(answered challenges)의 카운터(counter)는 업데이트(update)되지 않는다.
- 5개의 정답(correct answers) 후 congratulations view가 표시되지만, 벗어날 수 없다(get away from it). Play Again 버튼(button)은 아무 작업도 수행하지 않는다.
Congrats view Environment and duplicates (to avoid)
앞서 언급한 두 가지 문제(issues)를 해결(get rid of)해야 한다.
후자(latter, getting away from the congratulations view)는 간단히 해결(fix) 가능하다. Practice/CongratulationsView.swift를 열면, 파일 하단에 버튼(button)이 있다. 그것의 작업 처리기(action handler)는
self.challengesViewModel.restart()
를 호출(calls) 하는데, 이는 congratulations view를 벗어나 새로운 문제(challenge) 세션(session)을 다시 시작하는 올바른 방법으로 보인다.challengesViewModel
을 보면, 환경(environment)에서 인스턴스화된 객체(object)가 관찰된 객체(observed object)로 인라인(inline) 인스턴스화(instantiated)되는 것을 알 수 있다. 다른 사례에서와 마찬가지로 다음과 같이 교체(replace)한다:@EnvironmentObject var challengesViewModel: ChallengesViewModel
이제 빌드(build)하고 실행(run)하여 챌린지(challenge) 세션(session)의 끝으로 이동한다. congratulations view가 표시(displays)되면 이제 버튼을 탭(tap)해 세션(session)을 다시 시작(restart)할 수 있다.
다른 문제(issue) 해결을 위해 Practice/ScoreView.swift를 연다.
numberOfAnswered
는 상태 속성(state property)이지만, 제대로(properly) 작동하려면 상위 뷰(superview)에서 전달(passed)되어야 한다.환경(environment)에서 challenge view model을 가져 올 수 있지만 불필요한(unnecessary) 종속성(dependency)이 추가(add)된다. 이것은 한 쌍(pair)의 숫자(numbers)를 표시(display)하는 단순한 뷰(view)이므로, 가능한 한 단순하게(dumb) 만드는 것이 좋다.
상위 뷰(parent)가 매개 변수(parameter)를 전달(pass)하도록 하려면, 이를 바인딩(binding)으로 변경(change)해야 한다.
numberOfAnswered
에서@State
를@Binding
으로 바꾸고(replace), 초기화(initialization)를 제거(remove)하면 다음과 같다:@Binding var numberOfAnswered: Int
이제 속성(property)이 바인딩(binding)이므로, 생성자(initializer)에서 제공(provide)해야 한다. 사실, 이제 미리보기(preview)에서 인수(argument)가 누락되어 오류(error)가 발생한다. 바인딩(binding)으로 전달(passed)하여 추가한다:
ScoreView( numberOfQuestions: 5, numberOfAnswered: $numberOfAnswered )
마찬가지로(likewise),
ScoreView
를 사용하는ChallengeView
도 유사한(similar) 오류(error)를 발생시키지만, 전달(pass)할 상태(state) 또는 바인딩 속성(binding property)이 없다. 따라서 이전과 같이ChallengeView
에numberOfAnswered
를 추가한다:@Binding var numberOfAnswered: Int
그리고
ScoreView
에 전달(pass)한다:ScoreView( numberOfQuestions: 5, numberOfAnswered: $numberOfAnswered )
다시 한 번 미리보기(preview)는 이러한 변경 사항(changes)에 오류를 발생시키므로, 컴파일(compile)하려면 코드를 추가(add)해야 한다.
numberOfAnswered
바인딩(binding)을 전달(pass)한다. 이에 대한 상태 속성(state property)을 추가할 수 있다:@State static var numberOfAnswered: Int = 0
다음으로 예상 매개 변수(expected parameter)를 전달(passing)하여
ChallengeView
를 사용하는 행(line)을 업데이트(update)한다:return ChallengeView( challengeTest: challengeTest, numberOfAnswered: $numberOfAnswered )
이제 거의 끝났다.
PracticeView
에서ChallengeView
를 사용하므로, 컴파일 오류(compilation error)가 이 뷰(view)에 영향을 준다(affects). 이 익숙한(familiar) 단계(steps)를 마지막으로 반복(repeat)한다.PracticeView
에 바인딩 속성(binding property)을 추가한다:@Binding var numberOfAnswered: Int
바인딩(binding)을
ChallengeView
생성자(initializer)에 전달(pass)한다:ChallengeView( challengeTest: challengeTest!, numberOfAnswered: $numberOfAnswered )
PracticeView_Previews
에 상태 속성(state property)을 추가한다:@State static var numberOfAnswered: Int = 0
새 속성(property)을
ChallengeView
에 바인딩(binding)으로 전달(pass)한다:return PracticeView( challengeTest: .constant(challengeTest), userName: .constant("Johnny Swift"), numberOfAnswered: $numberOfAnswered )
WelcomeView
는 이 재귀(recursive) 여행(journey)의 마지막 단계(last step)이다. 여기에는 이미 환경(environment)에서 직접(straight) 가져온 challenges view model 이 있다.ScoreView
로 전달(passed down)해야 하는 속성(property)을 추가하기만 하면 된다.ChallengesViewModel
에서 다음 computed property을 추가한다:var numberOfAnswered: Int { return correctAnswers.count }
보다시피 이는 computed property이며 읽기 전용(read-only)이다.
WelcomeView
로 돌아가서이 새 속성(property)을PracticeView
에 대한 바인딩(binding)으로 전달한다:PracticeView( challengeTest: $challengesViewModel.currentChallenge, userName: $userManager.profile.name, // Add this numberOfAnswered: $challengesViewModel.numberOfAnswered )
컴파일러(compiler)는 읽기 전용(read-only) 속성(property)을 할당(assigned)할 수 없다고 알려(inform)준다.
Binding
은 변경 불가능한 값(immutable value)에서 바인딩(binding)을 생성하는constant()
라는 정적 메서드(static method)가 있다. 이것이 해결책(solution)인 것처럼 보인다. 해당 행(line)을 다음으로 바꾼다(replace):numberOfAnswered: .constant(challengesViewModel.numberOfAnswered)
이제 정상적으로 작동한다.
Score Working Object Ownership
이전 섹션(sections)에서 뷰(view)가 관찰 가능한 객체(observable object)를 얻을(obtain) 수 있는 세 가지 방법이 있음을 확인했다:
- 생성자에서 받음(By receiving in the initializer)
- 환경에서 추출(By extracting from the environment)
- 인스턴스 자체를 생성(By creating an instance itself)
처음 두 경우(cases)에서 객체(object)는 상위 뷰(parent view) 또는 앱(app, 이 경우에는
KuchiApp
), 종속성 컨테이너(dependency container) 또는 환경(environment)일 수 있는 다른 엔터티(entity)가 소유(owned)한다.후자의 경우(latter case) 인스턴스(instance)는 뷰(view)가 소유(owned)하지만, 뷰(view)는 값 유형(value type)이고 값 유형(value type)이 실제로 변경(mutate)되지 않는다는 점을 잊지 말아야 한다. 변경(mutation)을 포함(incorporating)하는 새 인스턴스(instance)가 생성된다. 직접적인 결과(direct consequence)로 뷰(view)에 참조 유형(reference type)의 소유권(ownership)이 있는 경우, 뷰(view)가 변경(mutates)되면 참조된 객체(referenced object)가 재활용(recycled)되고 새 인스턴스(instance)가 생성될 가능성이 있다.
아마 눈치채지(noticed) 못했을 것이지만, Kuchi에서 이러한 현상이 발생(happens)한다. 인스턴스(instance)가 실제로 뷰(view)에서 생성되지 않고, 생성자(initializer)로 전달(passed to)되기 때문에 숨겨(hidden)진다.
실행(run in)했던 시뮬레이터(simulator) 또는 기기(device)에서 Kuchi 앱(app)을 제거(uninstall)한다. registration view를 표시(show)하려 하지만 현재(currently)는 로그 아웃(logout) 또는 이를 "삭제(forget me)"하는 기능(feature)이 없다.
이제 KeyboardFollower.swift를 열고, 생성자(initializer) 맨 아래에 다음
print
문(statement)을 추가(add)한다:print("New KeyboardFollower instance created")
그러면 콘솔(console)에 메시지(message)가 출력(print)되어 새 인스턴스(instance)가 생성되는 시점을 알 수 있다. 이제 Xcode에서 앱(app)을 실행(run)하고 콘솔(console)이 보이는지(visible) 확인한다(View => Debug Area 메뉴(menu)에서 Activate Console를 선택(choose)하거나 ⇧ + ⌘ + C를 누른다).
RegisterView
가 표시되는 즉시(as soon as),KeyboardFollower
의 새로운 인스턴스(instance)가 생성되었음을 콘솔(console)에서 확인(confirmation)할 수 있다.Keyboard follower 이제 입력(typing)을 시작하면, 입력(type)한 각 문자(character)에 대해 콘솔(console)이 메시지(message)를 출력(prints)하는 것을 알 수 있다. 즉, 키(key)를 누를(press) 때마다 새 인스턴스(instance)가 생성된다. "Remember me"를 켜고(turn on) 끄는 경우에도 마찬가지이다.
Keyboard other followers 왜 이런 일이 일어나는지 생각해 본다. 이유는
RegisterView
가 소유(owned b)하고 있는KeyboardFollower
인스턴스(instance) 이외의 다른 것일 수가 없다.KeyboardFollower
의 속성(property) 인스턴스(instance)가 있고, 인라인(inline, 또는 생성자(constructor))에서 인스턴스화(instantiated)될 것으로 예상(expect)할 수 있다.이를 증명(prove)하기 위해
RegisterView
를 열고 확인(check)한다. 그러나 예상한 것과 다르다. 이러한(such) 속성(property)이 있지만 생성자(initializer)에 전달(passed)된 인스턴스(instance)로 초기화(initialized)된다. 즉, 소유자(owner)가RegisterView
가 아니다:@ObservedObject var keyboardHandler: KeyboardFollower init(keyboardHandler: KeyboardFollower) { self.keyboardHandler = keyboardHandler }
이 수수께끼(mystery)를 해결(solve)하려면 이 생성자(initializer)가 호출(invoked)되는 코드를 살펴 봐야한다. StarterView.swift를 열면 다음을 확인할 수 있다:
#if os(iOS) RegisterView(keyboardHandler: KeyboardFollower()) #endif
KeyboardFollower
인스턴스(instance)가 생성되고 즉시RegisterView
의 생성자(initializer)로 전달(passed to)된다는 것은 매우 분명하며, 다른 곳에서는 참조(referenced)되지 않는다. 결과적으로(consequently),RegisterView
가 재 인스턴스화(reinstantiated)되면KeyboardFollower
도 마찬가지이다.StarterView
를KeyboardFollower
인스턴스(instance)의 소유자(owner)로 만들어 이 버그를 쉽게 수정(fix)할 수 있다. 새 속성(property)을 추가(add)한다:let keyboardFollower = KeyboardFollower()
다음으로(next),
RegisterView
의 생성자(initializer)에 전달(pass)한다:#if os(iOS) RegisterView(keyboardHandler: keyboardFollower) #endif
앱(app)을 실행(run)하면
KeyboardFollower
가 한 번만 인스턴스화(instantiated)되는 것을 볼 수 있다.Keyboard follower single instance 이제 이 주제(topic)가 끝났다고 생각할 수 있다. 이 책이 SwiftUI 1.0에 관한 것이었다면 그랬을 것이다. 그러나 SwiftUI 2.0에서는 뷰(view)가 관찰 가능한 객체(observable object)의 소유자(owner)인 이러한 유형(type)의 문제(issues)를 해결(solve)하는 새로운 방법이 있다.
이는 실제로 필요하지 않는 한, 생성자(initializer)를 사용해 관찰 가능한 객체(observable object)를 전달(passing)하는 것이 우아(elegant)하지 않다는 사실에서 비롯된다. 뷰(view)에 관찰 가능한 객체(observable object)의 소유권(ownership)이 필요(requires)한 경우, 해당 뷰(view)를 사용하는 모든 곳에서 인스턴스(instance)를 만들고 참조(reference)를 유지(keep)한 다음 생성자(initializer)에 전달해야 한다.
새로운 방법은
@StateObject
라고 하며, 참조 유형(reference types)에 대한@State
라고 생각할 수 있다. SwiftUI는 뷰(view)가 변경(mutated)될 때 모든 상태 객체 속성(state object properties)이 유지(retained)되는지 확인한다. 이는 변경할 수 있는 값 유형(mutating value type)에 바인딩(bound)된 정적 속성(static property)을 갖는 것과 같다. 변경(mutation)은 새로운 인스턴스(instance)를 의미하기 때문에 SwiftUI는 변경된(mutated) 값 유형(value type) 인스턴스(instance)로 상태 객체(state objects)의 인스턴스(instances)를 전송(transferring)한다.StarterView.swift에서 이전에 추가한 속성(property)을 제거(remove)한다:
let keyboardFollower = KeyboardFollower()
다음으로,
RegisterView
에 매개 변수 없는(parameterless) 생성자(initializer)를 사용한다:#if os(iOS) RegisterView() #endif
나타나는 컴파일 오류(compilation error)는 무시(Ignore)한다.
다음으로
RegisterView
를 열고keyboardHandler
를 교체(replace)한다:@ObservedObject var keyboardHandler: KeyboardFollower
이를 사용하는 새로운
@StateObject
속성(attribute)을 사용한다:@StateObject var keyboardHandler = KeyboardFollower()
마지막으로 생성자(initializer)는 더 이상 필요하지 않으므로 제거(get rid of)할 수 있다.
다음을 제거(remove)한다:
init(keyboardHandler: KeyboardFollower) { self.keyboardHandler = keyboardHandler }
이제 앱(app)을 실행(run)하면
KeyboardFollower
가 여전히 한 번만 인스턴스화(instantiated)되어@StateObject
가 예상(expected)대로 작동하고 있음을 알 수 있다.State object @StateObject
는 완전히 새로운 도구(brand new tool)이며, 다른 도구(tools)를 대체(replacement)하기 위한 것이 아니다. 각 문제(problem)에 적절한 도구(proper tool)를 사용해야 한다:- 뷰(view)가 관찰 가능한 객체(observable object)를 소유(own)하기를 원할 때, 개념적으로(conceptually) 그 객체(object)에 속하기(belongs to) 때문에
@StateObject
를 사용한다. - 관찰 가능한 객체(observable object)가 다른 곳(elsewhere)에서 소유(owned)되는 경우,
@ObservedObject
또는@EnvironmentObject
를 사용한다. 하나 또는 다른 것을 선택하는 것은 각 특정(specific) 사례(case)에 따라(depends from) 다르다.
계속 진행하기 전에(before moving on), 앞서
KeyboardFollower
의 생성자(initializer)에 추가한print
문(statement)을 안전하게(safely) 삭제(delete)할 수 있다.Understanding environment properties
SwiftUI는 환경(environment)을 작동시키는 흥미롭고(interesting) 유용한(useful) 또 다른 방법을 제공(provides)한다. 이 장(chapter)의 앞부분에서 뷰(view) 계층 구조(hierarchy)를 통해 어떤 뷰(view)에서든 가져올 수 있는 환경 객체(environmental objects)를 주입(inject)했다.
SwiftUI는 자동으로(automatically) 동일한 환경(environment)을 시스템 관리(system-managed) 환경 값(environment values)으로 채운다(populates). 이 목록(populates)은 꽤 길며 apple.co/2yJO5C1에서 확인할 수 있다.
예를 들어(for example), 사용중인 색 구성표(color scheme, dark 혹은 light)를 지정(specifies)하는 속성(property)을 찾을 수 있다. 이것은 단지 정보를 제공(informative)하는 것이 아니라 반응적(reactive)이기 때문에, 속성(property) 값(value)이 변경(changes)되면 속성을 사용하는 모든 위치(wherever)에서 UI 업데이트(update)를 실행(triggers)한다.
Kuchi에서는 challenge view의 문제(issue)를 해결(fix)하려고 한다. 기기(device)가 가로 모드(landscape mode)이면 제대로 표현되지 않는다:
Challenge view in landscape 보기 좋게 하려면 기기(device) 방향(orientation)이 변경(changes)되는 시기를 감지(detect)하고, 그에 따라(accordingly) 반응(react)해야 한다. 하지만 안타깝게도(unfortunately) 그러한 속성(property)은 없다. 적어도(at least) 명시적인(explicit) 속성은 없다.
실제로(in fact),
enum
인verticalSizeClass
를 사용할 수 있다. 기기(device)의 세로 크기 등급(vertical size class)과 방향(orientation)이.compact
혹은.regular
인지를 나타낸다.속성(property) 값(value)을 읽고 변경 사항(changes)을 구독(subscribe)하려면, 속성 키 경로(property key path)를 전달(pass)할 수 있는 새로운
@Environment
속성(attribute)을 사용할 수 있다. 따라서 이 속성(property)을ChallengeView
에 추가한다:@Environment(\.verticalSizeClass) var verticalSizeClass
속성(property)에 임의(arbitrary)의 이름(name)을 지정할 수 있지만, 혼동(confusion)을 피하기(avoid) 위해 키 경로(key path)에 지정된(specified) 원래 이름(original name)을 사용하는 것이 좋다. 유형(type)을 지정(specify)할 필요는 없다. 기존(existing) 속성(property)인 만큼, 이미 알고 있을 것이다.
이 작업을 마치면(done), 해당 속성(property)의 값(value)에 따라(depending on) 레이아웃(layout)을 구별(differentiate)할 수 있다. 전체(entire)
body
의 구현(implementation)을 다음으로 바꾼다(replace):@ViewBuilder //body는 잠재적으로 여러 view를 반환할 수 있으므로 @ViewBuilder가 필요하다. var body: some View { if verticalSizeClass == .compact { //vertical class가 .compact 인지 확인한다. true이면 가로 모드 VStack { //landscape //VStack을 사용하여 하단에 ScoreView를 표시한다. HStack { //HStack은 QuestionView와 ChoicesView를 배치한다. Button(action: { self.showAnswers = !self.showAnswers }) { QuestionView(question: challengeTest.challenge.question) } if showAnswers { Divider() ChoicesView(challengeTest: challengeTest) } } ScoreView(numberOfQuestions: 5, numberOfAnswered: $numberOfAnswered) } } else { //portrait //이전의 구현 VStack { Button(action: { self.showAnswers = !self.showAnswers }) { QuestionView(question: challengeTest.challenge.question) .frame(height:300) } ScoreView(numberOfQuestions: 5, numberOfAnswered: $numberOfAnswered) if showAnswers { Divider() ChoicesView(challengeTest: challengeTest) .frame(height:300) .padding() } } } }
많은 변경 사항(changes)이 있는 것 같지만, 실제로는 대부분이 약간 수정(adjustments)된 중복(duplicated) 코드이다.
이제 빌드(build)하고 실행(run)하여 challenge view로 이동한다. 기기(device)의 방향(orientation)을 변경하면, 레이아웃(layout)이 자동으로(automatically) 조정(adapts)된다.
Challenge view in landscape 언급(mentioning)할 가치가 있는 한 가지는 계층 구조(hierarchy)의 모든 수준(level)에서 뷰 수정자(view modifier)인
.environment(_:_:)
를 사용하여 환경 속성(environment property)에 다른 값(value)을 수동(manually)으로 할당(assign)할 수 있다는 것이다.ChallengeView
의 상위 항목(parents) 중 하나에서 세로 크기 등급(vertical size class)를 설정하여 이를 확인(test)할 수 있다.WelcomeView
를 열고, 이 수정자(modifier)를PracticeView
에 추가(add)한다:PracticeView( challengeTest: $challengesViewModel.currentChallenge, userName: $userManager.profile.name, numberOfAnswered: .constant(challengesViewModel.numberOfAnswered) ) // Add this modifier .environment(\.verticalSizeClass, .compact)
이제 계층 구조(hierarchy)에서
PracticeView
및 모든 하위 뷰(subviews)에 대해 수직 크기 등급(vertical size class)을 compact로 강제(forcing)한다. 이는 수정할(modify) 속성(property)의 키 경로(key path)와 새 값(value)을 사용한다. 매우 직관적(intuitive)이다.이제 빌드(build)하고 실행(run)해 확인(proof)해 볼 수 있다. 기기(device)를 회전(rotate)해도
ChallengeView
는 항상 가로(landscape) 레이아웃(layout)을 표시한다.Fixed orientation 완료(done)되면 해당 수정자(modifier)를 제거(remove)한다.
Creating custom environment properties
환경 속성(environment properties)은 매우 유용(useful)하고 다재다능(versatile)하므로 직접(own) 만들 수 있다면 좋을 것이다.
사용자 정의(custom) 환경 속성(environment property)은 다음 두 단계(two-step)로 생성할 수 있다:
- 속성 키(property key)로 사용할,
EnvironmentKey
를 준수(conforming)하는 구조체(struct) 유형(type)을 만들어야 한다. EnvironmentValues
확장(extension)에 subscript 연산자(operator)를 사용하여 값(values)을 읽고(read) 쓰는(set) 새로운(newly) computed property 을 추가한다.
어떤 코드는 말보다 더 가치가 있다(some code is worth more than words).
ScoreView
에는 세션(session) 당 문제(challenges) 수를 정의(defines)하는 변경 불가능(immutable)한numberOfQuestions
속성(property)이 있다.ChallengeView
를 보면,ChallengesViewModel
에 정의(defined)된 실제 개수(actual number) 대신 상수(constant)를 전달(passes)하는 것을 알 수 있다. 이것은 사용자 정의(custom) 환경 속성(environment property)을 만들고 사용하는 방법을 보여주기(demonstrate)에 좋은 예시(candidate)이다.ChallengesViewModel
로 이동하여 파일 시작 부분에 다음 구조체(struct)를 추가한다:struct QuestionsPerSessionKey: EnvironmentKey { static var defaultValue: Int = 5 }
이 정의(defines)는:
- subscript 연산자(operator)와 함께 사용할 키(key)이다.
- 다른 곳(elsewhere)에서 명시적으로(explicitly) 초기화(initialized)되지 않은 경우, 속성에 할당(assigned)될 기본값(default value)이다.
다음으로 실제(actual) 속성(property)을 정의(define)한다. 구조체(struct) 뒤에 다음 코드를 추가한다:
extension EnvironmentValues { //EnvironmentValues의 extension을 만든다. var questionsPerSession: Int { //computed property get { self[QuestionsPerSessionKey.self] } set { self[QuestionsPerSessionKey.self] = newValue } //QuestionsPerSessionKey 유형을 사용해 읽기 및 쓰기 시 속성에 access 한다. } }
이제 질문 수(number of questions)를 정의(defines)하는 속성(property)을
ChallengesViewModel
에 추가(add)한다. 읽기 전용(read-only)으로 만들기 때문에 클래스(class) 외부(outside)에서 변경(changed)할 수 없다:private(set) var numberOfQuestions = 6
generateRandomChallenge()
에서 상수(constant)5
를 이 속성(property)의 값(value)으로 바꾸는 것이 좋다:func generateRandomChallenge() { if correctAnswers.count < numberOfQuestions { currentChallenge = getRandomChallenge() } else { currentChallenge = nil } }
이 메서드(method)는 정답 수(number of correct answers)가 질문 수(number of questions)보다 적을 경우, 새로운 질문(challenge)을 생성한다. 그렇지 않으면(otherwise),
currentChallenge
를nil
로 설정(sets)하여 세션(session)이 끝났음(over)을 나타낸다(indicating).WelcomeView
에서 이 새로운 환경 속성(environment property)을PracticeView
의 환경(environment)에 추가(add)하면,PracticeView
와 모든 하위 뷰(subviews)에서 사용할 수 있다:PracticeView( challengeTest: $challengesViewModel.currentChallenge, userName: $userManager.profile.name, numberOfAnswered: .constant(challengesViewModel.numberOfAnswered) ) // Add this .environment( \.questionsPerSession, challengesViewModel.numberOfQuestions )
이제 새 속성(property)을 사용할 준비가 되었다.
ChallengeView
로 이동하여 다음 속성(property)을 추가한다:@Environment(\.questionsPerSession) var questionsPerSession
이것은 환경(environment)에서
questionsPerSession
을 가져온다(pulls). 동일한 파일에 선언(declared)된 다른 환경 변수(environment variable)인verticalSizeClass
와 비교(compare)해 보면, 유일한 차이점(difference)은 이름(name)이다.마지막으로
ScoreView
를 참조(reference)하는 두 위치(two places)에서5
를 새로운 변수(variable)인questionsPerSession
으로 바꾼다(replace):ScoreView( numberOfQuestions: questionsPerSession, numberOfAnswered: $numberOfAnswered )
빌드(build)하고 실행(run)한다. 이제
ScoreView
는 새로운 질문 수(number of questions)를 보여준다(reports).Custom environment property Key points
이것은 또 다른 장대한(intense) 장(chapter)이었다. 그러나 결국 이전과 마찬가지로 개념(concepts)이 어떻게 작동하는지 이해(understand)하면 간단(simple)하다.
배운(learned) 내용을 요약(summarize)하면 다음과 같다:
@ObservedObject
를 사용하여ObservableObject
를 준수(conforming to)하는 클래스(class)의 인스턴스(instance)를 속성(property)으로 만들 수 있다. 클래스(class)는 하나 이상의@Published
속성(properties)을 정의(define)할 수 있다. 이러한 변수들은 뷰(view) 내부가 아닌 클래스(class)에서 구현(implement)한다는 점을 제외하면, 상태 변수(state variables)처럼 작동한다.@EnvironmentObject
를 관찰 가능한 객체(observable objects)를 주입(inject)할 수 있는 가방(bag)으로 사용한다. 그런 다음 주입(injected)한 뷰(view)와 모든 하위 항목(descendants)에서 이를 가져올(pull) 수 있다.@Environment
를 사용하면colorScheme
또는locale
과 같은 시스템 환경 값(system environment value)에 접근(access)할 수 있다. 반응성(reactivity)을 포함한 바인딩(binding)의 모든 이점(advantages)을 포함하는 환경 속성(environment property)을 만들 수 있다.@Environment
를 사용하여 고유한(own) 사용자 정의(custom) 환경 속성(environment properties)을 만들 수도 있다.
Where to go from here?
State and data flow reference documentation: apple.co/2YzOdyp
SwiftUI Attributes Cheat Sheet: bit.ly/35Xt7eU
'Raywenderlich > SwiftUI by Tutorials' 카테고리의 다른 글
Chapter 10: More User Input & App Storage (0) 2021.06.24 Chapter 8: State & Data Flow — Part I (0) 2021.04.12 Chapter 7: Introducing Stacks & Containers (0) 2021.04.08 Chapter 6: Controls & User Input (0) 2021.04.07 Chapter 5: Intro to Controls: Text & Image (0) 2021.04.07