State와 Binding
지난 시간 @State와 @Binding 프로퍼티 래퍼에 대해 학습했다.
1. @State
@State 프로퍼티 래퍼를 사용해서 상태 프로퍼티를 작성하면 해당 프로퍼티가 선언된 뷰와 바인딩할 수 있게 된다.
좀 더 쉽게 설명하자면 뷰와 바인딩이 되어 있는 상태프로퍼티에 변경이 일어나면 자동으로 뷰가 갱신된다는 말이다.
코드로 직접 살펴보자.
아래 코드는 버튼을 클릭하면 숫자가 1씩 증가하도록 구현한 앱이다.
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
}
}
}
앱을 실행시키고 버튼을 눌러보면 누를 때마다 숫자가 1씩 증가하는 것을 볼 수 있다.
숫자가 증가한 뒤 이를 참조하고 있는 뷰를 갱신하지 않아도 알아서 갱신된다.
그렇다면 @Binding는 무엇일까?
2. @Binding
위에서 작성한 코드는 상태 프로퍼티를 선언한 뷰에서 상태 프로퍼티를 직접 사용하고 있다.
즉, 선언과 사용이 하나의 뷰(Struct)에서 진행된다는 말이다.
그렇다면 메인 뷰(부모)에서 선언한 상태 프로퍼티를 서브 뷰(자식)에서 사용한다면 어떻게 될까?
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
}
}
}
struct SubView: View {
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number)")
}
.padding()
.background(.gray)
}
}
코드를 작성하고 빌드를 진행하면 컴파일 에러가 발생한다.
SubView 내부에 number 프로퍼티가 선언되어 있지 않기 때문이다.
그렇다면 SubView 내부에 number 프로퍼티를 선언하면 어떻게 될까?
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
}
}
}
struct SubView: View {
private var number: Int = 0
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number)")
}
.padding()
.background(.gray)
}
}
이번엔 빌드는 정상적으로 진행된다.
하지만 버튼을 클릭해도 서브뷰에 있는 텍스트는 변경되지 않는다.
SubView의 number와 ContentView의 number는 서로 다른 프로퍼티이기 때문이다.
그렇다면 두 개를 연결해주면 문제를 해결해 줄 수 있지 않을까?
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
SubView(number: number)
}
}
}
struct SubView: View {
// 생성자를 통해 number 전달
init(number: Int) {
self.number = number
}
private var number: Int = 0
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number)")
}
.padding()
.background(.gray)
}
}
생성자를 통해서 메인 뷰의 number를 전달했더니 서브 뷰에서도 화면이 갱신되는 것을 볼 수 있다.
서론이 길었지만 @Binding 프로퍼티 래퍼를 통해서 간단한 형태로 작성할 수 있다.
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
SubView(number: $number)
}
}
}
struct SubView: View {
@Binding var number: Int
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number)")
}
.padding()
.background(.gray)
}
}
생성자를 통해서 프로퍼티를 전달한 방식과 유사하다.
차이점은 서브 뷰에서 @Binding 프로퍼티 래퍼로 변수를 선언만 한다는 것이다.
메인 뷰에서는 Argument로 $number를 전달한다.
여기서 명심해야할 것은 $(달러)!
$(달러)기호를 빼먹으면 컴파일 에러가 발생한다.
서브 뷰에서 Binding<Int> 형으로 number를 선언했는데 Int형 number를 전달해서 발생하는 타입 에러이다.
처음 의도했던 대로 정상적으로 작동하는 것을 볼 수 있다.
하지만 의문점이 한 가지가 있다. 🧐
생성자를 통해서 전달하는 방식도 있는데 굳이 @Binding이라는 프로퍼티 래퍼가 등장한 이유는 무엇일까?
지금 예시들은 뷰 사이에서 단방향으로 데이터를 주고 받고 있다.
메인 뷰(ContentView) -> 서브 뷰(SubView) 방향으로, 다시 말해 서브 뷰에 파라미터로 메인 뷰의 프로퍼티를 전달하고 있다.
양방향으로 데이터를 주고 받도록 코드를 변경해보면 @Binding을 사용하려는 이유에 대해 명확하게 알 수 있다.
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
SubView(number: self.number)
}
}
}
struct SubView: View {
private var number: Int = 0
init(number: Int) {
self.number = number
}
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
}
서브 뷰에도 버튼을 달아서 SubView 값도 변경할 수 있도록 코드를 변경해줬다.
하지만 컴파일 오류를 발생시킨다.
View 자체가 Struct이므로 Immutable하기 때문이다.
컴파일 자체가 안되지만 논리적으로 생각해보면 우리가 구현하려는 기능은 일반적인 프로퍼티로는 불가능하다는 것을 알 수 있다.
메인 뷰 내부에 서브 뷰 인스턴스를 생성하고 파라미터로 프로퍼티 값을 전달하고 있다.
메인 뷰에서도 서브 뷰의 프로퍼티를 접근하기 위해선, 서브 뷰 내부에 메인 뷰 인스턴스를 생성하여 파라미터로 전달하기 때문이다.
그렇다면 이 기능은 구현할 수 없는 것일까?
이 때, @Binding 프로퍼티 래퍼를 사용하면 된다!
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
SubView(number: $number)
}
}
}
struct SubView: View {
@Binding var number: Int
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
mutating func addNumber() {
self.number += 1
}
}
정상적으로 컴파일이 정상적으로 진행되고 앱을 실행해보면 메인 뷰의 number와 서브 뷰의 number가 같은 값을 가지고 있고, 양방향으로 데이터를 주고 받고 있는 것을 볼 수 있다!
이것이 바로 @Binding을 사용하는 이유이다.
하지만 @Binding 역시 단점은 존재한다.
앞서 말했던 것처럼 상태 프로퍼티는 프로퍼티를 선언한 뷰에서만 접근이 가능하다.
하위 뷰가 아니거나 바인딩이 구현되어 있지 않으면 다른 뷰에서 접근이 불가능하다.
또한 뷰 자체에 종속적인 개념이므로 뷰가 사라지면 프로퍼티 역시 사라진다.
이러한 문제를 해결해주는 것이 Observable 객체다.
앞서 말한 문제점들을 해결하고, 다른 뷰에서 접근할 수 있는 영구적인 데이터를 표현하기 위해 사용한다.
Observable 객체
아래 코드와 빌드된 앱을 보면 @Binding 프로퍼티 래퍼를 사용해서, 페이지가 변경되어도 하나의 값을 가르키고 이를 변경하고 있다.
struct ContentView: View {
@State private var number: Int = 0
var body: some View {
NavigationView {
VStack {
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
SubView(number: $number)
NavigationLink(destination: SecondView(number: $number)) {
Text("Second View로 이동")
}
}
}
}
}
struct SubView: View {
@Binding var number: Int
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
}
struct SecondView: View {
@Binding var number: Int
var body: some View {
VStack {
Text("This is Second View")
Text("\(self.number)")
Button(action: {
self.number += 1
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
}
하지만 메인 뷰 내부에 Second View가 종속적으로 선언되어 있고, 매번 @Binding를 달아준 프로퍼티를 선언해야 한다.
단순히 하나의 페이지(부모)의 프로퍼티를 다른 페이지(자식)에서 사용하는 경우에는 기존 방식대로 @Binding으로 전달해도 되지만, 페이지 이동이 잦아지면 매번 직접 연결해줘야 하므로 번거롭다.
이런 문제들을 Observable이 해결해준다.
ViewModel과 비슷한 역할을 수행하는데, 특정 상황에 따라 변경되는 데이터를 수집하고 관리하는 역할을 수행한다고 볼 수 있다.
Observable 객체는 Observer 객체와 하나의 세트를 이룬다.
Observable 객체는 프로퍼티를 게시(Publish)하고 Observer 객체는 이를 구독하고 있다.
import Foundation
import Combine
class CustomNumber: ObservableObject {
@Published var number = 0
func increateNumber() {
self.number += 1
}
}
struct ContentView: View {
@ObservedObject var number: CustomNumber
var body: some View {
NavigationView {
VStack {
Text("\(self.number.number)")
Button(action: {
self.number.increateNumber()
}) {
Text("눌러주세요!")
}
SubView(number: number)
NavigationLink(destination: SecondView(number: number)) {
Text("Second View로 이동")
}
}
}
}
}
struct SubView: View {
@ObservedObject var number: CustomNumber
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number.number)")
Button(action: {
self.number.increateNumber()
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
}
struct SecondView: View {
@ObservedObject var number: CustomNumber
var body: some View {
VStack {
Text("This is Second View")
Text("\(self.number.number)")
Button(action: {
self.number.increateNumber()
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
}
기존에 @Binding으로 작성한 코드와 비교해보면 number 증가에 대한 로직을 ObservableObject 프로토콜을 채택한 CustomNumber에서 담당하고 있는 것을 볼 수 있다.
Environment 객체
number 증가에 대한 로직을 CustomNumber 클래스로 분리했지만 여전히 Navigation을 진행할 때 구독 객체에 대한 참조체를 전달해야 한다.
상황에 따라 다르지만 앱 내의 여러 뷰가 동일한 구독 객체에 접근해야하는 경우에는 복잡해질 수 있다.
이럴 때 사용하는 것이 Environment 객체다.
방법은 Observable 객체와 동일하지만 뷰에서 뷰로 전달할 필요 없이 모든 뷰가 접근할 수 있다는 것이다.
import SwiftUI
@main
struct _21024_ObservableApp: App {
let customNumber: CustomNumber = CustomNumber()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(customNumber)
}
}
}
하지만 사용하기 전에 Sence에서 각각의 뷰에서 참조하려는 ObservableObject를 등록해줘야 한다.
struct ContentView: View {
@EnvironmentObject var number: CustomNumber
var body: some View {
NavigationView {
VStack {
Text("\(self.number.number)")
Button(action: {
self.number.increateNumber()
}) {
Text("눌러주세요!")
}
SubView()
NavigationLink(destination: SecondView()) {
Text("Second View로 이동")
}
}
}
}
}
struct SubView: View {
@EnvironmentObject var number: CustomNumber
var body: some View {
VStack {
Text("This is SubView")
Text("\(self.number.number)")
Button(action: {
self.number.increateNumber()
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
}
struct SecondView: View {
@EnvironmentObject var number: CustomNumber
var body: some View {
VStack {
Text("This is Second View")
Text("\(self.number.number)")
Button(action: {
self.number.increateNumber()
}) {
Text("눌러주세요!")
}
}
.padding()
.background(.gray)
}
}
기존 방식과 다르게 뷰와 뷰 사이 간에 Navigation을 진행할 때 매개변수로 넘겨주지 않아도 된다는 장점이 있다.
사실 @State, @Binding, @ObservedObject, @EnvironmentObject 중에 어느 것을 쓸 지는 개발자 마음이고 어떻게 구현하느냐에 따라 달라진다고 생각한다.
그래도 각 프로퍼티 래퍼별로 장점이 있으니, 장점에 맞게 사용하면 될 듯하다.
정리
하나의 뷰, 혹은 특정 뷰의 종속적인 뷰에서 단방향으로 데이터를 주고 받을 때 -> @State
양방향으로 데이터를 주고 받을 때 -> @Binding
양방향으로 주고 받으면서, 로직을 분리하고 싶을 때 -> @ObservedObject
모든 뷰에서 참조할 수 있는 데이터와 로직을 분리하고 싶을 때 -> @EnvironmentObject
👀 회고
Environment 객체의 경우 Flutter의 Provider와 매우 유사한 형태를 지녔다. Provider를 통해 하위 위젯에서 데이터를 접근할 수 있는데 이것과 완전히 똑같았다. 한 가지 궁금한 점은 Flutter에서는 값의 변경이 일어나면 해당 위젯을 전부 다시 렌더링을 진행한다. (Consumer를 적용해서 특정 부분 렌더링도 가능하다.) SwiftUI에서는 자체적으로 특정 부분만 렌더링을 하는건지 전부 렌더링을 진행하는 것인지 좀 더 알아봐아겠다.
'💻 개발 > iOS' 카테고리의 다른 글
[iOS / SwiftUI] 스크롤, 무한으로 즐겨요~ (LazyVStack으로 무한 스크롤 구현하기) (0) | 2022.11.28 |
---|---|
[iOS / Swift] Swift 문자열 정복하기 (aka 'Character') (0) | 2022.11.03 |
[iOS / SwiftUI] 키보드가 사라지지 않아요...😩 (0) | 2022.10.21 |
[iOS / SwiftUI] ForEach로 View를 리펙토링 해볼까요? (0) | 2022.10.11 |
[iOS / Swift] lim 클로저 -> 0 (클로저, 극한으로 줄여보기) (0) | 2022.10.05 |