SwiftUI를 맛깔나게 소화하는 킥 : Property Wrapper

Property Wrapper 뿌시고 SwiftUI 마스터하기

Lee Di
DelightRoom
16 min readJan 17, 2023

--

사진: UnsplashRyan Concepcion

안녕하세요 Delightroom iOS 개발자 리디입니다.

다들 스유(SwiftUI)하고 계신가요?

SwiftUI는 높은 생산성과 유지보수 비용의 절감이라는 크고 아름다운 효용감을 선사해줍니다. 어느덧 SwiftUI도 안정기에 들어갔고, 많은 앱들에서 SwiftUI를 통한 화면을 그려보고자 하지만 등가교환의 법칙으로 높은 러닝커브를 자랑합니다. 특히 UIKit에 익숙해져있던 개발자에게 SwiftUI를 더욱 아름답게 쓸 수 있게 도와주는 Property Wrapper는 몹시 낯설게 느껴지기도 합니다. 그래서 오늘은 강력한 녀석들을 뿌셔보고자 합니다. 그럼 다같이 레츠기릿-!

@State

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)

  • @State는 view 내부에 속해야 할 때에만 사용 되어야 합니다.
  • @State는 view 내부에서 초기화해야 하고, 다른 객체로 부터 @State 를 받는 것을 권장하지 않습니다. (사실 받을 수는 있지만, 어차피 유지되지 않기 때문에 굳이 받을 이유는 없습니다. 🤔) 이런 이유로 @State는 접근 제어자를 private 으로 관리하기를 권장합니다. 외부에서 해당 property를 수정해서는 안되기 때문입니다.
  • SwiftUI는 @State property’s value를 내부적으로 저장합니다. 이후 @State변경사항에 대하여 view 는 fresh render 됩니다. 저장된 value는 fresh render 하는 동안에도 유지가 됩니다.
  • 데이터를 전달할 때는 Binding<T>로 @State property 를 자식 view에 전달할 수 있습니다. 이 경우 자식 뷰에서도 수정이 가능합니다. (@Binding에서 추가로 다룰 예정입니다.)

예시 코드

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

var body: some View {
Button(isPlaying ? "Pause" : "Play") {
isPlaying.toggle()
}
}
}
  • PlayButton view는 isPlaying state property를 초기화 합니다. Button을 누르면 isPlaying은 toggle되며, 변화된 값으로 Button은 rerender 됩니다.
  • isPlaying의 value에 직접 접근하는 것 처럼 보이지만, 사실은 @State속성으로 생성된 속성 변수를 참조하는 상태입니다.

@Binding

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)

  • 다른 view로부터 property들을 받을 때에 사용 됩니다. @Binding을 받은 view는 외부 소스(ex) 부모 뷰)로부터 생성된 property 변경 사항에 대하여 fresh render 됩니다. 이 property는 수정 역시 가능합니다. @Binding을 수정하게 되면, 제공한 뷰의 property 도 같이 업데이트 됩니다. (@State를 넘길 때는 $ 를 앞에 붙혀줘야합니다.)
  • view가 fresh render 할 때 @Binding은 유지 되지 않습니다. 외부에서부터 전달되기 때문에 굳이 유지할 필요가 없기 때문입니다. (@State는 위에서 언급한 것 처럼 유지 됩니다.)

예시 코드

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.
}
}
}

struct PlayButton: View {
@Binding var isPlaying: Bool

var body: some View {
Button(isPlaying ? "Pause" : "Play") {
isPlaying.toggle()
}
}
}
  • PlayerView에서는 isPlaying @State를 초기화해서 들고 있습니다. PlayButton은 @Binding으로 선언된 isPlaying property를 갖고 있으며, 이를 PlayerView 에서 초기화 시점에 주입 받고 있습니다.
  • PlayButton 은 버튼 클릭에 따라 @Binding된 isPlaying 을 toggle 하고, 이 변화는 PlayerView의 Text와 PlayButton 모두에 반영됩니다.

@StateObject

Only available in iOS 14+, iPadOS 14+, watchOS 7+, macOS 10.16+ etc.

  • 아마 이름을 보고 이미 예측하신 분들도 있을 것 같은데요. @StateObject는 ObservableObject 을 채택한 객체에 적용되는 것을 제외 하고는 @State와 비슷한 이유로 사용 합니다. 쉽게 말해서 Object의 State버젼! [ObservableObject는 @Published 속성이 변경되기 전에 변경된 값을 내보냅니다.(ObjectWillChange).]
  • State와 가장 큰 차이점이라면 StateObject는 언제나 ReferenceType 이라는 점이죠! ObservableObject 를 채택한 Object 내부의 @Published 는 속성이 바뀔 때마다 SwiftUI 에 알려 줍니다. 그리고 SwiftUI는 fresh render를 할 때 처음 생성된 @StateObject property를 유지합니다.
  • @StateObject 를 사용하는 view는 내부적으로 ObservableObject 를 만듭니다. 이 과정에서 SwiftUI는 @StateObject와 관련된 instance를 따로 설정하고, View가 초기화 될 때 다시 사용합니다. @StateObject는 한번만 초기화 되고 reuse되기 때문에, @StateObject로 마크된 instance 나 property는 새로 얻을 수 없습니다. 또한 fresh render되더라도, 처음 할당된 instance로 유지 됩니다.

예시 코드

class DataProvider: ObservableObject {
@Published var count: Int = 0

func increase() {
count += 1
}
}

struct CountCalculatorView: View {

@StateObject private var provider DataProvider = .init()

var body: some View {
VStack {
Text("CountCalculatorView: \(provider.count)")

Button {
provider.increase()
} label: {
Text("+")
}
}
}
}
  • CountCalculatorView가 들고 있는 @StateObject가 ObservableObject를 채택한 DataProvider 라는 것 이외에는 @State와 다르지 않습니다.

@ObservedObject

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)

  • @State@Binding이 있다면 @StateObject에는 @ObservedObject가 있습니다. 다만 @State를 넘길 때와는 다르게 $를 붙힐 필요는 없습니다.
  • 사용되는 뷰에 의해 ‘생성 또는 소유’되지 않은 ObservableObject instances를 wrap하는 데에 사용됩니다. @StateObject와 동일한 유형의 객체에 적용이 되며, View가 자체 ObservedObject instance를 만들지 않는다는 점을 제외하고 비슷합니다. 때문에 fresh render시에 별도로 유지되지 않습니다. (유지가 필요하다면 @StateObject 사용하시면 됩니다.) SwiftUI는 부모뷰가 ObservedObject를 전달해 준 것을 알고 있습니다.
  • 쉽게 정리해보면 다음과 같습니다.
    - 부모가 property을 소유 → @StateObject
    - 부모가 property을 소유하지 않음 → @ObservedObject

예시 코드

class DataProvider: ObservableObject {
@Published var count: Int = 0

func increase() {
count += 1
}
}

struct CountCalculatorView: View {

@StateObject private var provider: DataProvider = .init()

var body: some View {
VStack {
ObservedObjectView(provider: provider)

Button {
provider.increase()
} label: {
Text("+")
}
}
}
}

struct ObservedObjectView: View {

@ObservedObject var provider: DataProvider

var body: some View {
Text("ContentView: \(provider.count)")
}
}

@EnvironmentObject

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)

  • 초기화 시점에 넘겨주고 싶지 않은 경우 @EnvironmentObject를 사용합니다. (자식 view, App Scene등에 의존성을 만들어 줄 수 있습니다.) @EnvironmentObject 는 기본적으로 ObservableObject 를 confirm하고 있습니다. 그 말은 @EnvironmentObject의 속성 중 하나가 변경될 경우 View가 fresh render된다는 것입니다. (@ObservedObject와 동일)
  • 한가지 주의할 점은 @EnvironmentObject는 값이 없는 경우 접근하게 되면 crash가 날 수 있다는 것입니다.

예시 코드

struct CountCalculatorView: View {

@StateObject private var provider: DataProvider = .init()

var body: some View {
VStack {

EnvironmentObjectView()
.environmentObject(provider)

Button {
provider.increase()
} label: {
Text("+")
}
}
}
}

struct EnvironmentObjectView: View {

@EnvironmentObject var provider: DataProvider

var body: some View {
Text("ContentView: \(provider.count)")
SubView()
}

}

struct SubView: View {

@EnvironmentObject var provider: DataProvider

var body: some View {
Text("ContentView: \(provider.count)")
}
}
  • EnvironmentObjectView는 @EnvironmentObject가 사용된 DataProvider를 들고 있습니다. 때문에 CountCalculatorView에서 EnvironmentObjectView를 초기화 할 때에는 .environmentObject라는 별도의 함수를 통해 @StateObject를 넘겨주고 있습니다. 하지만 EnvironmentObjectView에서 SubView를 생성하는 경우 SubView내에서 @EnvironmentObject를 선언해놓기만 하면, 별도로 초기화 이후 주입해줄 필요는 없습니다.
  • 그럼 Object를 @Binding하는 것과 @EnvironmentObject를 사용하는 것은 궁극적으로 어떤 점이 다를까요? WWDC에 소개된 이미지를 보시면 쉽게 이해가 가실 듯 합니다~!.

ref: https://developer.apple.com/videos/play/wwdc2019/226/

@Environment

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)

  • @EnvironmentObject와 비슷하지만, view 의 환경변수를 읽는 데 사용됩니다. Environment가 변경되면, view는 fresh render됩니다. environment property를 읽기 전용의 특성을 가지며, set, modify 하는 데에는 쓸 수 없습니다. @Environment를 수정하고 싶다면, .environment view modifier를 사용해야 합니다.

예제 코드

  • presentation Mode 에 따라 다른 화면을 그리고 싶은 경우에는 다음과 같은 방식으로 활용이 가능합니다.
struct ContentView : View {
@Environment(\.presentationMode) private var presentationMode

var body: some View {
if presentationMode.isPresented {
return Text("닫기")
} else {
return Text("뒤로 가기")
}
}
}
  • 다음의 방식으로 view의 environment에 assign 할 수도 있습니다.
ContentView()
.environment(\.managedObjectContext, Persistence.shared.viewContext)
  • 이 뿐 아니라 custom property를 view의 environment에 추가할 수도 있습니다.
    // The type we want to use for the custom environment value
enum AppStyle {
case classic, modern
}

// Our environment key
private struct AppStyleKey: EnvironmentKey {
static let defaultValue = AppStyle.modern
}

// Register the key on SwiftUI's EnvironmentValues
extension EnvironmentValues {
var appStyle: AppStyle {
get { self[AppStyleKey.self] }
set { self[AppStyleKey.self] = newValue }
}
}

// Example usage
@main
struct PropertyWrappersApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appStyle, .classic)
}
}
}

@AppStorage

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)

  • UserDefaults에 대한 app-wide wrapper 입니다. UserDefaults의 값이 변화하면, view는 fresh render됩니다. 사용법은 @AppStorage 뒤에 keyname을 적어주면 됩니다. 코드로 보는 편이 더 이해가 편할 듯 하여 바로 예제코드로 넘어가보겠습니다.

예제 코드

struct ContentView: View {
@AppStorage("lastTap") var lastTap: Double?

var dateString: String {
if let timestamp = lastTap {
return Date(timeIntervalSince1970: timestamp).formatted()
} else {
return "Never"
}
}

var body: some View {
Text("Button was last clicked on \(dateString)")

Button("Click me") {
lastTap = Date().timeIntervalSince1970
}
}
}

결론

한번 Property Wrapper를 쫙 정리해봤는데요. 어떠셨나요?

지금 바로 Xcode를 키고, SwiftUI File을 만들어서 테스트 해보시면 더 쉽게 이해하시리라 믿어 의심치 않습니다 :) 여러분의 SwiftUI 뿌시기에 한걸음이라도 도움이 되었다면 저도 아주 만족할 수 있을 것 같습니다 😎

그럼 다들 행복한 스유하세요~~!! 🙏

ref: https://www.kodeco.com/11781349-understanding-data-flow-in-swiftui

앞으로도 종종 재미난 개발 이야기로 찾아 뵙겠습니다~!

긴 글 읽어주셔서 감사드립니다 👨‍💻

⏰ 딜라이트룸에서 알라미와 함께 아침을 바꿀 분들을 모십니다 🙌
👉 딜라이트룸에 어떤 포지션이 있는지 궁금해요(+ 입사 축하금 100만원💸)👉 딜라이트룸 미디엄 팔로우하기 (카테고리 섹션 오른쪽의 ‘Follow’ 버튼 클릭)
👉 딜라이트룸 일상을 엿볼 수 있는 인스타그램 구경하기

ref:
- SwiftUI Framework 주석들
- https://swiftuipropertywrappers.com/#stateobject
- https://seons-dev.tistory.com/entry/SwiftUI-Environment-프로퍼티-래퍼

--

--