SwiftUI의 Property Wrapper 훑어보기

Property Wrapper 종류

  • State
  • Binding
  • ObservableObject
  • EnvironmentObject
  • StateObject
  • AppStorage

State

우선 아주 간단한 Text만 있는 View를 그려보자.

struct ContentView: View {
    private var myName = ""
    
    var body: some View {
        VStack {
            Text("My name is '\(myName)'")
        }
    }
}

이제 이 ViewButton을 추가하고, Button을 누르면 myName의 값을 바꾸도록 해보자.

VStack {
    Text("My name is '\(myName)'")
    
    Button("Change Name") {
        myName = "leo"
    }
}

이렇게 Vstack 안에 Button을 추가했고 myName을 바꾸려고 했다. 그런데 빌드가 될까? 역시 빌드가 안된다. 그 이유는 우선 ContentView는 구조체다. Swift에서 구조체는 구조체 안에서 자신의 값을 바꿀 수 없다. 구조체가 자신의 값을 바꾸기 위해서는 mutating 키워드가 필요한데, mutating 키워드는 Computed Property에서 사용이 불가능하다. 그리고 body를 보면 bodyComputed Property 임을 알 수 있다. 따라서 우리는 원래대로라면 myName의 값을 바꿀 수 없는 것이고, 이를 해결하기 위해서 SwiftUI에서는 여러 Property Wrapper가 등장했고 그 중 하나가 바로 State이다.

State는 값 자체가 아니고 그 안에 들어있는 값을 일고 쓰는 수단일 뿐이다. SwiftUI에서는 State가 변경되면 body를 다시 계산하면서 뷰가 갱신되게 된다. 즉 State 어노테이션은 SwiftUI에서 값을 읽고 쓸 수 있게 해주며 뷰를 갱신할 수 있게 한다.

이제 이렇게 바꿔보자.

struct ContentView: View {
    @State private var myName = ""
    
    var body: some View {
        VStack {
            Text("My name is '\(myName)'")
            
            Button("Change Name") {
                myName = "leo"
            }
        }
    }
}

이제 Button을 누르면 Text가 잘 바뀌는 것을 알 수 있다.

Binding

@Binding Property Wrapper는 State와 똑같지만 외부로부터 주입받는 경우에 사용한다. 그래서 사용하는 내부에서 초기화하지 않고 타입만 정의해서 사용한다.

struct ContentView: View {
    @State private var myName = ""
    
    var body: some View {
        NavigationView(content: {
            VStack {
                NavigationLink(destination: ContentSubview(myName: myName)) {
                    Text("Move To ContentSubview")
                }
                
                Spacer()
                
                Text("My name is '\(myName)'")
                
                Button("Change Name") {
                    myName = "leo"
                }
            }
        })
    }
}

struct ContentSubview: View {
    @Binding var myName: String // 타입만 정의
    
    var body: some View {
        VStack {
            Text("Hello, \(myName)")
            
            Button("Reset Name") {
                myName = ""
            }
        }
    }
}

이제 ContentSubview에서의 일어난 myName의 변화는 ContentView에도 반영된다. 같은 소스를 공유하기 때문이다.

ObservableObject

@State@Binding프로퍼티 래퍼는 Value Type에서만 동작한다. 그렇다면 클래스와 같은 Reference Type은 어떻게 해야할까? 클래스와 같은 Reference Type은 ObservableObject를 채택해야 한다.

class MyInfo: ObservableObject {
    var myName = ""
    var myAge = 0
    var address = ""
}

struct ContentView: View {
    private var myInfo = MyInfo()
    
    var body: some View {
        NavigationView(content: {
            VStack {
                Text("My name is '\(myInfo.myName)'")
                
                Button("Change Name") {
                    myInfo.myName = "leo"
                }
            }
        })
    }
}

이제 myName 대신 MyInfo라는 클래스를 정의하고 여기에 필요한 Property를 만들었다. 아까처럼 Button을 누르면 myInfomyName을 변경한다. 그러면 Text가 바뀔까? 당연히 안바뀐다. 아까 @State 처럼 특정 어노테이션이 있어야 body를 다시 계산하고 뷰를 갱신할 수 있는데, 여기에는 어디에도 뷰를 다시 그릴 포인트가 없다. 클래스는 ObservableObject를 채택하고 있는데, 이제 여기서 Property가 바뀔때마다 뷰를 갱신할 수 있도록 Published 어노테이션을 추가해보자. 이 어노테이션은 말 그대로 변화가 생길때마다 이 변화를 발행한다. PublishedView를 갱신하기 때문에 UI와 관련된 Property에 한해서 써야 한다.

class MyInfo: ObservableObject {
    @Published var myName = ""
    var myAge = 0
    var address = ""
}

struct ContentView: View {
    var myInfo = MyInfo()
    
    var body: some View {
        NavigationView(content: {
            VStack {
                Text("My name is '\(myInfo.myName)'")
                
                Button("Change Name") {
                    myInfo.myName = "leo"
                }
            }
        })
    }
}

또 모든 변화에 Publish하지 않고 특정 조건에 따라 Publish 할 수도 있다.

class MyInfo: ObservableObject {
    var myName = "" {
        willSet {
            if newValue == "leo" { // 이런식으로 특정 조건에만 변화를 Publish 할 수도 있다.
                objectWillChange.send()
            }
        }
    }
    var myAge = 0
    var address = ""
}

이대로 끝일까? 역시나 No. MyInfoObservableObject를 채택하고 있고, 여기서 Published된 값을 통해 뷰를 갱신하고 싶다면 사용하는 쪽에서 선언할 때 @State@Binding과 같은 Property Wrapper를 사용해야 한다.

StateObject & ObservedObject

아까 말했듯 @State@Binding은 Value Type과 함께 동작한다. 우리는 위에서 Reference Type인 클래스를 관찰 가능한 ObservableObject로 만들었으니 이와 함께 동작하기 위해 @StateObject@ObservableObject가 필요하다. 둘의 차이는 @State가 Reference Type에서 @StateObject가 되며 @Binding@ObservedObject가 된다. 그래서 @Binding과 마찬가지로 ObservedObject는 초기값을 제공할 필요가 없다.

class MyInfo: ObservableObject {
    @Published var myName = ""
    var myAge = 0
    var address = ""
}

struct ContentView: View {
    @StateObject private var myInfo = MyInfo()
    
    var body: some View {
        NavigationView(content: {
            VStack {
                Text("My name is '\(myInfo.myName)'")
                
                Button("Change Name") {
                    myInfo.myName = "leo"
                }
            }
        })
    }
}

struct ContentSubView: View {
    @ObservedObject var myInfo: MyInfo // 타입만 정의

    var body: some View {
        Text(myInfo.myName)
    }
}

EnvironmentObject

EnvironmentObject는 싱글톤 객체로 이해하면 쉽다. 대신 View에서 생성할 수 없으며 @ObservedObject 처럼 외부에서 주입해야만 한다. 바로 코드를 보자.

class MyInfo: ObservableObject {
    @Published var myName = ""
    var myAge = 0
    var address = ""
}

struct ContentView: View {
    @EnvironmentObject var myInfo: MyInfo
    
    var body: some View {
        NavigationView(content: {
            VStack {
                Text("My name is '\(myInfo.myName)'")
                
                Button("Change Name") {
                    myInfo.myName = "leo"
                }
            }
        })
    }
}

구조 자체는 아까와 똑같은데 myInfoEnvironmentObject 어노테이션으로 작성됐고 초기화되지 않았다. 이 myInfo는 앱의 전역에서 쓰이는 객체라고 가정하고 특정 화면에서 MyInfo에 접근하고 싶다면 위와 같이 작성하면 된다. 그런데 일반적인 싱글톤 객체의 경우 싱글톤 객체에 접근하는 순간 초기화가 일어나는데, 아까 말했듯 EnvironmentObjectView 안에서 초기화될 수 없다. 그럼 도대체 언제 초기화되어서 메모리에 올라가느냐? 바로 Root View를 초기화할때다.

SceneDelegate를 가보자

// SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

    // Create the SwiftUI view that provides the window contents.
    let contentView = ContentView()

    // Use a UIHostingController as window root view controller.
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView: contentView)
        self.window = window
        window.makeKeyAndVisible()
    }
}

여기서 ContentView를 초기화활때 .environmentObject(_:)를 호출해서 EnvironmentObject 들을 초기화해야 한다.

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

    // Create the SwiftUI view that provides the window contents.
    let contentView = ContentView()
        .environmentObject(MyInfo())

    // Use a UIHostingController as window root view controller.
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView: contentView)
        self.window = window
        window.makeKeyAndVisible()
    }
}

또 여러개의 EnvironmentObject가 필요하다면 체이닝을 통해 계속 추가할 수 있다.

AppStorage

AppStorageUserDefaults를 래핑한 것이므로 쉽다. 바로 코드로 넘어가자.

struct ContentView: View {
    @AppStorage("value") var myName = ""
    
    var body: some View {
        NavigationView(content: {
            VStack {
                Text("My name is \(myName)")
                
                Button("Change Name") {
                    myName = "leo"
                }
            }
        })
    }
}

AppStore 어노테이션을 추가하고 바로 다음에 오는 "value"Key값이 된다. 그리고 var myName은 변수의 이름이기 때문에 키 값과 동일할 필요는 없다. 앱을 실행하고 Button을 누른다음에 앱을 종료하고 다시 실행하면 TextMy name is leo로 나오는 것을 확인할 수 있다.