How SwiftUI's Preference Keys are propagated
SwiftUI's PreferenceKey
declaration is as following:
public protocol PreferenceKey {
associatedtype Value
static var defaultValue: Self.Value { get }
static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)
}
While it's clear what both Value
and defaultValue
are and do, the same cannot be said for reduce(value:nextValue:)
:
let's take a deep dive into this mysterious method.
Official definition
Here's the current documentation for reduce
:
/// Combines a sequence of values by modifying the previously-accumulated
/// value with the result of a closure that provides the next value.
///
/// This method receives its values in view-tree order. Conceptually, this
/// combines the preference value from one tree with that of its next
/// sibling.
///
/// - Parameters:
/// - value: The value accumulated through previous calls to this method.
/// The implementation should modify this value.
/// - nextValue: A closure that returns the next value in the sequence.
static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)
This definition sets some foundation on reduce
core functionality:
it's used to compute a view preference key value, only when multiple children modify that key.
Let's make an example.
NumericPreferenceKey
The following is a simple preference definition that holds an integer as its value:
struct NumericPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) { ... }
}
From now on, every view in any view hierarchy has a default value of 0
for NumericPreferenceKey
, regardless of the reduce
implementation.
When is reduce
invoked
Imagine a small view hierarchy with one root, two leaves, and nothing in between:
VStack {
Text("A")
Text("B")
}
For clarity's sake:
VStack
is the root, while the twoText
s are the leaves.
We will use this hierarchy in different scenarios.
No child alters/sets the preference key
VStack {
Text("A")
Text("B")
}
As no view sets the NumericPreferenceKey
value, all views have a NumericPreferenceKey
value of NumericPreferenceKey.defaultValue
, which is 0
, as per our definition.
NumericPreferenceKey.reduce
will never be called on the Text
s, as no one can pass a value to a leaf.
reduce
is also not called on VStack
, because its children don't set/pass a NumericPreferenceKey
value to their parent.
One child alters/sets the preference key
VStack {
Text("A")
.preference(key: NumericPreferenceKey.self, value: 1)
Text("B")
}
In this case:
Text("A")
sets itsNumericPreferenceKey
value to1
and pass it to its parentText("B")
defaultsNumericPreferenceKey
todefaultValue
, and not pass anything to its parent
What about VStack
? Let's take a look at the reduce
definition once again: Combines a sequence of values by modifying the previously-accumulated value with the result of a closure that provides the next value.
Since only children that have set/changed the NumericPreferenceKey
value will pass it to their parents, VStack
will only have accumulated one value: 1
from Text("A")
.
Once again, NumericPreferenceKey.reduce
is not called on VStack
, and the NumericPreferenceKey
value associated to VStack
is now 1
.
Multiple children alter/set the preference key
VStack {
Text("A")
.preference(key: NumericPreferenceKey.self, value: 1)
Text("B")
.preference(key: NumericPreferenceKey.self, value: 3)
}
In this example:
- both
Text
s set and pass to their parent aNumericPreferenceKey
value of1
and3
, respectively VStack
accumulates twoNumericPreferenceKey
values
SwiftUI doesn't know what NumericPreferenceKey
value to assign to VStack
, as multiple values are proposed from its children:
this is where our NumericPreferenceKey.reduce
comes to the rescue, helping SwiftUI reduce these multiple values into one, which will be then assigned to our VStack
.
NumericPreferenceKey.reduce
would be called even if all passed values were the same.
So what's the value of VStack
? Before answering this, we need to know in what order the values are passed to VStack
.
Reduce call order
PreferenceKey
's reduce
method always contains two parameters: the current value
, and the next value to merge.
Going back to our example:
VStack
first receives the value1
fromText("A")
. As no other value was previously accumulated, this becomes the current value ofVStack
- then
VStack
receives the value3
fromText("B")
, now SwiftUI needs to combine this value with the current value, therefore callingNumericPreferenceKey.reduce
with1
as thevalue
parameter, and3
as thenextValue
This is what the SwiftUI header meant by This method receives its values in view-tree order.
: reduce
is always called by traversing our view's children from first to last, in declaration order.
If our VStack
had Text
s from "A"
to "Z"
, all setting their NumericPreferenceKey
value, reduce
would be called first with the current value, inherited from Text("A")
and Text("B")
, then with the new current value and Text("C")
, etc.
reduce
is called only between values accumulated within siblings: if a VStack
child had its own children, the same concepts would be applied recursively, and then that child would pass to VStack
its final value, regardless of how it was obtained.
It's finally time to compute our VStack
's NumericPreferenceKey
value:
to do so, we need to take a look at the NumericPreferenceKey.reduce
implementation.
Common reduce implementations
Each preference key declaration has its own reduce
implementation:
in this section, let's cover some of the most common ones.
value = nextValue()
The most common definition assigns nextValue()
to value
, this could also be NumericPreferenceKey
's implementation:
struct NumericPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
value = nextValue()
}
}
Let's go back to our example where both Text("A")
and Text("B")
pass a value, and compute VStack
's NumericPreferenceKey
:
- first
VStack
takes in the value passed byText("A")
, as there was no prior accumulated value, this is the newVStack
current value - then
VStack
gets the value passed byText("B")
, as we have two valuesreduce
is called, and the newVStack
value will be whatever the new proposed value is (that's whatvalue = nextValue()
does).
In other words, with this implementation, when multiple children pass a value, reduce
will discard all of them but the last one, which will become the value of our view.
Empty implementation
In previous articles we've defined various preference keys with an empty reduce
implementation:
struct NumericPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
}
}
Once again, let's go back to our example and compute VStack
's NumericPreferenceKey
:
- first
VStack
takes in the value passed byText("A")
, as there was no prior accumulated value, this is the newVStack
current value - then
VStack
gets the value passed byText("B")
, as we have two valuesreduce
is called, and nothing happens, as ourreduce
does nothing.VStack
keeps the current value.
This implementation is the opposite of the previous one: our view will keep the very first collected value, and ignore the rest.
value += nextValue()
Other common implementations use reduce
to combine all values with some math operators such as sum:
struct NumericPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
value += nextValue()
}
}
It should be intuitive by now that, in this case, our view will have as its value the sum of all the values passed by its children.
And many more
Other implementations worth mentioning are on preference keys whose Value
is either an array or a dictionary, and where the reduce
method is used to group all the children values together (via append(contentsOf:)
or similar).
Once we understand the inner workings of preference key, it becomes intuitive to read and understand the effects of reduce
.
PreferenceKey
is a function of the current state
Like SwiftUI views, preference key values are the outcome of the current state and are not persisted.
If we look at the value += nextValue()
reduce
implementation for example, the current view value is the sum of the current passed values: if one children changes the passed value, SwiftUI will re-compute our view preference key value from scratch.
The same is true for any preference key Value
: even in case of arrays or dictionaries. We always start over, nothing is persisted.
When is the preference key computed?
If the complete view in our app is our VStack
example, reduce
is actually never called:
struct ContentView: View {
var body: some View {
VStack {
Text("A")
.preference(key: NumericPreferenceKey.self, value: 1)
Text("B")
.preference(key: NumericPreferenceKey.self, value: 3)
}
}
}
This is true despite VStack
having multiple NumericPreferenceKey
values passed: did this article lied to us?
SwiftUI always strives to do as little as possible to present the outcome to the user. No one is reading or using the preference key in this example; therefore, SwiftUI will ignore it.
All our keys are there and present in their proper place in the view hierarchy. They're just not used. Therefore SwiftUI won't spend any time resolving them.
If we want to see reduce
getting called, we need to somehow read NumericPreferenceKey
, one way is to add an onPreferenceChange(_:perform:)
function in our VStack
:
struct ContentView: View {
var body: some View {
VStack {
Text("A")
.preference(key: NumericPreferenceKey.self, value: 1)
Text("B")
.preference(key: NumericPreferenceKey.self, value: 3)
}
.onPreferenceChange(NumericPreferenceKey.self) { value in
print("VStack's NumericPreferenceKey value is now: \(value)")
}
}
}
onPreferenceChange(_:perform:)
tells SwiftUI that we're interested in knowing VStack
's VStackNumericPreferenceKey
value and when it changes: this is all we need to set up to see our reduce
method getting called.
Why is reduce
's nextValue
a function?
public protocol PreferenceKey {
associatedtype Value
static var defaultValue: Self.Value { get }
static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)
}
Something that probably comes out as perplexing when reading PreferenceKey
's definition is that reduce
's arguments are one value and a function: we're combining two values. Why doesn't SwiftUI give us two values?
The answer is SwiftUI's laziness.
Let's take our previous reduce
empty implementation and use it in a slightly more complicated example:
struct ContentView: View {
var body: some View {
VStack {
Text("A")
.preference(key: NumericPreferenceKey.self, value: 1)
VStack {
Text("X")
.preference(key: NumericPreferenceKey.self, value: 5)
Text("Y")
.preference(key: NumericPreferenceKey.self, value: 6)
}
}.onPreferenceChange(NumericPreferenceKey.self) { value in
print("VStack's NumericPreferenceKey value is now: \(value)")
}
}
}
struct NumericPreferenceKey: PreferenceKey {
static var defaultValue: Int = 0
static func reduce(value: inout Int, nextValue: () -> Int) {
}
}
Here we have a VStack
as our root, this VStack
contains two children: a Text("A")
and another VStack
, which, in turn, has two Text
s as children.
All Text
s in the view set their NumericPreferenceKey
value, and we call onPreferenceChange(_:perform:)
on our root.
Let's compute the root NumericPreferenceKey
value:
- first
VStack
receives the value passed byText("A")
, as there was no prior accumulated value, this is the newVStack
current value - then it receives another value from its other child, the inner
VStack
, and ourreduce
method gets called
In this example, reduce
does nothing. We don't need to know what the exact value passed by our inner VStack
is.
Since we do not access to nextValue
, SwiftUI won't compute it.
This means that the inner VStack
preference key is not computed at all, as no one reads it, therefore our reduce
is called just once, to resolve the root VStack
preference key only.
And this is why reduce
takes in a value and a method: the nextValue()
method is a way for SwiftUI to check if that value is needed, and if it's not, it won't resolve it.
SwiftUI needs to resolve the whole view hierarchy as quickly and as efficiently as possible, this is yet another optimization.
Conclusions
SwiftUI's PreferenceKey
is one of those behind the scenes tools that are not very popular but yet are indispensable to obtain certain results:
in this article, we explored PreferenceKey
's inner workings and revealed how its reduce
method is used and what it is for, discovering even more SwiftUI efficiency.
All we've seen today is very much undocumented: please let me know if I've missed anything!
Thank you for reading, and stay tuned for more SwiftUI articles!