이전 글에서 SwiftUI가 데이터의 변화에 따라 자동으로 뷰를 업데이트하기 위해 사용할 수 있는 여러가지 도구가 있다고 이야기했었다.
SwiftUI는 이 도구들을 사용해 뷰와 데이터 사이의 의존관계를 파악하기 때문에 '무엇을, 언제, 어떻게 써야할지'에 대해 알아야 SwiftUI를 효과적으로 쓸 수 있다.
Data Flow를 위한 도구들을 공부해보기 전에 알아두면 좋은 것이 있다.
도구들의 생김새를 보면 대부분 @가 앞에 붙어있다.
Property Wrapepr는 프로퍼티를 선언할 때 앞에 붙이는 @로 시작하는 것으로, 말 그대로 Property를 Wrap해서(감싸서) 미리 정의해둔대로 행동하도록 하는 장치다.
SwiftUI에서는 Property Wrapper를 다음과 같이 쓴다.
Data Flow를 위한 도구들은 크게 2가지 역할을 한다고 볼 수 있다.
이 관점을 가지고 공부해보도록 하자!
WWDC 영상 중 Data Essentials in SwiftUI에서 쓰인 예시를 함께 보면서 기본적인 도구들부터 살펴보자.
아래와 같은 앱을 만들어보려고 한다. 뷰와 데이터간의 의존관계를 어떻게 설정해야 효율적으로 데이터의 변화에 따라 뷰를 업데이트할 수 있을까?
해당 정보를 뷰에 보여주기만 하는 경우, 즉 데이터가 변함에 따라 뷰를 업데이트 하지 않아도 되는 경우 그냥 프로퍼티를 쓸 수 있다.
예를 들어 메인 페이지인 List를 만드는 경우, 이미 정해져있는 데이터를 보여주기만 해도되기때문에 뷰에 일반 프로퍼티로 데이터를 전달해도 된다.
struct BookCard: View {
let book: Book
let progress: Double
var body: some View {
HStack {
BookCover(book.coverName)
VStack(alignment: .leading) {
TitleText(book.title)
AuthorText(book.author)
}
Spacer()
RingProgressView(value: progress)
}
}
}
변하지 않을 정보이기 때문에 let으로 선언하였다.
그런데 데이터가 변할 때마다 뷰를 업데이트 해주고 싶은 경우는 어떻게 할까?
예를 들어, 책을 더 읽어서 앱에서 읽은 비율을 업데이트하고 싶은 경우 ‘Update Progress’버튼을 눌러 수정창을 열어야 할 것이다.
어떻게 구현할 수 있을까? 코드를 통해 살펴보자.
// 뷰에 필요한 데이터
struct EditorConfig {
var isEditorPresented = false
var note = ""
var progress: Double = 0
mutating func present(initialProgress: Double) {
progress = initialProgress
note = ""
isEditorPresented = true
}
}
struct BookView: View {
@State private var editorConfig = EditorConfig()
func presentEditor() { editorConfig.present(...) }
var body: some View {
...
Button(action: presentEditor) { ... }
...
}
}
수정창을 킬 것인지를 결정하는 UI 상태를 나타내는 데이터(isEditorPresented
), 메모와 읽은 비율을 나타낼 데이터(note
, progress
)가 뷰에 필요하기 때문에 별도로 EditorConfig
구조체로 묶어주었다.
그리고 버튼이 눌리면 수정창이 열리도록 present
메소드를 통해 데이터를 변경해준다.
이렇게 해당 뷰 안에서만 쓰이는 데이터의 경우, 특히 UI의 상태와 관련된 일시적인 데이터들을 변경해가면서 사용하고 싶은 경우에 @State를 사용해 데이터와 뷰 사이의 의존관계를 만들어준다.
‘@State를 사용해 데이터와 뷰 사이의 의존관계를 만들어주는 것’의 의미는 정확히 무엇일까?
@State는 SwiftUI에서 가장 간단한 source of truth이자, SwiftUI에 의해 관리되는 값을 읽고 쓸 수 있게 해주는 도구다.
UI의 상태에 대한 프로퍼티나(모달을 열 것인지 말 것인지, 토글을 on할 것인지 off할 것인지 등) 앱의 데이터모델이 완성되기 전에 간단히 테스트용으로 데이터의 변화에 따라 뷰를 업데이트하는 것을 시험해보고 싶은 경우, 우리는 뷰에 @State를 사용해 데이터의 뿌리를 둔다.
이 데이터는 해당 뷰에서 만들어진 데이터이고, 앞으로 다른 곳에서 이 데이터가 필요한 경우 해당 뷰에 있는 데이터를 참조해서 사용하게 될 것이다.
모든 @State는 Source of Truth다.
@State를 선언할 때마다, 해당 뷰가 소유하는 new source of truth를 정의하는 것이기 때문에 같은 값에 대해 여러 뷰에 @State를 선언하는 실수를 주의해야 한다.
State로 선언한 프로퍼티는 SwiftUI가 관리한다.
프레임워크가 해당 변수에 대한 영구 저장소를 할당하고, 의존관계를 추적하는 것이다. 어려운 말 같지만 해당 프로퍼티의 데이터는 뷰에서만 생성되고 사용되기 때문에 SwiftUI가 관리하는 것이 당연한 것 같다.
프로퍼티의 라이프사이클을 SwiftUI가 관리하기 때문에 데이터가 바뀔 때마다 뷰가 새로 그려질때도 State의 값을 저장소에 따로 저장해둔다.
'SwiftUI가 관리한다', '프레임워크가 영구 저장소를 할당한다, 따로 저장한다'는 말이 잘 와닿지 않을 수 있다.
좀 더 자세히 설명하자면, @State로 프로퍼티를 정의하는 경우 프레임워크가 해당 데이터를 메모리 어딘가에 저장해놓고, @State 변수는 그 값을 가리키는 포인터가 된다.
즉, @State 변수는 값 자체가 아닌 포인터인 것이다.
위에서 말한 것처럼 @State 변수는 포인터이기 때문에, 해당 변수의 값을 바뀌어도 포인터가 가리키고 있는 메모리 어딘가의 값을 바꾸는 것이지 @State 변수 값 자체를 바꾸는 것이 아니다.
메모리 어딘가에 저장된 실제 값을 wrappedValue
라고 하는데, 직접 이 프로퍼티에 접근해 값을 사용하지는 않고 @State로 정의한 프로퍼티로 참조해서 사용하는 것이다.
따라서, @State를 쓰는 경우 immutable한 View 안에서도 변수의 값을 바꿀 수 있는 것처럼 보인다.
@State는 source of truth기때문에 여러 뷰에 같은 데이터에 대해 여러개의 @State를 만들면 안된다고 했다.
그런데 'Update Progress'버튼을 눌러서 열리는 수정창의 경우, 메모와 읽은 비율을 나타내는 부모뷰의 데이터를 변경해야 한다.
부모뷰에 있는 source of truth를 참조만 해서 직접 소유하지 않으면서 해당 데이터와 수정창 뷰 사이에 명확한 의존관계를 정의할 수 있는 방법이 바로 @Binding이다.
struct BookView: View {
@State private var editorConfig = EditorConfig()
var body: some View {
...
ProgressEditor(editorConfig: $editorConfig)
...
}
}
struct ProgressEditor: View {
@Binding var editorConfig: EditorConfig
...
TextEditor($editorConfig.note)
...
}
부모뷰가 이니셜라이즈될 때, State 프로퍼티의 바인딩을 자식뷰에 넘겨주는 방식으로 구현한다.
양방향의 소통 관계를 설정할 수 있다.
프로퍼티에 $ 기호를 붙이면 State에서 Binding을 생성해 projectedValue
를 리턴한다.
@State
를 사용한다.@Binding
을 사용한다.하지만 우리는 보통 앱을 만들 때, 데이터를 UI와 분리시켜 데이터를 저장하고 처리한다.
다음 글에서는 뷰와 별도로 분리된 데이터 모델 객체와 뷰간의 관계 설정을 어떻게 할 수 있을지 아직 알아보지 못한 도구들에 대해 공부해보도록 하자!
참고
https://developer.apple.com/documentation/swiftui/state
https://developer.apple.com/documentation/swiftui/managing-user-interface-state
https://developer.apple.com/videos/play/wwdc2020/10040/?time=488