← All topics/Swift language features

Question 7

Property wrappers: @State, @Published, custom wrappers

What problem do property wrappers solve? How would you implement a simple custom property wrapper, and what is the projected value ($)?

Answer outline

Property wrappers let you write a property behavior once in a wrapper type and apply it with an attribute wherever you need it. Typical uses are clamping a value to a range and exposing SwiftUI state and bindings.

SwiftUI uses @State for view-local state and @Binding to hand that state to a child view. Combine uses @Published on an ObservableObject: you conform a class to it, mark the changing properties @Published, and hold the object in a view with @StateObject or @ObservedObject. Changes fan out through objectWillChange.

The @Observable macro is the modern default when your deployment target allows it. It applies to a class and has the compiler synthesize observation for its stored properties. Plain properties are tracked, so you don't add @Published.

Every wrapper exposes a wrappedValue, which is what the property reads and writes. A wrapper may also define a projectedValue, reached with the $ prefix, which exposes wrapper-specific API such as a Binding in SwiftUI. An @Observable instance gets those same $ bindings through @Bindable.

Principles

  • wrappedValue is the value the property exposes, and projectedValue ($name) is the wrapper's extra API.
  • @Observable tracks stored properties on its own, so don't add @Published to them.
  • @Bindable gives you bindings from an @Observable instance, the way $ does for @State.
  • Keep a wrapper's rules in one place, so every wrapped property gets the same validation for free.

A wrapper is a type marked @propertyWrapper with a wrappedValue property:

Minimal custom wrapper
@propertyWrapper
struct Clamped {
    private var value: Int
    let range: ClosedRange<Int>
    init(wrappedValue: Int, _ range: ClosedRange<Int>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

Once the wrapper is applied to a property, every assignment is clamped to the configured range:

Using the wrapper
struct Settings {
    @Clamped(0...100) var volume = 50
}

var settings = Settings()
settings.volume = 120
print(settings.volume) // 100

settings.volume = -10
print(settings.volume) // 0

Plain stored properties are tracked with no @Published on each field, and the view holds the model with @State:

Observable model with the Observation framework
import Observation

@Observable
final class ProfileModel {
    var name = ""
    var isLoading = false
}

// SwiftUI: @State private var model = ProfileModel()