swiftuipreferencekey

Why does onPreferenceChange return the wrong size in certain cases?


I am using a GeometryReader to find the size of a view and want to report this to a preference key. The geometry reader always reads the correct value, but onPreferenceChanged often gets 0.0 reported instead of the value that was read.

I have simplified the case to illustrate the error. (Never mind that the size is already hard coded and I have no further use of the value in this example.)

struct HeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

struct ContentView: View {
    var body: some View {
        ZStack {
            Color.yellow
            // Content to be inserted here...
        }
        .frame(width: 300, height: 300)
        .background(
            GeometryReader { proxy in
                let _ = print("Setting preference key:", proxy.size.height)
                Color.clear
                    .preference(key: HeightPreferenceKey.self, value: proxy.size.height)
            }
        )
        .onPreferenceChange(HeightPreferenceKey.self) { height in
            print("onPreferenceChange height: \(height)")
        }
    }
}

With the following content inserted, I would get this printout, which is to be expected:

Setting preference key: 300.0
onPreferenceChanged height: 300.0
Button {} label: { Image(systemName: "book") }
    .buttonStyle(.borderless)
Text("Hello")

However, if I remove the button style or insert a condition, then onPreferenceChanged will only get 0.0, as in these cases:

Button {} label: { Image(systemName: "book") }
if true {
    Text("Hello")
}

Can anyone explain why this is happening, and how I can avoid this problem?


Solution

  • Your PreferenceKey is implemented incorrectly. From the documentation for defaultValue

    Views that have no explicit value for the key produce this default value. Combining child views may remove an implicit value produced by using the default. This means that reduce(value: &x, nextValue: {defaultValue}) shouldn’t change the meaning of x.

    Your PreferenceKey does not satisfy this requirement. Reducing the default value 0 into 300 will change the value to 0, and this is exactly what's happening in your code. Try putting some prints in reduce.

    The idea for this requirement is that SwiftUI reserves the right to still call reduce to combine child views even if they do not have a .preference. This all depends on implementation details, and it is not strange that slight changes in the view hierarchy changes whether or not SwiftUI calls reduce this way.

    Notice that your onPreferenceChange is put on a view that has multiple children other than the background with a preference set - Color.yellow, the button, the text, etc. It wouldn't be strange if SwiftUI tries to combine one of these children's preferences (the default value 0) with the preference of the background using your reduce method.

    If you put onPreferenceChange immediately after .preference, this is a lot less likely to happen.

    Here is a correct implementation of a preference key, using an optional.

    struct HeightPreferenceKey: PreferenceKey {
        static let defaultValue: CGFloat? = nil
        static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
            if let next = nextValue() {
                value = next
            }
        }
    }