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 two Texts 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 Texts, 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 its NumericPreferenceKey value to 1 and pass it to its parent
  • Text("B") defaults NumericPreferenceKey to defaultValue, 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 Texts set and pass to their parent a NumericPreferenceKey value of 1 and 3, respectively
  • VStack accumulates two NumericPreferenceKey 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:

  1. VStack first receives the value 1 from Text("A"). As no other value was previously accumulated, this becomes the current value of VStack
  2. then VStack receives the value 3 from Text("B"), now SwiftUI needs to combine this value with the current value, therefore calling NumericPreferenceKey.reduce with 1 as the value parameter, and 3 as the nextValue

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 Texts 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 by Text("A"), as there was no prior accumulated value, this is the new VStack current value
  • then VStack gets the value passed by Text("B"), as we have two values reduce is called, and the new VStack value will be whatever the new proposed value is (that's what value = 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 by Text("A"), as there was no prior accumulated value, this is the new VStack current value
  • then VStack gets the value passed by Text("B"), as we have two values reduce is called, and nothing happens, as our reduce 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 Texts as children.

All Texts 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 by Text("A"), as there was no prior accumulated value, this is the new VStack current value
  • then it receives another value from its other child, the inner VStack, and our reduce 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!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all