[SwiftUI] 데이터 바인딩 - EnvironmentObject, StateObject (2)
여기저기서 같은 객체를 써야할 때는 어떻게 해야할까? @Binding으로 넘겨주면 되지!라고 생각할 수 있는데
만약 A(메인 페이지) -> B(더보기 페이지) -> C(마이 페이지) 순서로 화면 전환이 되는 경우가 있다고 가정해보자.
사용자 정보를 A, C 페이지에서만 사용하고 싶은데 이때 @Binding으로 객체를 넘겨준다면
틀린 방법은 아니지만 B에서 불필요한 데이터인데도 B를 거쳐서 C에 넘겨줘야한다.
이는 매우 비효율적이고 복잡성만 늘게 된다.
또한 사용자 정보같은 데이터는 어느 페이지에서든 동일하기 때문에
일일이 Binding으로 넘겨주기 보단 전역적으로 사용하는 것이 낫다고 판단된다.
이때 EnvionmentObject 프로퍼티를 사용하면 이 불편함을 없앨 수 있다.
참고로 ObservedObject, ObservableObject를 모른다면 아래 포스팅 먼저 보고 오면 이해하기 편할듯 합니당
[SwiftUI] 데이터 바인딩 - State, Binding, ObservedObject (1)
SwiftUI에선 사용자에게 데이터를 어떻게 보여줘야할까? 🤔 SwiftUI에서 바인딩하는 데이터 종류는 다양하고 용도가 다 다르다. 바인딩 데이터 종류를 정확히 인지하지 않고 코드를 짜다 보니 매번
im-gu-ma.tistory.com
🔵 EnviornmentObject
@EnviornmentObject는 ObservableObject를 준수하는 class를 전역적으로 사용할 수 있도록 해준다.
View마다 새로운 객체를 만드는 것이 아니라 똑같은 객체를 공유하여 사용한다.
싱글톤 인스턴스를 생각하면 이해하기 쉬울듯 하다.
먼저 ObservableObject를 준수하는 UserInfo 클래스를 작성해준다.
닉네임과 나이를 변경할 것이기 때문에 @Published로 선언해준다.
class UserInfo: ObservableObject {
@Published var nickName: String = "안뇽"
@Published var age: Int = 0
init() {
print("UserInfo init!")
}
func changeNickName(_ new: String) {
nickName = new
}
func changeAge(_ new: Int) {
age = new
}
}
다음으로 예시에서 얘기했던 것 처럼 A(메인 페이지)를 만들어준다.
struct ContentView: View {
@EnvironmentObject var userInfo: UserInfo
var body: some View {
NavigationView {
VStack {
HStack {
Text("닉네임: \(userInfo.nickName)")
.font(.system(size: 30))
Spacer()
Button("변경") {
userInfo.changeNickName("말하는 고구마")
}
.font(.system(size: 30, weight: .semibold))
}
HStack {
Text("나이: \(userInfo.age)")
.font(.system(size: 30))
Spacer()
Button("변경") {
userInfo.changeAge(25)
}
.font(.system(size: 30, weight: .semibold))
}
NavigationLink("Detail View") {
DetailView()
}
.font(.system(size: 30, weight: .bold))
.foregroundColor(.mint)
.padding()
}
.padding(.init(top: 10, leading: 30, bottom: 10, trailing: 30))
}
}
}
다음으로 B(더보기 페이지)를 만들어준다.
참고로 이 페이지는 UserInfo가 필요하지 않기 때문에 화면 전환만 작성해주었다.
dismiss는 임의로 만든 뒤로가기 버튼이니 크게 신경쓰지 않아도 된다.
struct DetailView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
NavigationLink("My Page") {
UserInfoView()
}
.font(.system(size: 30, weight: .bold))
.foregroundColor(.mint)
.padding()
Button("뒤로가기") {
dismiss()
}
.font(.system(size: 30, weight: .bold))
.foregroundColor(.mint)
.padding()
}
.navigationTitle(Text("DetailView"))
}
}
마지막으로 C(마이 페이지)를 만들어주었다.
여기선 UserInfo 객체가 필요하기 때문에 @EnvironmentObject를 추가해주었다.
struct UserInfoView: View {
@EnvironmentObject var userInfo: UserInfo
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
HStack {
Text("닉네임: \(userInfo.nickName)")
.font(.system(size: 30))
Spacer()
Button("변경") {
userInfo.changeNickName("말하는 감자")
}
.font(.system(size: 30, weight: .semibold))
}
HStack {
Text("나이: \(userInfo.age)")
.font(.system(size: 30))
Spacer()
Button("변경") {
userInfo.changeAge(23)
}
.font(.system(size: 30, weight: .semibold))
}
Button("뒤로가기") {
dismiss()
}
.font(.system(size: 30, weight: .bold))
.foregroundColor(.mint)
.padding()
}
.padding(.init(top: 10, leading: 30, bottom: 10, trailing: 30))
.navigationTitle(Text("My Page"))
}
}
하지만 이대로 실행시키면 에러가 발생하는데
UserInfo의 ObservableObject를 찾을 수 없다.
UserInfo에 대한 View.environmentObject(_:)가 이 뷰의 상위 항목으로 누락되어있을 수 있다.
라고 하는데 무슨말일까?
Environment object로 사용할 경우 상위 뷰에 해당 모델 개체를 설정해달라고 공식문서에 나와있다.
쉽게 생각하면 우리는 UserInfo 클래스를 모든 View에서 똑같이 쓰겠다고 말해줘야 한다.
이는 인스턴스를 쓰기 전에 말해줘야 한다.
View가 생성될 때 전역적 사용을 위해 상위 뷰에서 인스턴스화 해주도록 한다.
그렇다면 상위 뷰가 대체 어디일까?!
바로 요기!
추가해주고 나면 화면 전환을 하며 값을 바꿔도 데이터가 잘 유지되는 것을 확인 할 수 있다.
🟣 StateObject
@StateObject는 간단히 말하면 View가 렌더링(다시 그려져도) 값을 유지하는 프로퍼티이다.
예시로 한번 이해해보자!
우선 count 값을 관리하는 CountViewModel을 생성해주었다.
class CountViewModel: ObservableObject {
@Published var count: Int = 0
init() {
print("ViewModel init!")
}
deinit {
print("ViewModel deinit!")
}
func addCount() {
count += 1
}
func subCount() {
count -= 1
}
}
A view 안에 B view가 있는 형태로 작성해준다.
struct A: View {
@ObservedObject var viewModel = CountViewModel()
var body: some View {
VStack {
Text("count: \(viewModel.count)")
.font(.system(size: 40))
.padding()
Button("add count") {
viewModel.addCount()
}
.font(.system(size: 30, weight: .semibold))
Button("subtract count") {
viewModel.subCount()
}
.font(.system(size: 30, weight: .semibold))
Divider()
B()
}
}
}
struct B: View {
@ObservedObject var viewModel = CountViewModel()
var body: some View {
VStack {
Text("count: \(viewModel.count)")
.font(.system(size: 40))
.padding()
Button("add count") {
viewModel.addCount()
}
.font(.system(size: 30, weight: .semibold))
.foregroundColor(.mint)
Button("subtract count") {
viewModel.subCount()
}
.font(.system(size: 30, weight: .semibold))
.foregroundColor(.mint)
}
}
}
이 상태로 실행해보면 작동이 이상하다는 것을 확인할 수 있다.
분명 위의 A view와 아래B view는 다른 view이고 같은 객체를 참조하는 것도 아닌 별개의 view인데
A view에서 count 값을 변경시키면 B view의 count 값이 0으로 초기화 된다.
어떻게 된 일일까?
초기화 되는 이유는 매우 단순하다.
A view 안에 B view가 있다
⬇️
A view 에서 count 값을 증감시키면 A view를 다시 렌더링 한다.
⬇️
A view 안의 B view도 다시 렌더링 된다.
콘솔창을 확인해보면 더 확실히 알 수 있다.
A view 에서 count 값을 증감시킬 때 마다 B view가 다시 렌더링되면서 B의 viewModel이 생성 및 소멸된다.
B의 viewModel이 생성 되었다가 바로 소멸되는 이유는 참조하는 것이 없어서
ARC가 메모리에서 자동으로 해제시켜버리는 것이 아닐까 싶다..!
아무튼! B view를 유지시키고 싶다면 @ObservedObject 대신 @StateObject 프로퍼티를 사용해주면 된다.
실행해보면 성공적으로 B view를 유지시킬 수 있다.
🗂️ 정리
@EnvironmentObject
- ObservableObject를 준수하는 객체를 전역적으로 사용하고 싶을 때 사용
- 전역적 사용을 위해 상위 뷰에서 인스턴스화 해줘야 함
@StateObject
- ObservableObject를 준수하는 객체가 View가 다시 렌더링 되어도 유지시키고 싶을 때 사용
📌 EnvironmnetObject 참고 : https://developer.apple.com/documentation/swiftui/environmentobject
📌 StateObject 참고 : https://990427.tistory.com/97?category=1001679