Home

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, then PreferenceKey.
  • 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.