Home Understanding SwiftUI's PreferenceKeys
Post
Cancel

Understanding SwiftUI's PreferenceKeys

TLDR: the PreferenceKey protocol enables a child view to send values up the view hierarchy to their parent views. It’s the inverse of Environment variables which we use to send data down the view hierarchy.

Preference keys are a part of SwiftUI’s declarative data flow mechanism. They enable us to send information from child views to their parent views. These keys are based on the PreferenceKey protocol, which defines the way views accumulate and propagate the data within the view tree.

How it Works

Generally in SwiftUI data flows down the hierarchy from parent views to child views and the only way we can get it to flow back up is by using some sort of binding to create a two way read-write relationship where the parent view is the source of truth. But if you’ve used the default NavigationStack in SwiftUI, you’ll notice that when you are setting the title on a NavigationStack you don’t actually need to set any sort of binding to propagate that flow of info from the child view back up to the parent view, and that’s because behind the scenes navigation stack title actually uses the PreferenceKey protocol to accomplish this upward flow of data.

How PreferenceKeys Work

PreferenceKeys work by allowing child views to set values that can be read by their parent views. This is particularly useful when you need to pass information up the view hierarchy without relying on bindings or state variables.

Let’s break down the PreferenceKey protocol:

1
2
3
4
5
protocol PreferenceKey {
    associatedtype Value
    static var defaultValue: Value { get }
    static func reduce(value: inout Value, nextValue: () -> Value)
}
  • Value: This is the type of data you want to pass up the view hierarchy.
  • defaultValue: This is the initial value of the preference.
  • reduce: This method combines multiple values of the same preference key into a single value.

Creating a Custom PreferenceKey

Let’s create a simple example to illustrate how PreferenceKeys work. We’ll create a custom preference key that allows child views to send their heights to a parent view.

1
2
3
4
5
6
7
struct HeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

In this example, our HeightPreferenceKey uses a CGFloat as its value type. The reduce function takes the maximum value, which means if multiple child views set this preference, the largest height will be used.

Using PreferenceKeys in Views

Now, let’s see how we can use this preference key in our views:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ChildView: View {
    let height: CGFloat

    var body: some View {
        Color.blue
            .frame(height: height)
            .preference(key: HeightPreferenceKey.self, value: height)
    }
}

struct ParentView: View {
    @State private var maxHeight: CGFloat = 0

    var body: some View {
        VStack {
            ChildView(height: 100)
            ChildView(height: 150)
            ChildView(height: 200)

            Text("Max height: \(maxHeight)")
        }
        .onPreferenceChange(HeightPreferenceKey.self) { value in
            maxHeight = value
        }
    }
}

In this example, each ChildView sets its height using the preference modifier. The ParentView listens for changes to this preference using onPreferenceChange and updates its maxHeight state accordingly.

Real-World Use Case: Custom Navigation Title

Remember how I mentioned that SwiftUI’s NavigationStack uses preference keys behind the scenes for setting titles? Let’s create a simplified version of this to demonstrate a more practical use case:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct CustomTitleKey: PreferenceKey {
    static var defaultValue: String? = nil

    static func reduce(value: inout String?, nextValue: () -> String?) {
        value = nextValue() ?? value
    }
}

struct CustomNavigationView<Content: View>: View {
    @State private var title: String?
    let content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        VStack {
            Text(title ?? "")
                .font(.largeTitle)
            content
        }
        .onPreferenceChange(CustomTitleKey.self) { value in
            title = value
        }
    }
}

struct ContentView: View {
    var body: some View {
        CustomNavigationView {
            VStack {
                Text("Hello, World!")
                Text("This is a custom navigation view")
            }
            .preference(key: CustomTitleKey.self, value: "My Custom Title")
        }
    }
}

In this example, we’ve created a CustomNavigationView that uses a preference key to set its title. The child views can set the title using the preference modifier, and the CustomNavigationView will update its title accordingly.

Conclusion

PreferenceKeys in SwiftUI provide a mechanism for passing data up the view hierarchy. Again, they’re quite useful when we need to communicate information from child views to parent views without relying on bindings or state variables.

While they might seem a bit complex at first, once you understand how they work, you will like using them to build complex components. Not to mention they’re used extensively in many of SwiftUI’s built-in views and can make your custom views more flexible and reusable.

Remember, the key points are:

  1. They allow data to flow up the view hierarchy. Always think child -> parent
  2. They’re defined by implementing the PreferenceKey protocol.
  3. Child views set preferences using the preference modifier.
  4. Parent views read preferences using the onPreferenceChange modifier.
This post is licensed under CC BY 4.0 by the author.