iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Property Wrappers

Property wrappers let you attach reusable behaviour to a stored property — lazy initialization, validation, persistence, dependency injection, SwiftUI state — with a single @WrapperName annotation. Once you understand the pattern, the entire SwiftUI mental model clicks.

@State, @Binding, custom wrappers

EXAMPLE
import Foundation
import SwiftUI
import Combine

// 1) The shape — a property wrapper is a generic type marked @propertyWrapper
//    with a wrappedValue.
@propertyWrapper
struct Clamped<Value: Comparable> {
    private var value: Value
    private let range: ClosedRange<Value>

    init(wrappedValue: Value, _ range: ClosedRange<Value>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Value {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

struct Volume {
    @Clamped(0...100) var level: Int = 50
}

var v = Volume()
v.level = 150
print(v.level)        // 100 (clamped)
v.level = -10
print(v.level)        // 0  (clamped)

// 2) projectedValue — the '$' companion
@propertyWrapper
struct Logged<Value> {
    private var stored: Value
    private(set) var history: [Value] = []

    init(wrappedValue: Value) { self.stored = wrappedValue }

    var wrappedValue: Value {
        get { stored }
        set { stored = newValue; history.append(newValue) }
    }

    var projectedValue: [Value] { history }
}

struct Counter {
    @Logged var count: Int = 0
}

var c = Counter()
c.count = 1; c.count = 2; c.count = 3
print(c.count)        // 3
print(c.$count)       // [1, 2, 3]   — accessed via the '$' projection

// 3) SwiftUI uses property wrappers everywhere
//   @State              — local, value-type, view-owned state
//   @Binding            — two-way reference to someone else's @State
//   @StateObject        — view-owned ObservableObject (created once)
//   @ObservedObject     — externally-owned ObservableObject
//   @EnvironmentObject  — ObservableObject injected via environment
//   @Environment        — read system values (colorScheme, locale, dismiss)
//   @AppStorage         — UserDefaults-backed property
//   @SceneStorage       — per-scene UI state restoration
//   @FocusState         — keyboard focus tracking
//   @Published          — Combine-friendly stored property on ObservableObject

struct CounterView: View {
    @State private var count = 0
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("+1") { count += 1 }
            ChildView(value: $count)             // pass binding
        }
    }
}

struct ChildView: View {
    @Binding var value: Int
    var body: some View {
        Button("reset") { value = 0 }            // writes through the binding
    }
}

// 4) ObservableObject + @Published — shared state
final class CartModel: ObservableObject {
    @Published var items: [String] = []
    func add(_ item: String) { items.append(item) }
}

struct CartView: View {
    @StateObject private var model = CartModel()    // owned here
    var body: some View {
        VStack {
            ForEach(model.items, id: \.self, content: Text.init)
            Button("Add coffee") { model.add("coffee") }
        }
    }
}

struct CartCounterView: View {
    @ObservedObject var model: CartModel              // injected from parent
    var body: some View { Text("\(model.items.count) items") }
}

// 5) @AppStorage — persistent settings
struct SettingsView: View {
    @AppStorage("theme")     var theme:     String = "system"
    @AppStorage("fontScale") var fontScale: Double = 1.0
    var body: some View {
        Form {
            Picker("Theme", selection: $theme) {
                Text("System").tag("system"); Text("Light").tag("light"); Text("Dark").tag("dark")
            }
            Slider(value: $fontScale, in: 0.8...1.5)
        }
    }
}
// Writes go to UserDefaults; the view automatically re-renders when changed.

// 6) Custom — a tiny dependency-injection wrapper
@propertyWrapper
struct Injected<T> {
    private var resolved: T?
    var wrappedValue: T {
        mutating get {
            if let r = resolved { return r }
            let value: T = Container.resolve()
            resolved = value
            return value
        }
    }
}

enum Container {
    static var network: NetworkService = LiveNetworkService()
    static var auth:    AuthService    = LiveAuthService()
    static func resolve<T>() -> T {
        if T.self == NetworkService.self { return network as! T }
        if T.self == AuthService.self    { return auth    as! T }
        fatalError("No registration for \(T.self)")
    }
}

class UserService {
    @Injected var network: NetworkService
    @Injected var auth:    AuthService
}

// 7) Codable + property wrappers — defaults at decode time
@propertyWrapper
struct Default<T: Codable>: Codable {
    let fallback: T
    var wrappedValue: T
    init(_ fallback: T) { self.fallback = fallback; self.wrappedValue = fallback }
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let decoded = try? container.decode(T.self)
        self.fallback = decoded ?? (try Self(from: decoder).fallback)
        self.wrappedValue = decoded ?? self.fallback
    }
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(wrappedValue)
    }
}

struct Profile: Codable {
    var name:  String
    @Default(true) var notifications: Bool
    @Default([])   var tags: [String]
}

// Now missing keys default to a value during JSONDecoder run.

// 8) Combine — @Published on ObservableObject
final class SearchVM: ObservableObject {
    @Published var query:   String = ""
    @Published var results: [String] = []
    private var cancellables = Set<AnyCancellable>()

    init() {
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] q in self?.search(q) }
            .store(in: &cancellables)
    }
    private func search(_ q: String) { /* … */ }
}

// 9) Limitations / gotchas
//   • Wrappers can't be applied to computed properties
//   • Wrappers can't access the enclosing instance directly (need @propertyWrapper enclosing-self trick)
//   • You can't combine certain wrappers (e.g. @State + @Published) — design around it
//   • Inherits Sendable / Codable issues from the wrapped type

// 10) The 'enclosing self' pattern (for wrappers that need to read other properties)
//   See Swift evolution proposal SE-0258; used by SwiftUI internally. Rarely needed in app code.

// 11) Common bugs
//   • @State on a non-View — only works in SwiftUI views
//   • @StateObject created in body — re-created every render; declare at the view init level
//   • @ObservedObject without an owner higher up — its lifetime is tied to the parent
//   • Forgetting to pass a binding ('value' instead of '$value') — child gets a snapshot, not a binding
//   • Wrapper stores reference to value via init but never updates wrappedValue setter — stale state
//   • Custom wrappers with mutating get used inside non-mutating context — compiler error; mark struct mutating

Why it matters

Property wrappers turn cross-cutting concerns — clamping, persistence, validation, dependency injection, SwiftUI state — into a single annotation. Use the built-ins for SwiftUI (@State, @Binding, @StateObject, @AppStorage), and write your own when the same boilerplate keeps showing up; the $ projection is your hook for exposing extra API like history or a publisher.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
@propertyWrapper
struct Trimmed {
    private var value: String = ""
    var wrappedValue: String {
        get { value }
        set { value = newValue.trimmingCharacters(in: .whitespaces) }
    }
    init(wrappedValue: String) { self.wrappedValue = wrappedValue }
}
Try it Yourself »

Discussion

Loading…