Shadowfacts

The outer part of a shadow is called the penumbra.

Custom Property Wrappers in SwiftUI

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 Views, 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 ProgressfractionCompleted 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:

  1. The property wrapper is, itself, an enum. This works because SwiftUI correctly discovers and wires up the Environment associated value in one of the cases.
  2. There’s an .environment static member, so that you don’t have to spell out the whole Environment initializer when the type can be inferred (e.g., in a view initializer’s default parameter).
  3. 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 DraftAttachments. 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.

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. ↩︎

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. ↩︎


Comments

Reply to this post via the Fediverse.