If you've used SwiftUI, you've definitely encountered property wrappers like @State
, @Binding
, and @ObservedObject
. But have you ever wondered how they work, or how to create your own? In this article, we'll dive deep into property wrappers and learn how to make SwiftUI views react to our custom implementations.
What is a Property Wrapper?
A property wrapper is a Swift language feature that adds a layer of separation between code that manages how a property is stored and the code that defines a property. Introduced in Swift 5.1, property wrappers allow you to write reusable property management logic once and apply it to multiple properties.
Basic Property Wrapper Example
Here's a simple property wrapper that ensures a value stays within bounds:
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
var wrappedValue: Value {
get { value }
set { value = min(max(range.lowerBound, newValue), range.upperBound) }
}
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(range.lowerBound, wrappedValue), range.upperBound)
}
}
// Usage
struct Settings {
@Clamped(0...100) var volume: Int = 50
}
Key Components of a Property Wrapper
-
The
@propertyWrapper
attribute - Marks a type as a property wrapper -
wrappedValue
property - The actual value being wrapped -
init(wrappedValue:)
- Initializer that accepts the initial value -
projectedValue
(optional) - Provides additional functionality, accessed with$
prefix
What is DynamicProperty?
DynamicProperty
is a protocol in SwiftUI that allows property wrappers to participate in the view update cycle. This is the secret sauce that makes property wrappers like @State
and @ObservedObject
trigger view updates.
How DynamicProperty Works
When a property wrapper conforms to DynamicProperty
, SwiftUI does the following:
- Tracks the property wrapper as part of the view's state
- Calls the
update()
method before each view render - Automatically invalidates and re-renders the view when wrapped values change
Here's what the protocol looks like:
protocol DynamicProperty {
mutating func update()
}
The update()
method is called by SwiftUI before rendering the view body. You can implement custom logic here, but often you don't need to provide an implementation as the default behavior is sufficient.
Creating a Custom Property Wrapper for SwiftUI
Now let's combine both concepts to create a property wrapper that makes SwiftUI views react to changes.
Step 1: Basic Structure
@propertyWrapper
struct MyState<Value>: DynamicProperty {
@State private var value: Value
var wrappedValue: Value {
get { value }
nonmutating set { value = newValue }
}
init(wrappedValue: Value) {
_value = State(wrappedValue: wrappedValue)
}
}
Step 2: Understanding the Components
DynamicProperty conformance
By conforming to DynamicProperty
, we tell SwiftUI to track this wrapper as part of the view's state system.
Internal @State property
We use @State
internally because it already knows how to trigger view updates. This is composition at work.
nonmutating setter
The nonmutating
keyword is crucial. SwiftUI views are structs, which are value types. Normally, you can't mutate properties of a struct without marking the method as mutating
. However, @State
and similar wrappers use nonmutating set
to allow mutations without requiring the entire view to be marked as mutating
.
Step 3: Using the Custom Property Wrapper
struct ContentView: View {
@MyState var count = 0
var body: some View {
VStack(spacing: 20) {
Text("Count: \(count)")
.font(.largeTitle)
Button("Increment") {
count += 1
}
}
}
}
When you tap the button, the count changes, and SwiftUI automatically re-renders the view. This happens because:
-
MyState
conforms toDynamicProperty
- The internal
@State
property publishes changes - SwiftUI detects the change and invalidates the view
- The view body is called again with the updated value
Advanced Example: Custom Property Wrapper with Binding
Let's create a more sophisticated wrapper that also provides a Binding
:
@propertyWrapper
struct Validated<Value>: DynamicProperty {
@State private var value: Value
private let validator: (Value) -> Bool
var wrappedValue: Value {
get { value }
nonmutating set {
if validator(newValue) {
value = newValue
}
}
}
var projectedValue: Binding<Value> {
Binding(
get: { value },
set: { newValue in
if validator(newValue) {
value = newValue
}
}
)
}
init(wrappedValue: Value, validator: @escaping (Value) -> Bool) {
self.validator = validator
_value = State(wrappedValue: wrappedValue)
}
}
// Usage
struct FormView: View {
@Validated(validator: { $0.count >= 3 }) var username = ""
var body: some View {
VStack {
TextField("Username (min 3 chars)", text: $username)
Text("Username: \(username)")
}
}
}
Alternative Approaches
You can also use @StateObject
with ObservableObject
for more complex state management:
@propertyWrapper
struct Reactive<Value>: DynamicProperty {
@StateObject private var storage: Storage<Value>
var wrappedValue: Value {
get { storage.value }
nonmutating set { storage.value = newValue }
}
init(wrappedValue: Value) {
_storage = StateObject(wrappedValue: Storage(value: wrappedValue))
}
private class Storage<T>: ObservableObject {
@Published var value: T
init(value: T) {
self.value = value
}
}
}
This approach is useful when you need more control over observation and publishing changes.
Key Takeaways
Property wrappers encapsulate reusable property logic and are marked with
@propertyWrapper
DynamicProperty is the bridge between your property wrapper and SwiftUI's update system
Composition over reimplementation - Use existing wrappers like
@State
internally rather than rebuilding from scratchnonmutating setters allow property modification without requiring mutable view contexts
Projected values (accessed with
$
) provide additional functionality like bindings
Conclusion
While SwiftUI's built-in property wrappers cover most use cases, knowing how to create your own gives you the flexibility to build exactly what your app needs.
Top comments (1)
Dynamic property
Tracks the property wrapper as part of the view's state
Calls the update() method before each view render
Automatically invalidates and re-renders the view when wrapped values change