호댕의 iOS 개발

[SwiftUI] Property Wrapper (@State, @Binding, @StateObject, @ObsevedObject, @EnvironmentObject) 본문

Software Engineering/Swift

[SwiftUI] Property Wrapper (@State, @Binding, @StateObject, @ObsevedObject, @EnvironmentObject)

호르댕댕댕 2022. 2. 26. 18:06

SwiftUI의 경우 관련된 대부분의 요소가 프로토콜과 구조체로 구성되어 있다. 

View를 만들 때 사용하는 View라는 프로퍼티도 프로토콜로 되어 있고 이를 통해 View를 정의할 때에도 대부분 구조체를 사용하게 된다. 

 

 

👥 UIKit과 SwiftUI의 차이점

UIKit을 사용할 때는 대부분 이런 것들이 Class로 구현되어 있으며 사용할 때 차이점이 존재하게 된다. 

 

1️⃣ Event-Driven VS Data-Driven

UIKit의 경우 View 스스로 자신이 어떻게 변화할지 알 수 없다. 

만약 특정 뷰의 색을 바꾼다고 생각했을 때 대개 이런 과정을 거칠 것이다. 

 

  1. UIView, 버튼 객체를 만든다. 
  2. 코드로 만든 경우 addSubView를 통해 View에 올린다. 
  3. 버튼에서 UIView의 색이 바뀌는 로직을 구현한다. 

즉, 색이 바뀌는 UIView는 자신이 어떤 색으로 바뀌는지 알 수 없고 다른 곳에서 UIView를 알고 바꿔주는 로직을 작성하게 되는 것이다. 

 

 

이에 반해 SwiftUI는 모든 애플 플랫폼에서 UI를 선언할 때 사용할 수 있는 현대적인 방법이다. 즉, SwiftUI는 선언적 UI를 갖게 되는데 그렇다면 선언적 UI는 뭘 의미하는 것일까?

 

선언적 UI는 위에서 UIKit으로 색을 바꾸는 로직을 SwiftUI로 생각해보면 좀 더 쉽게 알 수 있다. 

import SwiftUI

struct ContentView: View { 
    @State var foregroundColor: Color = .green
    
    var body: some View {
        VStack {
            Rectangle()
                .padding()
                .foregroundColor(foregroundColor)
            
            Button(action: {
                if foregroundColor == .green {
                    foregroundColor = .red
                } else {
                    foregroundColor = .green
                }
            }, label: {
                Text("색 변환")
                    .font(.largeTitle)
            })
        }
    }
}

이런 식으로 SwiftUI는 View가 본인이 어떻게 바뀌게 되는지를 직접 선언한다. View가 어떤 상태에서 어떤 그림을 그릴지 다 알고 있는 것이다. 이는 UIKit과 가장 큰 차이점으로 이것이 바로 선언적 UI이다. 

 

이런 차이점으로 UIKit은 대부분 Event-Driven Programming을 사용하지만 SwiftUI는 Data-Driven Programming을 사용한다.

(물론 SwiftUI에서도 Event-Driven Programming을 사용하긴 한다)

 

2️⃣ Class VS Structure

이외에도 Class와 구조체의 차이로 인한 차이점도 발생한다. 

기존 UIKit을 사용한 경우 ViewController, View들은 Class로 구현이 되어 있다. 하지만 SwiftUI의 경우 View를 선언할 때에도 구조체로 선언하게 된다.

 

따라서 내부에서 생성한 프로퍼티가 immutable이다. 

그래서 기존 Class에서 사용했던 것처럼 프로퍼티를 사용하게 되면 위와 같은 에러를 만나게 된다. 

 

따라서 @State 같은 Property Wrapper를 사용하게 된다. 

 

그럼 @State는 무슨 역할을 해주길래 @State를 붙여주면 컴파일 에러가 발생하지 않는 것일까?

 

 

 

Property Wrapper

일단 @State 같이 Property 앞에 @을 붙이고 있는 친구들을 Property Wrapper라고 부른다. 

 

SwiftUI에선 대부분 Struct를 사용하기 때문에 따로 mutating 함수를 만들어서 Event-Driven으로 구현하지 않는 한 내부의 값을 직접 변경하기가 쉽지 않다. 

 

따라서 사용하는 것이 Property Wrapper이다. 

이를 사용해 변경이 가능한 데이터와 상호작용하는 방법에 대해 선언할 수 있는 것이다. 

 

1️⃣ @State

그 중 위 코드 예시에서 사용한 @State에 대해 먼저 살펴보자. 

 

@State를 붙인 프로퍼티의 값이 바뀌게 되면, 원래 View의 모양은 무효화되고 View를 새로운 값을 사용하여 다시 그리게 된다. 

이를 통해 SwiftUI로 관리되는 값을 읽기만 하는 것이 아니라 쓸 수도 있게 해준다. 

 

즉, 이를 통해 View Hierarchy에서 해당 값을 사용하는 곳에 단 하나의 정보 소스로 사용되는 것이다. 

struct PlayButton: View {
    @State private var isPlaying: Bool = false

    var body: some View {
        Button(isPlaying ? "Pause" : "Play") {
            isPlaying.toggle()
        }
    }
}

state 키워드를 붙인 프로퍼티는 View의 외부에서 사용이 불가능하다. 이렇게 할 경우 SwiftUI가 제공하는 Storage Management에서 충돌이 날 수 있기 때문이다. 

따라서 이를 피하기 위해선 state 프로퍼티는 항상 private로 선언해주는 것이 좋다. 또한 값에 접근해야 하는 가장 상위 View에서만 state를 사용하는 것이 좋다. 

 

2️⃣ @Binding

만약 자식 뷰에서 이렇게 값을 수정해야 하는 경우는 어떻게 해야 할까??

이때 사용하는 것이 바로 @Binding이다. 

 

// 자식 뷰

struct PlayButton: View {
    @Binding var isPlaying: Bool

    var body: some View {
        Button(isPlaying ? "Pause" : "Play") {
            isPlaying.toggle()
        }
    }
}
// 부모 뷰

struct PlayerView: View {
    var episode: Episode
    @State private var isPlaying: Bool = false

    var body: some View {
        VStack {
            Text(episode.title)
                .foregroundStyle(isPlaying ? .primary : .secondary)
            PlayButton(isPlaying: $isPlaying) // Pass a binding.
        }
    }
}

전체 PlayerView에서 사용되는 자식 뷰 PlayButton이 부모 뷰의 isPlaying을 .toggle()을 통해 바꾸는 경우 @Binding을 붙여주는 것을 볼 수 있다. 

 

또한 부모 뷰에서 바인딩할 값을 사용할 것을 표시하기 위해 isPlaying 앞에 $를 붙이고 있다. 해당 키워드를 붙일 경우 Property Wrapper 자체를 받아 이를 변경할 수 있게 된다. 

 

3️⃣ ObservableObject

그렇다면 View Model을 따로 만들어서 값을 관리해주고 싶다면 어떻게 해야 할까? 

구조체의 경우 값을 복사해서 사용하기 때문에 Class를 사용해야 다양한 곳에서 Heap 메모리에 저장된 값을 참조해서 사용할 수 있다. 

 

이때 클래스에서만 사용 가능한 ObservableObject 프로토콜을 사용하면 이를 참조하고 있는 다른 곳에서 값이 변경되었는지를 알려주게 된다.

 

또한 ObservableObject 프로토콜을 채택한 클래스 내부에 @Published Property Wrapper를 사용해주면 이를 사용한 View가 변경될 때마다 업데이트가 된다. 

 

import Foundation  // Foundation에 combine이 import되어 있다

class Model: ObservableObject { // Combine에 ObservableObject가 정의되어 있다
    @Published var status = false
}

 

4️⃣ @EnvironmentObject

이는 현재 관찰 가능한 객체가 변경될 때마다 현재 View를 무효화하고 다시 View를 그릴 수 있도록 한다. 

만약 property를 environmentObject로 선언하는 경우 environmentObject(:)를 선언해주어야 한다. 파라미터에는 반드시 ObservableObject 프로토콜을 채택한 객체가 들어가야 한다. 

 

상위 뷰에서 .environmentObject를 해주면 상위뷰의 모든 하위 뷰에서 이를 알 수 있게 된다. 

@main
struct Experiment_SwiftUIApp: App {
    var model = Model()
    
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(model)
        }
    }
}

Model의 인스턴스를 생성하고 가장 상위  뷰인 ContentView에 .environmentObject를 해주게 되면 하위 뷰에선 이를 알고 사용할 수 있는 것이다. 

struct ContentView: View {
    var body: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 140)), GridItem(.adaptive(minimum: 140))]) {
            ToggleView()
            ToggleView()
            ToggleView()
            ToggleView()
        }
    }
}

 

즉, 싱글톤처럼 상위 뷰들의 모든 하위 뷰는 해당 값을 알고 사용할 수 있게 되는 것이다. 

struct ToggleView: View {
    @State private var isStateToggleOn: Bool = false
    @EnvironmentObject var model: Model 
    
    var body: some View {
            
        ZStack {
            RoundedRectangle(cornerRadius: 20)
                .stroke(lineWidth: 2)
                .foregroundColor(.red)
            
            VStack {
                Text(isStateToggleOn ? "State On" : "State Off")
                
                Toggle("State", isOn: $isStateToggleOn)
                    .padding()
                Toggle("Environment", isOn: $model.status)
                    .padding()
            }
        }
        .padding()
    }
}

위 코드에서도 ContentView의 하위 뷰인 ToggleView에서 @EnvironmentObject Property Wrapper를 사용하여 Model 타입의 프로퍼티를 선언하게 되면 이를 사용할 수 있게 된다. 

 

또한 해당 값의 변동이 생기면 다른 ToggleView에서도 값의 변경을 알 수 있게 된다. 

toggleView의 model.status가 Toggle을 누르며 변경되면 모든 toggleView에 전달이 되는 것이다.

(시뮬레이터에서 위에 있는 토글이 왜 저런지는 모르겠다... 그래도 전부 바뀌긴 한다)

 

5️⃣ @ObservedObject

ObservableObject 프로토콜을 채택한 객체를 구독하고 관찰이 가능한 @Published가 붙은 프로퍼티가 변경될 때마다 View를 무효화하는 Property Wrapper이다. 

 

뭔가 개인적으로 Notification Center와 유사한 것 같다는 인상을 받았다. 

이를 사용하게 되면 특정 뷰에서 모델을 주입 받아서 사용할 수 있다. 

@main
struct Experiment_SwiftUIApp: App {
    var model = Model()

    var body: some Scene {
        WindowGroup {
            ContentView(model: model)
        }
    }
}
struct ContentView: View {
    @ObservedObject var model: Model // 주입을 통해 원하는 뷰만 적용을 해주면 된다.

    var body: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 140)), GridItem(.adaptive(minimum: 140))]) {
            ToggleView(isEnvironmentOns: model)
            ToggleView(isEnvironmentOns: model)
            ToggleView(isEnvironmentOns: model)
            ToggleView(isEnvironmentOns: model)
        }
    }
}
struct ToggleView: View {
    @State private var isStateToggleOn: Bool = false
    @ObservedObject var isEnvironmentOns = Model() // ObservedObject랑 유사? 어떤 차이가 있는지 알아보자 @EnvironmentObject는 싱글톤과 유사? View의 라이프사이클에 의존.

    var body: some View {

        ZStack {
            RoundedRectangle(cornerRadius: 20)
                .stroke(lineWidth: 2)
                .foregroundColor(.red)

            VStack {
                Text(isStateToggleOn ? "State On" : "State Off")

                Toggle("State", isOn: $isStateToggleOn)
                    .padding()
                Toggle("Environment", isOn: $isEnvironmentOns.status)
                    .padding()
            }
        }
        .padding()
    }
}

6️⃣ @StateObject

@ObservedObject와 거의 유사하며 iOS 14에 새롭게 나온 Property Wrapper이다. 

하지만 View의 라이프사이클과는 관계 없이 View가 새롭게 초기화되더라도 기존의 값을 그대로 들고 있게 된다는 차이점이 존재한다. 

 

사실 위 코드 예시에선 View를 새롭게 초기화하는 일이 발생하지 않고 하나의 화면에서 보이게 되기 때문에 @StateObject와 @ObservedObject의 차이는 없다고 볼 수 있다. 

 

하지만 새로운 View를 present했다가 dismiss했다가를 하며 데이터를 그대로 유지해야 한다면 @StateObject가 유용할 것 같다. 

Comments