Custom Property Wrappers for SwiftUI
As I’ve been working with SwiftUI, I’ve come up with some custom property wrappers that I want to share with you. Most of these are property wrappers that can be easily accomplished using other approaches, but I like these for their expressivity.
Making Custom Property Wrappers
The easiest way to make a custom property wrapper that triggers SwiftUI view updates is to simply wrap another property wrapper:
@propertyWrapper
public struct ReinventedState<T>: DynamicProperty {
@State var wrappedValue: T
}
There’s one crucial piece to make this work, and that’s to declare that your property wrapper conforms to DynamicProperty
. If you do this, then the SwiftUI runtime will discover your property wrapper instance and start tracking it for changes. Since your property wrapper is using other built-in property wrappers, SwiftUI will recognize that your wrapper is dependent on the others, and when those update, yours will too.
There is an update()
method that’s part of the DynamicProperty
protocol, but you typically don’t need to implement it (the default implementation does nothing) unless your wrapper is tracking some internal state that it needs to update before the .wrappedValue
can be invoked in a var body
somewhere. update()
is called right before the view’s body is called.
But this is really all there is to it.
@Feature
One of the most useful property wrappers I’ve made is a simple “feature flag” trigger. It’s fairly common to have feature flags to control whether app-global features are available. Some apps go so far as to download a set of feature flag values and use those to dynamically adjust behavior. I like it for doing this like enabling a hidden debug menu.
The idea behind @Feature
is that I can use it to track a particular key path on a Features
singleton (or environment object) and then when that feature flag changes, the @Feature
wrapper triggers a change at the view level. It looks something like this:
public class Features: ObservableObject {
public static let shared = Features()
private init() { }
@Published public var isDebugMenuEnabled = false
}
@propertyWrapper
public struct Feature<T>: DynamicProperty {
@ObservedObject private var features: Features
private let keyPath: KeyPath<Features, T>
public init(_ keyPath: KeyPath<Features, T>, features: Features = .shared) {
self.keyPath = keyPath
self.features = features
}
public var wrappedValue: T {
return features[keyPath: keyPath]
}
}
Now in a view, I can use it like so:
struct AppSettingsView: View {
@Feature(\.isDebugMenuEnabled) var showDebugMenu
var body: some View {
Form {
if showDebugMenu {
NavigationLink(destination: DebugSettings()) { Text("Debug Settings") }
}
…
}
}
}
Now any time the Features
object publishes a change from anywhere in the app, my @Feature
wrapper will pick it up, and the AppSettingsView
will be automatically re-built (if it’s on-screen).
A couple notes about this:
- Yes, this is a singleton and singletons are “bad”. It’s illustrative here. You could just as easily use an
@EnvironmentObject
instead of an@ObservedObject
in the implementation and you’d get the same result - Since the entire
Features
object is being observed, then technically a change to any feature will trigger a change to every@Feature
wrapper. You could imagine working around this by doing something clever with specializing onT: Equatable
and checking for differences on a particular key path before triggering a change. If you did this, you wouldn’t be observing theFeatures
type directly, but some sort of object that wraps it and re-publishes a particular value. @Feature
relies on aKeyPath
to know what value to pull out. This guarantees some type-safety, but it also means I don’t end up with stringly-typed keys scattered around my app (more on this shortly).- By using
@Feature
instead of observing theFeatures
value directly with@EnvironmentObject
or something, it’s easier to search through the a codebase to find all uses of a feature: I can search for@Feature
or.isDebugMenuEnabled
and be confident that I’ll have found everything. - Strongly-typed key paths means that if I use the Refactor command to renamed a property, it gets changed automatically everywhere throughout the app.
AppSetting and SceneSetting
@AppSetting
and @SceneSetting
are two that are nearly identical in construction to @Feature
, but differ only in where they pull their values from. @Feature
relies on a Features
class, whereas @AppSetting
and @SceneSetting
would rely on AppSettings
and SceneSettings
instances, respectively.
I won’t go too much into the code here, beyond saying that I built these because I don’t like scattering the string keys for UserDefaults
values everywhere, and I much prefer the type safety of using key paths.
So, I built an AppSettings
class:
public class AppSettings: ObservableObject {
@AppStorage("appSetting1") public var appSetting1: Int = 42
@AppStorage("appSetting2") public var appSetting2: String = "Hello"
…
}
and a SceneSettings
class:
public class SceneSettings: ObservableObject {
@SceneStorage("sceneValue1") public var sceneSetting1: Int = 54
@SceneStorage("sceneValue2") public var sceneSetting2: String = "World"
…
}
Their respective property wrappers are basically identical to @Feature
, but obviously use a key path rooted in the corresponding type, and then use an @ObservedObject
/@EnvironmentObject
of that type. They also implement the projectedValue
property so that I can create bindings to use these values using the $…
syntax.
struct SomeDetailView: View {
@SceneSetting(\.shouldShowThatOneInfoSection) var showInfo
var body: some View {
DisclosureGroup("Detail Info", isExpanded: $showInfo) {
SomeInfoView()
}
…
}
}
Like with @Feature
, these have the same benefits of type safety, refactorability, and avoiding stringly-typed keys.
Other One-Off Wrappers
I’ll sometimes create one-off property wrappers to access specific things in the environment or specific global values. I do these mainly to draw attention to their usage in a particular view. For example, it can be easier to scan through a view and spot an @OpenURL var openURL
value than it is to scan through a list of a handful of @Environment(\.…)
values and notice the right key path.
When deciding what to use, I always try to err on the side of readability. The compiler doesn’t care what I type, as long as it can correctly generate the executable code. So when deciding what to do, I remember that more often than not, I’m writing this for me, not for the compiler. Therefore, I write it in a way that will (hopefully) make the most sense to me when I come back to this code in weeks, months, or years and have inevitably forgotten what it does.
Where We Go From Here
This is a quick overview of creating custom property wrappers that trigger SwiftUI view updates, and a couple of examples of some that I’ve found to be useful.
The thing I really want to share requires having a bit of background around creating custom property wrappers. I call it @Query
and it is how I follow my Laws of Core Data in SwiftUI.