PreferenceKey's reduce method demystified
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:)
:
in this article let's take a deep dive into this mysterious method.
Official definition
Here's the current SwiftUI's headers 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 what's the core functionality of reduce
:
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")
}
Here no view sets its own NumericPreferenceKey
value, therefore 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")
.
Therefore, once again, NumericPreferenceKey.reduce
is also 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
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 strive to do as little as possible to present the final outcome to the end user, in this example no one is reading nor utilizing the preference key: therefore SwiftUI will ignore it.
All our keys are actually there and are present in their right place in the view hierarchy, they're just not used, therefore SwiftUI won't spend any time on resolving them.
If we want to see reduce
getting called, we need to use NumericPreferenceKey
somehow, 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 what our VStack
NumericPreferenceKey
value is and when it changes: this is all we need to setup in order to see our reduce
method getting called.
Why is reduce's nextValue a function?
Something that probably comes out as perplexing when reading PreferenceKey
's definition is why the reduce
arguments are a value and a function: we're combining two values, right? Why can't SwiftUI just give us the explicit next value already?
public protocol PreferenceKey {
associatedtype Value
static var defaultValue: Self.Value { get }
static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)
}
It turns out that the reason why is once again SwiftUI 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 own NumericPreferenceKey
, 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 even 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 actually 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 effectiveness.
All we've seen today is very much undocumented: please let me know if I've missed anything!
As always, thank you for reading and stay tuned for more SwiftUI articles!