Most data flow constructs in SwiftUI propagate data down the view tree. Preferences work in the opposite direction: passing data up the hierarchy. This makes them very powerful, both in ways that are fairly straightforward and by enabling more complex behavior.
A preference is defined by two things: a default value, which is the value used when nothing writes an explicit value, and a reducer function, which defines how multiple values for the same preference at the same level of the view hierarchy are combined. The default value is fairly straightforward, and really depends on what, conceptually, the preference represents, so I won’t spend much time on it here. The reducer, however, is where the nuance lies.
The Reducer Function
When defining a PreferenceKey
, one of the requirements of the protocol is the reduce
method. Unfortunately, the documentation for the method isn’t great, and there’s one requirement in particular it has that’s not explicitly stated:
It is incorrect to write value = nextValue()
as your reducer.
struct BadStringPref: PreferenceKey {
static var defaultValue: String { "default" }
static func reduce(value: inout String, nextValue: () -> String) {
// This is wrong!
value = nextValue()
}
}
The reason for this is that, when the reduce
method is called, either the current or next value may be the preference’s default value. That means that just naively overwriting the current value with the next one may replace a non-default preference value with the default one. What’s more, the reduce
method may be called by SwiftUI even if there is only one explicitly-written preference in your view tree.
Here’s a simple example of a view hierarchy which can produce that result:
Color.red
.preference(key: BadStringPref.self, value: "non-default")
.overlay {
if true {
Color.green
}
}
.onPreferenceChange(BadStringPref.self) {
print($0) // => "default"
}
The presence of the dynamic content (i.e., the if
statement) inside the overlay[1][1]1. This is not specific to the overlay
modifier, but seemingly applies to any view that combines multiple child views, such as VStack
. results in the preference’s reduce method being called with a nextValue
of the preference’s default. This can also happen with other modifiers, such as toolbar
:
Color.red
.preference(key: BadStringPref.self, value: "non-default")
.toolbar {
// Doesn't even need content!
}
.onPreferenceChange(BadStringPref.self) {
print($0) // => "default"
}
So, how should this be fixed? Well, the reduce
method needs to be modified to not return the default value if either the current or next values are non-default.[2][2]2. In more math-y terms, you want the default value of the preference to be the identity value of the reduce operator.
More concretely, it depends on the type of the preference value. Optionals are probably the simplest case: the intuitive answer of “use the ??
operator” is correct. If you use value = value ?? nextValue()
, then the only way the reduced value can be nil
is if both current and next were nil
to begin with. For booleans, you can use the logical OR or AND operators depending on whether the default value is false
or true
, respectively. Beyond that, it’s hard to make prescriptive recommendations since it depends on what the preference conceptually represents. The important part is that you need to take into consideration the current value, rather than always accepting the next value.
Stated more simply: while it’s more obviously wrong to always ignore the next value, it’s also incorrect to always ignore the current value.
Reduce Order
One more note about the reduce
method: the docs say that it “receives its values in view-tree order.” But, observationally, this is not always the case[3][3]3. Note that, in this screen recording, when the List
is scrolled up, lower numbers (which occur earlier in the view-tree order) are appended to the array, indicating that they are the nextValue
.. I think it would be more accurate for the docs to say that the method receives its values in “graph-order” (that is, the order in which the graph nodes representing the individual views are added), which often, but not necessarily always, matches the view-tree order. In any event, it seems prudent to avoid depending heavily on the order in which reduce
sees preference values.
Using Preferences Effectively
SwiftUI models your user interface as a graph, with each view and modifier being represented as a node in the graph. The way to get the most out of preferences is, in my experience, by using them to let the graph do work for you. In particular, this means that, instead of trying to build separate mechanisms that pass data up the hierarchy, just use preferences.
ViewThatFits
Suppose you have a design where you want to display two pieces of UI (information, controls, whatever) in one place if they both fit. If they don’t, however, you want to display only one piece of UI there, and move the other control elsewhere (e.g., into a menu). You could accomplish this by using GeometryReader
and choosing a breakpoint. But you would have to lift the GeometryReader
all the way up the hierarchy to the lowest common ancestor of the primary and secondary locations, which can have other implications for the layout.
Instead, you can use ViewThatFits
to decide whether or not to display the secondary control, and use preferences to let the graph do the work of getting the information about which view was chosen to the alternate location for the secondary control.
One caveat with this approach lies in the way that ViewThatFits
seems to work—at least as of iOS 18. ViewThatFits
tries its children in the order that you specify them. But, while preferences of views after the one that’s selected are not written, the preferences of the views before the one selected (i.e., that are too big to fit) are written. This means that, to get the intended result of knowing which view is actually being displayed, you need to explicitly write a preference value (even if it’s the default value) for each view in the ViewThatFits
:
ViewThatFits {
Color.red
.frame(width: 200)
.preference(key: IsWide.self, value: true)
Color.blue
.preference(key: IsWide.self, value: false)
}
Notably, in the circumstances where, in the example above, the blue view is shown, the preference key’s reduce
method is not invoked. This suggests to me that SwiftUI is taking into account the preferences written by ViewThatFits
children to some degree, since the reducer would be called if two views were inside a different container.[4][4]4. Given that, I’m inclined to think the behavior described above is a bug (FB17310142), but fortunately one that’s easy to work around.
Data Validation
Here’s a more concrete example. In Tusker, the Compose screen lets you author a thread of multiple posts together, before posting them all at once. Managing the state of this UI is a little complicated, because a thread can consist of an arbitrary number of draft posts, any one of which the user may be editing. While there are parts of the UI that only rely on the state of an individual draft, there are other parts (such as the “Post” button) that need to track the state of the entire thread. You might see where I’m going with this.
The view that’s responsible for editing an individual draft writes a preference whose value is whether or not that particular draft is valid (not exceeding the character limit, etc.)[5][5]5. One could take this even further by having each control write a preference representing its own validity, but there’s a tradeoff with conceptual complexity.. The preference’s reducer function is the logical AND operator, and so when views higher in the hierarchy read the preference, SwiftUI has taken care of combining the individual drafts’ validities into a single value representing the state of the thread as a whole.
If you want to take a look at how this actually works in practice (it’s simpler than you might expect), the code is public.[6][6]6. There’s fair bit of unrelated stuff going on here—mostly related to some custom property wrappers and the actual validity checking for an individual draft—but, fundamentally, the preferences are being used exactly as described.
This approach has the useful result that views that care about the validity of the entire thread do not need to somehow observe the state of a bunch of separate objects. The view layer’s observation of the model layer takes place at the natural granularity that emerges from the structure of the data. This is the crux of what I mean about letting SwiftUI’s graph do work for you. Primitives like ForEach
influence the shape of the graph, which is something you can take advantage of.
This is not specific to the overlay
modifier, but seemingly applies to any view that combines multiple child views, such as VStack
. ↩︎
In more math-y terms, you want the default value of the preference to be the identity value of the reduce operator. ↩︎
Note that, in this screen recording, when the List
is scrolled up, lower numbers (which occur earlier in the view-tree order) are appended to the array, indicating that they are the nextValue
. ↩︎
Given that, I’m inclined to think the behavior described above is a bug (FB17310142), but fortunately one that’s easy to work around. ↩︎
One could take this even further by having each control write a preference representing its own validity, but there’s a tradeoff with conceptual complexity. ↩︎
There’s fair bit of unrelated stuff going on here—mostly related to some custom property wrappers and the actual validity checking for an individual draft—but, fundamentally, the preferences are being used exactly as described. ↩︎
Comments
Reply to this post via the Fediverse.