You can use SwiftUI by trying to fight the framework or by trying to work with it. One of the best ways I’ve found of working with the framework is by using custom property wrappers. Of all the tools that SwiftUI provides, property wrappers are the one that most consistently makes me feel like I’m using the framework how it wants to be used.
In Swift, property wrappers are simply types that are decorated with a special attribute and contain a wrappedValue
member. While property wrappers by themselves can be useful, where they really shine is when combined with the tools SwiftUI provides for plumbing them into SwiftUI’s data model.
Composing Property Wrappers
The first step to building useful custom property wrappers is knowing that, like View
s, property wrappers can themselves contain more property wrappers.
@propertyWrapper
struct Uppercasing: DynamicProperty {
@Environment(\.myString) var myString: String
var wrappedValue: String {
myString.uppercased()
}
}
The DynamicProperty
protocol serves as a marker for SwiftUI that a conforming type may contain property wrappers which need to be plumbed into SwiftUI’s data model (similar to View
itself). The example above works exactly like you’d expect.
The protocol also provides an update
method which the documentation says is called “before rendering a view’s body
to ensure the view has the most recent value.” This method serves as a useful point for ensuring that both the data you’re vending to the view is up-to-date and that any method for observing external changes is set up.
@propertyWrapper
struct MyObserver: DynamicProperty {
var index: Int
@State var wrappedValue: String
@State private var observer: AnyCancellable?
init(index: Int) {
self.wrappedValue = MyManager.shared.values[index]
}
func update() {
observer = MyManager.shared.addObserver(index) {
wrappedValue = $0
}
}
}
The Escape Hatch
You can get pretty far by using the framework’s built-in property wrappers to actually observe data and trigger view updates. But there are plenty of scenarios where you may have some external change that you want to result in a view update too. What you need is some escape hatch to manually say to SwiftUI, “hey, my data has changed and my containing view should be updated.”
The way I most often reach for is by having, within my custom property wrapper, a @StateObject
with an “observer” type that encapsulates the observation logic for the property wrapper. Having a single reference type to store state is useful in itself[1][1]1. Just like with a View
update, you can’t modify @State
values within a dynamic property’s update
method, so it’s helpful to have a reference type in which you can stash things., but making it an ObservableObject
means that you can trigger a view update just by calling objectWillChange.send()
.
Another way you can do this without involving Combine is by giving your property wrapper its own @State
value that contains some unused data whose only purpose is to be modified when the view needs to be updated. This approach is shown in this blog post by Saagar Jha.
Examples
Finally, here are some examples of actual custom property wrappers I use, both in Tusker and in other projects, in hopes that they can provide some inspiration for ways custom property wrappers can be useful.
@OptionalObservedObject
This one is pretty self-explanatory. SwiftUI’s @ObservedObject
property wrapper doesn’t work with optional types. Mine functions exactly like you’d expect: it takes an optional value of a type that implements ObservableObject
and, when it has a value, triggers a SwiftUI view update whenever the wrapped object changes.
The need for this one is largely obviated if you can exclusively target OS versions since the advent of @Observable
, but it’s still useful if you can’t.
@ProgressObserving
This is a nice simple one. It uses key-value observing to watch a Progress
’ fractionCompleted
property and update a SwiftUI whenever it changes. It also takes care of dispatching to the main thread if necessary (since a progress tree may be updated from multiple threads).
@PreferenceObserving
A while back, I spent a bunch of time over-engineering a system for storing Tusker’s user preferences. The details of how it works aren’t germane (perhaps another time), but one important consideration is that all the preferences end up in a big ObservableObject
. This means that, while watching the entire preferences object from SwiftUI is trivial, watching only a single preference key is somewhat awkward. This property wrapper encapsulates all that awkwardness, allowing a view to read the current value of a user preference and be updated whenever it changes (but not when irrelevant preferences change). The property wrapper is used by providing a KeyPath
from the type of the preferences container object to the preference itself:
struct SomeView: View {
@PreferenceObserving(\.$statusContentMode) var statusContentMode
var body: some View { /* ... */ }
}
@EnvironmentOrLocal
SwiftUI’s environment is great for dependency injection, but sometimes you need to override an environment value for just part of a view hierarchy, and trying to get the .environment
modifier in the right place would be awkward. This is a cute one for a few reasons:
- The property wrapper is, itself, an
enum
. This works because SwiftUI correctly discovers and wires up theEnvironment
associated value in one of the cases. - There’s an
.environment
static member, so that you don’t have to spell out the wholeEnvironment
initializer when the type can be inferred (e.g., in a view initializer’s default parameter). - Because of the magic of
@Observable
, nothing needs to be done to make SwiftUI’s dependency tracking work.
Expand for the full code
@propertyWrapper
enum EnvironmentOrLocal<T: AnyObject & Observable>: DynamicProperty {
case environment(Environment<T>)
case local(T)
static var environment: Self {
.environment(Environment(T.self))
}
var wrappedValue: T {
get {
switch self {
case .environment(let env):
env.wrappedValue
case .local(let v):
v
}
}
}
}
@DraftObserving
This is one of the more complex property wrappers that I’ve written for Tusker. In the app’s Compose screen, in-progress drafts are modeled with a CoreData object. In addition to the draft object itself, there are also several related objects, such as DraftAttachment
s. Since NSManagedObject
conforms to ObservableObject
, it’s fairly straightforward to observe a single one. But there are a few places where I need to observe not only the draft, but also the various related objects[2][2]2. For example: depending on the user’s preferences, the validity of a draft overall may depend on whether or not all of the attachments have alt text.. This property wrapper takes care of observing all of them and updating a SwiftUI whenever any changes. I won’t get into all the (slightly gnarly) details, but the key idea is that the property wrapper observes the Draft
itself and, whenever the draft changes, checks whether any of the related objects have changed, attaching observers to them as well if needed.
@ThreadObserving
Tusker allows composing an entire thread of posts at once and, like there are parts of the UI that may change whenever any aspect of a single draft changes, there are parts that may change whenever any aspect of any draft in the thread changes. This property wrapper is a fairly straightforward composition of many of the observer object that @DraftObserving
uses, aggregating their effects together.
@FocusedInputAutocompleteState
This one extends the focused input system I mentioned in my last post, about FocusedValues
. One of the focused values used in Tusker’s Compose screen is a representation of the focused text input view itself (i.e., a UITextView
). The actual protocol provides a number of properties, including a Combine publisher representing the state of the current autocomplete session (e.g., “inactive” or “mention starting with @sh”). It’s a publisher since its value changes over time and other parts of the UI need to be able to react to it, but the fact that it is makes observing it a little cumbersome.
This property wrapper uses @FocusedValue
to get the current autocomplete state publisher and then attaches a subscriber to it. Then, whenever either the focused input changes or the autocomplete state of the current input changes, it triggers a SwiftUI view update.
Just like with a View
update, you can’t modify @State
values within a dynamic property’s update
method, so it’s helpful to have a reference type in which you can stash things. ↩︎
For example: depending on the user’s preferences, the validity of a draft overall may depend on whether or not all of the attachments have alt text. ↩︎
Comments
Reply to this post via the Fediverse.