[번역] Point-Free #65 SwiftUI와 상태 관리 - 파트 1
거대하고 복잡한 아키텍쳐를 만드는 일엔 어떤 장애물이 도사리고 있을까요? 오늘은 SwiftUI로 앱을 만들어보면서 애플은 그 장애물을 어떻게 극복했는지 알아보려고 합니다.
바로잡기
이 에피소드는 Xcode 11 베타 3을 기반으로 작성되어서 최근 버전과 비교해 큰 차이가 있을 수 있습니다. 확인된 사항들은 정정해두었으며 더 자세한 내용은 블로그에 작성해두었습니다.
시작하며
오늘은 어플리케이션 아키텍쳐에 대해 토론하는 시간을 가져봅시다. 지금까지는 사이드 이펙트를 바깥으로 빼고, 순수 함수를 사용하며, 읽기 쉬운 함수를 만들고, 이를 합성하면서 이해하기 쉽고 테스트가 가능한 어플리케이션을 만드는 방법에 대해 익혔습니다.
이 정도면 함수형 프로그래밍의 토대가 되는 지식은 모두의 머릿속에 깊이 자리잡았다고 생각합니다. 이제는 이러한 아이디어를 섞어서 복잡하고 거대한 어플리케이션을 만들 수 있는 하나의 아키텍쳐에 대해 이야기할 시간입니다! 하지만 그전에 우리 앞에 어떤 문제가 도사리고 있으며, 복잡한 아키텍쳐를 만들 때 겪을 시련의 종류엔 무엇이 있는지 알아보는 것이 먼저입니다. 이번 에피소드에선 함수형 프로그래밍에 대한 내용은 많지 않습니다. 함수형 프로그래밍이 아키텍쳐에서 어떤 역할을 해야하는지에 대한 배경 설명을 먼저 할 것이기 때문입니다.
이번 시리즈에선 어플리케이션 개발에서 발생하는 다양한 문제의 예를 주로 볼 예정입니다. 아마도 그 문제 상황들은 변경되거나 지속되어야 할 상태(State)를 가질 것입니다. 어떤 경우엔 API 리퀘스트에서 데이터를 불러와서 알림을 띄워주기도 하고, 수 많은 화면들이 존재하는데 각 화면이 글로벌 앱 상태를 변경해야 하는 경우도 있을 것입니다. 오랜 기간 유지되며 확장 가능한 아키텍쳐를 만드려면 이러한 업무를 처리하는 일관된 방식이 있는 것은 필수입니다. 물론 사람 수에 상관없이 중요합니다.
예제는 Swift 5.1과 Xcode 11 베타를 사용해서 만들었지만, 앞으로 얘기할 내용은 UIKit 기반 어플리케이션에도 동일하게 적용됩니다. SwiftUI를 선택한 이유는 애플이 UIKit 시절과 다르게 아키텍쳐가 어떻게 생겨야 하는지에 대해 어느정도 강력한 태도를 가지고 있다고 생각했기 때문입니다. 저희는 이번 시간이 아키텍쳐의 중요성을 이해하고 애플이 제공하는 도구를 발판으로 사용해서 니즈에 딱 맞는 아키텍쳐를 만들기 위한 완벽한 기회라고 생각하기 때문에 SwiftUI를 선택했습니다.
오늘은 SwiftUI의 작동 방식 중 우리에게 꼭 필요한 내용이 아니면 깊게 파지 않을 것입니다. 꼭 필요한 부분은 자세히 설명하겠지만 전체적으로 깊이 알아보는 것은 다른 에피소드에서 다루도록 하겠습니다.
어플리케이션 만들기
오늘 만들 어플리케이션은 간단한 숫자 카운팅 앱이고, 숫자가 소수(prime number)인지 확인하고 즐겨찾기 목록에 추가/제거 할 수 있는 기능을 가지고 있습니다. 또한 n번째 소수를 찾는 기능도 있는데, 이는 Wolfram Alpha의 API를 사용할 것입니다. 즐겨찾기한 소수의 목록도 볼 수 있고 원하면 제거도 할 수 있습니다. 어플리케이션의 데이터가 어떻게 흐르는지 보여주는 것에 집중하기 위해 스타일링은 최소한으로 넣었습니다.
이 앱은 절대 실제 서비스되는 어플리케이션과 같을 수는 없습니다. 오히려 토이 프로젝트에 가깝다고 할 수 있죠. 하지만 우리의 어플리케이션 아키텍쳐에 있을 문제는 실제 서비스 어플리케이션과 비교해 모자라지 않기 때문에 예제로는 충분하다는 결정을 내렸습니다.
-
사용자에게 보여줘야 할 UI의 상태 변경이 아주 많습니다. 예를 들어, 버튼을 누르면 모달이 뜨고, 모달의 컨텐츠는 이전 화면에서 일어난 일에 의존성을 가질 것입니다. 게다가 버튼은 네트워크 리퀘스트를 발생시키고, 리퀘스트 중에는 버튼을 비활성화 할 예정입니다.
-
즐겨찾기한 소수의 목록같이 여러 화면에 걸쳐서 유지돼야하는 상태도 존재합니다. 소수를 추가하거나 제거할지 선택하면 그 선택에 따라 모든 즐겨찾기한 소수의 목록을 보여주는 리스트를 업데이트 해야하기 때문입니다.
-
앱을 아주 작은 단위의 서브 컴포넌트로 나눌 수 있습니다. 이러한 상황이 왜 좋냐면 특정 화면을 만들 때 큰 범위인 어플리케이션을 고민하지 않고 고립된 상태에서 개발할 수 있습니다. 더 나아가면 Swift 패키지로 만들 수도 있겠죠?
-
n번째 소수를 찾기 위한 계산을 하는 API 리퀘스트 같은 사이드 이펙트가 앱내에 존재합니다. 여기서 사이드 이펙트를 처리하는 방식에 대한 생각을 말씀드리자면, 아무데서나 네트워크 호출을 하지 않게 만들고 싶습니다. 무차별한 네트워크 호출은 앱 내의 데이터 흐름을 이해하기 어렵게 만들고 뷰를 테스트하기 어렵게 만들기 때문입니다.
네비게이션 화면
실전으로 들어가기 전의 SwiftUI 살짝 맛보기 시간입니다! 다음과 같은 루트 뷰를 만듭니다. 이 뷰는 타이틀 뷰와 수직으로 쌓여있는 두 개의 버튼으로 구성돼있습니다. SwiftUI는 항상 View 프로토콜에 따른 구조체를 생성해야 하는데 일단 내용은 EmptyView
로 채워놓겠습니다.
import SwiftUI
struct ContentView: View {
var body: some View {
EmptyView()
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(
rootView: ContentView()
)
여러분은 여러분도 모르게 Swift 5.1에서 발표된 새로운 기능을 이미 사용하셨습니다!
-
body
타입의 속성에some
키워드가 있습니다. 당장 이 키워드의 작동 방식에 대해서 깊이 이해할 필요가 당장은 없습니다. 나중에 다른 에피소드에서some
의 개념에 대해 자세히 다룰 것이니 지금은View
프로토콜에 맞는 어떠한(some) 값을 반환하기만 하면 된다고 이해하시면 되겠습니다. -
한 줄 짜리 블록에선
return
을 굳이 명시하지 않아도 알아서 값을 반환합니다.
플레이그라운드에서 뷰를 렌더링하기 전에 UIHostingController
으로 한 번 감싸는 것을 보셨나요? 이는 SwiftUI 세상과 UIKit 세상을 연결하는 작업입니다.
이제 뷰의 내용을 채워볼까요? 다른 화면으로 이동해야하니 루트 뷰는 NavigationView
로 바꾸겠습니다.
struct ContentView: View {
var body: some View {
NavigationView {
}
}
}
NavigationView
를 사용하면 다른 화면으로 갈 수 있는 버튼인 NavigationLink
를 사용할 수 있게 됩니다.
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: EmptyView()) {
Text("Counter demo")
}
NavigationLink(destination: EmptyView()) {
Text("Favorite primes")
}
}
}
}
SwiftUI는 요소가 나란히 있거나 하나가 다른 하나의 위에 있을 경우만 뷰를 그리기 때문에 지금같은 코드에선 첫 번째 NavigationLink
만 보일 것입니다. 저희는 전자이니 List
로 NavigationLink
들이 나란히 있다고 명시적으로 적어주곘습니다.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: EmptyView()) {
Text("Counter demo")
}
NavigationLink(destination: EmptyView()) {
Text("Favorite primes")
}
}
}
}
}
마지막으로 뷰의 타이틀을 세팅하기 위해 navigationBarTitle
을 NavigationView
의 최상단에 추가합니다.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: EmptyView()) {
Text("Counter demo")
}
NavigationLink(destination: EmptyView()) {
Text("Favorite primes")
}
}
.navigationBarTitle("State management")
}
}
}
아직 무엇을 채워야 할지 확실하지 않은 화면에 EmptyView
을 넣어보세요. UI를 천천히 채워넣어도 되는 것은 정말 끝내주게 편합니다. 만약 뷰가 꼭 있어야하는데 어떤 내용을 채워야 할지 모르겠다면 EmptyView
가 제격입니다.
어느새 첫 번째 화면이 완성되었네요! SwiftUI를 사용하니 정말 빠르게 화면을 구성할 수 있어서 좋네요.
카운터 화면
다음은 카운터 화면을 작성할 시간입니다. 일단 구조만 먼저 잡아두겠습니다.
struct CounterView: View {
var body: some View {
EmptyView()
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(
// rootView: ContentView()
rootView: CounterView()
)
이제 가로 스택과 세로 스택을 나타내기 위해 HStack
과 VStack
을 사용해서 화면을 그려보겠습니다.
struct CounterView: View {
var body: some View {
VStack {
HStack {
Button(action: {}) {
Text("-")
}
Text("0")
Button(action: {}) {
Text("+")
}
}
Button(action: {}) {
Text("Is this prime?")
}
Button(action: {}) {
Text("What is the 0th prime?")
}
}
}
}
Button
을 사용하려면 버튼을 탭 했을 경우 해야 할 액션을 지정해줘야 합니다. 이 버튼에 역할이 없는 것은 아니지만 지금 당장은 화면만 만드는 중이니 나중을 위해 빈 클로저를 넣어주겠습니다.
이번 에피소드에서는 스타일링에 대해 신경쓰지 않기로 했지만 화면의 가독성을 헤치는 건 원치 않기 때문에 타이틀 사이즈로 폰트를 설정해주었습니다.
.font(.title)
드디어 핵심 UI가 제자리를 찾았네요. 하지만 루트 뷰인 ContentView
를 완성시켜서 버튼을 누르면 카운터 화면으로 보내는 일이 남았습니다.
NavigationLink(destination: CounterView()) {
이제 CounterView
로 넘어갈 수 있겠네요.
그리고 타이틀이 제대로 된 곳에 위치하기 위해서는 카운터 뷰에 네비게이션 타이틀도 추가해야 합니다.
.navigationTitle("Counter demo")
아직 이 화면이 하는 일은 아무것도 없습니다. 버튼을 눌러봐도 아무 작동도 하지 않습니다.
SwiftUI가 제공하는 옵션 중 하나는 특정한 뷰에 상태를 이어주어서 상태가 바뀌면 뷰가 다시 그려지도록 할 수 있는 것입니다. 가장 간단한 옵션부터 시작해봅시다. @State
를 사용해서 뷰에 상태를 이어주겠습니다.
@State var count: Int = 0
이는 Swift의 새로운 기능인 프로퍼티 래퍼(Property Wrapper)
입니다. 어떠한 타입을 어떤 기능을 제공하는 다른 타입으로 한 번 감쌀 수 있으면서 내부의 값을 바깥에서는 이전과 같이 사용할 수 있는 기능입니다. 위에서 @State
속성은 정수 타입을 한 번 감싸면서 새로운 객체로 만들었는데, 이 속성을 사용하면 다음과 같은 특징을 가지게 됩니다.
count
변수만 변경해도 뷰에 그려져 있는 값이 알아서 변경됩니다.
self.count // Int
가장 쉬운 확인 방법은 Text
에 넣어보는 것이죠.
Text("\(self.count)")
그럼 이제 입력한 숫자가 “몇 번째” 소수인지 알려줄 수 있는 텍스트를 보여줘볼까요? 그럴려면 count
의 값을 그냥 정수가 아닌 몇 번째인지 알려줄 수 있게 바꿔야겠네요. (예. 1st, 2nd, 3rd 등) NumberFormatter
를 사용해서 작성해봅시다.
private func ordinal(_ n: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter.string(for: n) ?? ""
}
이 헬퍼 함수를 이용해서 버튼의 내용을 업데이트합시다.
Button(action: {}) {
Text("What's the \(ordinal(self.count)) prime?")
}
지금까지 예시로만 봤을 때 @State
는 많은 일을 하지 않는 것처럼 보일 수도 있습니다. 그저 정수인 count
를 직접적으로 참조하고 있었으니까요. 하지만 사실은 뒤에서 @State
가 count
를 Binding
으로 감싸주고 있습니다.
self.$count // Binding<Int>
이게 바로 SwiftUI가 값이 언제 바뀌었는지 알아내고 변경된 정보를 통해 뷰를 다시 그리는 데에 사용하는 타입인 Binding
입니다. 뷰를 작성하는 입장에서 변경될 수 있는 값을 이렇게 간단하고 익숙하게 만들 수 있기 때문에 이건 정말 강력한 기능이라고 생각합니다! 물론 SwiftUI는 수면 아래에서 열심히 값의 변경이 어떤 곳의 변화를 일으켜야 하는지 계속 계산하고 있겠죠.
이러한 이유로 +/- 버튼에선 count
변수만 건드리면 바로 값이 변경하는 코드가 완성됩니다.
Button(action: { self.count -= 1 }) {
Text("-")
}
Button(action: { self.count += 1 }) {
Text("+")
}
어느정도 작동하는 앱이 만들어졌네요! 이제 +/- 버튼을 누르면 counter
값이 변하는 데다가 버튼에 있는 레이블의 내용도 변경될 것입니다.
카운터 화면 상태 유지하기
지금까지는 아주 쉬웠으니 모두 이해하셨으리라 믿습니다. 사실은 심각한 문제가 하나 있습니다. @State
속성을 사용한다는 것은 뷰에게 신경써야 할 로컬 상태가 있다는 것을 의미합니다. 하지만 이 상태를 다른 화면으로 넘어갔을 때도 유지할 수 있는 방법이 없습니다. 이 상황은 쉽게 재현이 가능한데요, 카운터 화면에서 메인 화면으로 갔다가 다시 카운터 화면으로 가면 이전 값을 유지하는 것 대신 값을 0으로 초기화됩니다.
그 이유는 바로 @State
는 로컬에 특화된 기능이라 다른 화면으로 유지되지는 않기 때문입니다. 대표적인 예시는 바로 버튼의 하이라이트 상태입니다. 버튼의 하이라이트 상태는 사용자의 터치에 의해 변경되고 화면 바깥으로 변경된 정보가 전달될 필요가 없기 때문입니다.
상태가 유지되길 원한다면 SwiftUI가 제공하는 기능인 @ObjectBinding
을 사용해야 합니다. 이 기능의 작동 방식은 @State
와 매우 비슷한데요, 다른 점은 여러분의 상태가 어느 위치에서 어떻게 작동해야하는지 SwiftUI 시스템에 알려줄 필요가 없다는 것입니다. @ObjectBinding
을 사용하면 우리의 상태를 로컬 뷰에 묶이지 않고 좀 더 전역적인 객체로 만들고 앱 전체에 상태를 알릴 수 있습니다.
그럼 이제 @State
를 @ObjectBinding
으로 변경해보겠습니다.
@ObjectBinding var count: Int = 0
하지만 이 코드에는 두 가지 문제점이 있습니다.
-
첫 번째, 이 화면을 열 때 항상 카운터 값이 0부터 시작하진 않을 것이기 때문에 기본값을 0으로 지정하면 안됩니다. 이 값이 전역적으로 유지되게 만드려면 기본값을 제거하고
var count: Int
처럼 타입만 지정하면 되겠네요. -
두 번째,
@ObjectBinding
을 따르는 값의 타입은 항상BindableObject
프로토콜을 따라야 합니다. 상태가 유지되려면 어떻게 값의 변경을 제어할지와 나머지 시스템에 어떻게 알릴지에 대한 프로토콜을 반드시 구현해줘야 합니다.
두 번째의 의미는 count
를 프로토콜을 받을 수 있는 타입으로 한 번 감싸줘야 한다는 것을 의미합니다. struct
(값 타입이 좋은 점은 지난 에피소드에서 알려드렸습니다!)가 이 프로토콜에 어울리는지 한 번 봅시다.
struct AppState: BindableObject {
var count: Int
}
바로 문제가 생겼네요.
🛑 Non-class type ‘AppState’ cannot conform to class protocol ‘BindableObject’
이 에러는 BindableObject
프로토콜이 AnyObject
를 따르기 때문인데요, AnyObject
를 따른다는 것은 곧 이 객체가 class
여야 한다는 것을 의미합니다. 잘 생각해보면 이 경우 사용해야 하는건 클래스가 맞는 것 같습니다. 왜냐하면 우리가 원하는 상태는 하나여야 하고(singular), 앱 상태는 어딜가나 유지돼야해서(persistent) 상태가 복사가 아닌 참조되는 것이 맞기 때문입니다. 바로 모든 작업이 하나의 진짜 데이터에서 일어나는 것이죠.
그러니 클래스로 변경하겠습니다.
class AppState: BindableObject {
var count: Int
}
이제 우리에게 돌아오는 건 컴파일러가 보내는 count
가 초기화되지 않았다고 하는 에러와 프로토콜의 요구 사항을 충족하지 않았다는 에러입니다.
일단 count
의 기본값을 설정해줍니다.
class AppState: BindableObject {
var count = 0
}
프로토콜을 충족하려면 반드시 didChange
프로퍼티를 제공해야 합니다. 자동 완성이 알려주는 모양이 조금 혼란스러울 수 있지만, 우리가 해야 하는 일은 단지 didChange
퍼블리셔를 제공하는 것입니다.
var didChange: AppState.PublisherType
바로잡기
Xcode 11 베타 5 이후 버전에선 SwiftUI의
BindableObject
프로토콜이 Combine 프레임워크의ObservableObject
프로토콜로 변경되었습니다. 이 프로토콜은ObservableObjectPublisher
의 프로퍼티인objectWillChange
를 사용하고, 모델에 어떠한 변경이 일어나도 그 바로 직전에(이후가 아닙니다) 실행됩니다.let objectDidChange = ObservableObjectPublisher()
또한 위의 보일러 플레이트 코드도 필요가 없어집니다. 왜냐하면
ObservableObject
프로토콜이 기본 퍼블리셔로 제공되기 때문입니다.
퍼블리셔(Publisher)는 Combine 프레임워크에서 나와서 SwiftUI에도 적용된 개념입니다. 이 퍼블리셔에 대해서는 나눌 얘기가 정말 많으니 다른 에피소드에서 따로 다루려고 합니다. 그러니 지금 당장은 모델에 변경이 일어났을 때 이 변경에 관심있는 구독자들에게 알림을 주기 위한 매커니즘 정도라고 생각하시면 되겠습니다. 우리는 PassthroughSubject
를 사용하면 되겠네요. PassthroughSubject
는 두 가지 제네릭을 가지고 있는데요, 하나는 값을 보낼 수 있는 제네릭과 다른 하나는 에러 상황에서 스트림을 끝내버리기 위한 제네릭입니다. 앞에서 했던 것처럼 지금 당장은 변화가 생겨도 구독자들에겐 아무 값도 보내지 않고 절대 실패하지 않는다는 의미의 Void
와 Never
를 넣어두겠습니다.
var didChange = PassthroughSubject<Void, Never>()
이 매커니즘은 노티피케이션 센터가 작동하는 것과 매우 닮았지만 영향을 끼칠 수 있는 영역이 제한돼있다는 점이 다릅니다. 이 객체의 변화에 대해 누군가가 관심이 있다면 쉽게 구독할 수 있고, 우리는 변화가 발생했을 때 send
메소드에 값만 담아서 호출하면 됩니다.
드디어 컴파일러의 요구 조건을 모두 충족시킨 것 같습니다. 하지만 그렇다고 어떤 일이 일어나고 있는 것은 아닌 것 같네요. 어떤 일이 생기기 위해선 모델이 바뀔 때마다 퍼블리셔에게 값이 바뀌었다고 알려줘야 합니다. 그리고 다행히도 Swift에는 이러한 경우를 위한 우아하고 직관적인 기능이 존재합니다. 우리가 할 일은 그저 변경을 감시하고 싶은 속성에 didSet
핸들러만 붙여주는 것입니다.
var count = 0 {
didSet {
self.didChange.send()
}
}
바로잡기
Xcode 11 베타 5 이후에는
didSet
대신에willSet
을 사용해야 합니다.var count = 0 { willSet { self.objectWillChange.send() } }
길고 긴 보일러 플레이트 코드를 작성하지 않는 방법도 있습니다. 바로
@Published
프로퍼티 래퍼를 사용하는 것이죠.@Published var count = 0
여기까지 어플리케이션 안에서 값이 유지되는 상태를 가지기 위해 우리가 해야하는 일에 대해 설명했습니다. 이제 뷰에 적용할 시간입니다.
@ObjectBinding var state: AppState
바로잡기
Xcode 11 베타 5 이후에는 SwiftUI의
@ObjectBinding
프로퍼티 래퍼가 Combine 프레임워크에서 소개된@ObservedObject
로 변경되었습니다.
이렇게 되면 더 이상 count
필드는 존재하지 않고, state
를 통해 count
에 접근해야 합니다.
Button(action: { self.state.count -= 1 }) {
…
Text("\(self.state.count)")
…
Button(action: { self.state.count += 1 }) {
…
Text("What's the \(ordinal(self.state.count)) prime?")
거의 다 고쳤습니다! 마지막은 카운터 뷰로 넘어갈 때 상태도 같이 넘겨주는 것입니다.
NavigationLink(destination: CounterView(state: <#AppState#>)) {
이러면 ContentView
에도 앱 상태에 접근할 수 있어야하니 다음과 같이 추가해줍니다.
struct ContentView: View {
@ObjectBinding var state: AppState
그럼 다음과 같이 작성할 수 있습니다.
NavigationLink(destination: CounterView(state: self.state)) {
정말 마지막으로 플레이그라운드 라이브 뷰를 생성할 때 AppState
의 초기값을 넣어줍니다.
PlayergroundPage.current.live = UIHostingController(
rootView: ContentView(state: AppState())
)
좋습니다. 지금까지 전역 앱 상태를 필요로 하는 뷰에 이어주는 배관 작업을 진행했습니다. 이러한 작업의 이점은 바로 count
값이 모든 화면에 걸쳐 유지된다는 점입니다! 우리는 카운터 화면에 들어가도, 여기서 count
값을 변경해도, 다시 메인 화면으로 돌아갔다가 다시 카운터 화면으로 돌아와도 유지되는 count
값을 확인할 수 있습니다. SwiftUI의 @ObjectBinding
의 힘이 얼마나 강력한지 확인하는 하루였네요.
다음 시간 예고: 소수 확인기
이번 시간엔 뷰에 상태를 표시하는 방법, 뷰에 일어난 반응을 상태의 변화로 만드는 방법 그리고 전체 어플리케이션에 걸쳐 상태를 유지하는 방법을 알게되었으니, 이젠 새로운 화면을 하나 만들 것입니다. 그 화면은 바로 소수 확인기 모달입니다. 여기엔 “이 수가 소수인가요?” 버튼이 존재하고, 이 버튼을 탭하면 현재 카운터의 count
값이 소수인지 아닌지 레이블에 보여줄 것입니다. 그리고 즐겨찾기 소수 목록에 추가/제거 할 수 있는 버튼도 제공할 예정입니다.
연습문제
-
앱을 껐다가 켜도 상태를 불러와서 이전과 같이 유지되게 하려면 어떻게 할 수 있을까요? 몇 가지 단계를 거치면 가능합니다.
AppState
가Codable
을 따르도록 만들어야 합니다. 왜냐하면PassthroughSubject
의willChange
프로퍼티에CodingKeys
를 제공하지 않으면 인코딩과 디코딩 모두 직접 구현 해야하기 때문입니다.- 모든 모델의
willSet
에 가서 상태를 JSON으로 만들어서UserDefaults
에 저장하도록 만듭니다. - 시작 화면인
ContentView
가 생성될 때UserDefaults
에서AppState
를 불러와서 상태로 만든 후ContentView
에 넣어줍니다.
이 구현을 성공하신다면 여러분의 데이터는 여러 번의 플레이그라운드 실행에도 유지될 것입니다! 그렇지만 몇 가지 문제가 있긴 합니다.
Codable
을 구현하는 것은PassthroughSubject
때문에 좀 귀찮습니다. 우리는 상태가 변할 때마다UserDefaults
에 상태를 저장하는데 이는 매우 비효율적입니다. 게다가 모든willSet
에 대해 반복하게 해두었으니 정말 비효율적입니다. 이를 효율적으로 관리하는 방법에 대해서는 곧 알아볼 예정이니 걱정마세요. -
어떤 정수가 주어졌을 때 이 정수가 소수인지 아닌지 확인하는 알고리즘을 찾고 Swift로 변환해보세요.
-
만약 현재
count
값이 소수라면 카운터 화면의Text
를 초록색으로, 아니라면 빨간색으로 바꾸도록 만들어보세요. -
SwiftUI
의 모달을 나타내기 위해 옵셔널한Modal
값 하나를 인자로 받는presentation
을 사용하신 분도 계실겁니다. 이 값이 있으면 모달이 나타나고 이 값이nil
이라면 화면이 닫힙니다.선택적으로
@State
값을CounterView
에 추가한 후, "이 수가 소수인가요?" 버튼을 눌렀을 때 그 결과에 맞춰서 화면을 보여주거나 숨길 수 있게 만들어보세요. -
var favoritePrimes: [Int]
필드를AppState
에 추가한 후 이 값이 변경되면didChange
에 잡히는지 확인해보세요.이 새로운
favoritePrimes
상태를 모달의 "즐겨찾기 소수에 추가" / "즐겨찾기 소수에서 제거" 버튼을 그리는 데에 상요해보세요. 또한 버튼을 누르면 이 즐겨찾기 소수 목록에서 추가 혹은 제거할 수 있는 기능을 구현해보세요. -
AppState
에 새로운 상태를 추가하는 것이 아주 번거롭습니다. 상태의 어떠한 필드가 변경되면willChange
를 호출해야 하며, 여러 필드를 하나의 상태 클래스에 묶어두려면 더 많은 작업이 필요할 것입니다.이러한 문제는 제네릭 클래스인
Store<A>
를 통해 해결할 수 있습니다. 이 클래스를 구현하고 어플리케이션 내부의AppState
를Store<AppState>
로 변경해보세요.
참고자료
-
SwiftUI Tutorials
Apple
애플은 SwiftUI와 Combine과 동시에 새로운 개념에 대해 이해할 수 있는 초고퀄의 인터랙티브한 튜토리얼도 제공하고 있습니다.
-
Inside SwiftUI (About @State)
kateinoigaku • Sunday Jun 9, 2019
@State
가 뒤에선 어떻게 작동되고 있는지 알고 있는 사람은 드물 것입니다. 때론 마법처럼 보이기도 하죠! 이 글은@State
가 내부적으로 어떻게 구현됐는지 알아보고, SwiftUI가 런타임에 사용할 수 있는 풍부한 메타데이터를 사용하고 있다고 합니다. (과거에 이와 관련해서 깊게 판 글쓴이의 다른 글도 여기있습니다)