The Secret Sauce for Mastering SwiftUI: Property Wrappers

Crushing Property Wrappers to Master SwiftUI
DelightRoom's avatar
Jan 17, 2023
The Secret Sauce for Mastering SwiftUI: Property Wrappers

Crushing Property Wrappers to Master SwiftUI

Photo: Ryan Concepcion on Unsplash

Hello, this is Lidi, an iOS engineer at DelightRoom.

Is everyone using SwiftUI?

SwiftUI gifts us the grand, beautiful utility of high productivity and reduced maintenance costs. SwiftUI has now entered a stable phase, and many apps are trying to draw their screens with it, but by the law of equivalent exchange, it boasts a steep learning curve. In particular, for engineers accustomed to UIKit, Property Wrappers—which help you use SwiftUI more elegantly—can feel awfully unfamiliar. So today, I’m going to crush these powerful little guys. Let's get it-!

@State

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

  • @State should only be used when it belongs strictly inside a view.

  • @State must be initialized within the view, and it's not recommended to receive @State from another object. (You technically can receive it, but there's no real reason to since it won't be maintained anyway. 🤔) Because of this, it is recommended to manage @State with a private access modifier. This is because the property shouldn't be modified from the outside.

  • SwiftUI internally stores the @State property’s value. Afterward, the view is freshly rendered in response to changes in @State. The stored value is maintained even during this fresh render.

  • When passing data, you can pass a @State property to a child view as Binding<T>. In this case, it can also be modified in the child view. (We will cover this further in @Binding.)

Example Code

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

     var body: some View {
         Button(isPlaying ? "Pause" : "Play") {
             isPlaying.toggle()
         }
     }
 }
  • The PlayButton view initializes the isPlaying state property. When the Button is pressed, isPlaying is toggled, and the Button is re-rendered with the changed value.

  • It looks like you are directly accessing the value of isPlaying, but you are actually referencing a property variable created by the @State attribute.

@Binding

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

  • Used when receiving properties from another view. A view that receives a @Binding is freshly rendered for property changes generated from an external source (e.g., a parent view). This property can also be modified. If you modify the @Binding, the property of the view that provided it gets updated as well. (When passing @State, you must prefix it with a $.)

  • When the view freshly renders, the @Binding is not maintained. Because it is passed from the outside, there is no need to maintain it. (@State is maintained, as mentioned above.)

Example Code

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 initializes and holds the isPlaying @State. PlayButton has an isPlaying property declared with @Binding, which is injected at the time of initialization from PlayerView.

  • PlayButton toggles the @Binding isPlaying upon a button click, and this change is reflected in both the Text and PlayButton of PlayerView.

@StateObject

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

  • Some of you might have already guessed from the name. @StateObject is used for similar reasons as @State, except that it is applied to objects that adopt ObservableObject. Simply put, it's the State version of an Object! [ObservableObject emits the changed value before the @Published attribute actually changes (ObjectWillChange).]

  • The biggest difference from State is that StateObject is always a ReferenceType! The @Published inside an Object that adopts ObservableObject notifies SwiftUI whenever the attribute changes. And SwiftUI maintains the initially created @StateObject property when it freshly renders.

  • A view using @StateObject internally creates an ObservableObject. During this process, SwiftUI separately configures the instance associated with the @StateObject and reuses it when the View is initialized. Because @StateObject is initialized only once and reused, instances or properties marked with @StateObject cannot be acquired anew. Additionally, even if freshly rendered, it is maintained as the initially allocated instance.

Example Code

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("+")
            }
        }
    }
}
  • Other than the fact that the @StateObject held by CountCalculatorView is a DataProvider that adopts ObservableObject, it is no different from @State.

@ObservedObject

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

  • Just as @State has @Binding, @StateObject has @ObservedObject. However, unlike when passing @State, you don't need to attach a $.

  • It is used to wrap ObservableObject instances that are not 'created or owned' by the view in which they are used. It applies to the same type of objects as @StateObject, and it is similar except that the View does not create its own ObservedObject instance. Therefore, it is not separately maintained during a fresh render. (If you need to maintain it, you should use @StateObject.) SwiftUI knows that the parent view passed the ObservedObject.

  • To simply summarize:
    - Parent owns the property → @StateObject
    - Parent does not own the property → @ObservedObject

Example Code

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, *)

  • When you don't want to pass something at initialization, you use @EnvironmentObject. (You can create dependencies for child views, App Scenes, etc.) @EnvironmentObject basically conforms to ObservableObject. This means that if one of the properties of the @EnvironmentObject changes, the View gets a fresh render. (Same as @ObservedObject)

  • One thing to watch out for is that if you access an @EnvironmentObject when it doesn't have a value, it can cause a crash.

Example Code

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 holds the DataProvider where @EnvironmentObject is used. Thus, when initializing EnvironmentObjectView in CountCalculatorView, it passes the @StateObject through a separate function called .environmentObject. However, if SubView is created from EnvironmentObjectView, as long as @EnvironmentObject is declared within SubView, there is no need to inject it separately after initialization.

  • So what is the ultimate difference between using an @EnvironmentObject and doing @Binding on an Object? Looking at the image introduced at WWDC should make it easy to understand~!.

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

@Environment

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

  • It is similar to @EnvironmentObject, but is used to read the environment variables of a view. When the Environment changes, the view is freshly rendered. The environment property is read-only and cannot be used to set or modify. If you want to modify the @Environment, you have to use the .environment view modifier.

Example Code

  • If you want to draw a different screen depending on the presentation Mode, you can utilize it in the following way.

struct ContentView : View {
    @Environment(\.presentationMode) private var presentationMode
    
    var body: some View {
        if presentationMode.isPresented {
            return Text("닫기")
        } else {
            return Text("뒤로 가기")
        }
    }
}
  • You can also assign to a view's environment in the following way.

ContentView()
    .environment(\.managedObjectContext, Persistence.shared.viewContext)
  • Beyond this, you can also add custom properties to a view's 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, *)

  • It is an app-wide wrapper for UserDefaults. When the value in UserDefaults changes, the view is freshly rendered. To use it, just write the keyname after @AppStorage. It might be easier to understand by looking at the code, so let's jump straight into the example code.

Example Code

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
        }
    }
}
Share article