PreferenceKey, explained by building one
The environment sends values down the view tree. PreferenceKey sends them up — from children to
an ancestor — and it is the mechanism behind navigationTitle, toolbar and every API where a deep
child configures something near the root.
It looks strange until you build one.
The protocol
Two requirements:
protocol PreferenceKey {
associatedtype Value
static var defaultValue: Value { get }
static func reduce(value: inout Value, nextValue: () -> Value)
}
defaultValue is what an ancestor sees when no descendant set anything. reduce combines the values
from multiple children into one — because several children may each set a preference, and the parent
receives a single answer.
Building one: measuring the widest child
The classic case. You want a column of labels all sized to the widest one, and no built-in modifier does it.
struct MaxWidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
reduce here is max — of all the widths reported by children, the parent wants the largest.
Children report their width:
extension View {
func reportWidth() -> some View {
background(
GeometryReader { proxy in
Color.clear.preference(key: MaxWidthKey.self, value: proxy.size.width)
}
)
}
}
The GeometryReader is inside a background, which matters — a background takes the size of the
view it is behind, so the reader measures the label rather than expanding to fill the parent.
The ancestor listens and applies:
struct LabelColumn: View {
let rows: [(String, String)]
@State private var labelWidth: CGFloat = 0
var body: some View {
VStack(alignment: .leading) {
ForEach(rows, id: \.0) { label, value in
HStack {
Text(label)
.reportWidth()
.frame(width: labelWidth, alignment: .leading)
Text(value)
}
}
}
.onPreferenceChange(MaxWidthKey.self) { labelWidth = $0 }
}
}
The catch, and why I usually do not use this
That code works, and it has two real problems.
It is a frame behind. The sequence is: lay out with labelWidth = 0, children report their
widths, onPreferenceChange fires, state changes, lay out again. The first frame is wrong. Usually
invisible; occasionally a visible flicker on appear.
It can loop. If the value you set from the preference changes the size that produces the
preference, SwiftUI oscillates and eventually logs “Bound preference tried to update multiple times
per frame”. The example above avoids it because labelWidth only ever grows, but it is easy to write
one that does not.
Warning
For this specific problem — aligning things across rows — custom alignment guides are the
better tool. They work inside the layout pass, so there is no extra frame, no state, and no
possibility of a loop. PreferenceKey is the general mechanism; alignment guides are the
purpose-built one.
Here is the same result with an alignment guide:
extension HorizontalAlignment {
private enum ValueColumn: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat { context[.leading] }
}
static let valueColumn = HorizontalAlignment(ValueColumn.self)
}
VStack(alignment: .valueColumn) {
ForEach(rows, id: \.0) { label, value in
HStack {
Text(label)
Text(value).alignmentGuide(.valueColumn) { $0[.leading] }
}
}
}
No state, no GeometryReader, no lag.
Where PreferenceKey is genuinely right
It remains the correct tool for non-geometric data flowing upward — which is what it was designed for, and where nothing else works.
struct ToolbarItemsKey: PreferenceKey {
static let defaultValue: [ToolbarEntry] = []
static func reduce(value: inout [ToolbarEntry], nextValue: () -> [ToolbarEntry]) {
value.append(contentsOf: nextValue())
}
}
Now any descendant can contribute a toolbar item, and a container near the root collects them all
and renders them. That is exactly how SwiftUI’s own toolbar and navigationTitle work — a deeply
nested view declares something and an ancestor it knows nothing about acts on it.
Other legitimate uses: a child declaring “I am currently loading” so a root spinner can appear, a form field reporting a validation error to a summary at the top, a page reporting its title to a custom navigation bar.
The rule I ended up with
- Geometry flowing up? Try alignment guides first, then
onGeometryChange, thenPreferenceKey. - Anything else flowing up?
PreferenceKey, and it is the only answer. - Anything flowing down? The environment.
The mistake I made for a year was treating PreferenceKey as the measuring tool, because that is
what every tutorial uses it for. It is the upward-communication tool, and measuring is just its most
photogenic example.