PreferenceKey protocol is used to produce values that can be communicated up in the view hierarchy. It allows a child view to communicate with the parent view.
This is very similar to how Environment variable works but the opposite. Environment is used to send data down the view hierarchy.
One of the example use of PreferenceKey is the NavigationStack and navigationTitle . navigationTitle doesn’t modify the navigation view directly but instead it uses PreferenceKey to communicate the title change.
When a view has multiple child views, the PreferenceKey provides a way to combine all values into a single value with the help of reduce(value:nextValue:) method, which is the only and required method for this protocol.
To understand this better, let’s look at few examples.
We will start by creating a new struct which will conform to the PreferenceKey protocol.
struct DTPreferenceKey: PreferenceKey {}
DefaultValue
This protocol has two conformance requirements.
First one is the defaultValue , this represents the default value of the preference if none is explicitly set. defaultValuecan be any built-in type or custom type conforming to the Equatable protocol.
We will set this to be a String type by specifying typealias Value = String once typealias is defined, Xcode’s compiler will stub out remaining protocol requirements.
Click on fix to add protocol stubs and we will get following:
Next, we will see static var declaration required issue
Clicking Fix will not work this time so simply initialize the defaultValue with empty string.
static var defaultValue: String = ""
Reduce
Reduce function is used to combine all the values in a subtree of views. It does so by combining a sequence of values by modifying previously accumulated values with the result of a closure that provides the next value.
It takes two parameters:
value: this represents value accumulated through previous calls to this method.
nextValue: this is a closure that returns the next value in the sequence.
We will update our example to combine value and nextValue into the value 😃
Next comes the preference modifier, which allows us to set a value that’s associated with a key.
This modifier is used along with onPreferenceChange(_:perform:) instance method, which adds an action to perform when the specified preference key’s value changes.
Let’s use this in our example. We will add two Text views inside a VStack and set preference modifier for both Textviews. We will use State variable to capture value from preference and render that on screen in another Text view.
struct DevTechiePreferenceKeyExample: View {
@State private var prefValue = ""
var body: some View {
VStack {
Text("DevTechie")
.preference(key: DTPreferenceKey.self, value: "DevTechie")
Text("Learn iOS Development")
.preference(key: DTPreferenceKey.self, value: "SwiftUI, iOS, UIKit")
Divider()
Text(prefValue)
.font(.title3)
}
.onPreferenceChange(DTPreferenceKey.self) { value in
prefValue = value
}
}
}
Build and run
User Case 1 — Cart Total
One of the use case for preference key could be to compute total value of a cart. For this use case, we can leverage the power of reduce, since we are getting all the values. We can simply add all the child values together and get the total out of it.
Next, we will use this inside our view where we have a list of items with randomly generated price values and each item in the list has a preference modifier to propagate their item price.
We will use onPreferenceChange to reflect total value of cart into a State variable.
struct DevTechiePreferenceKeyExample: View {
@State private var total: Double = 0
var body: some View {
NavigationStack {
List {
ForEach(1..<10) { idx in
HStack {
Text("Item #\(idx)")
Spacer()
let num = Double.random(in: 10...100)
Text(num, format: .currency(code: "USD"))
.preference(key: DTTotalValuePK.self, value: num)
}
}
HStack {
Text("Cart Total:")
Spacer()
Text(total, format: .currency(code: "USD"))
}
.font(.title.bold())
}
.navigationTitle("DevTechie")
.onPreferenceChange(DTTotalValuePK.self) { totalValue in
total = totalValue
}
}
}
}
Build and run:
Use Case 2 — Equal Width View
Let’s take a look at another practical use case for preference key. We will align items in a list.
Let’s understand the problem first. Imagine that we have a list displaying two items.
struct DevTechiePreferenceKeyExample: View {
var body: some View {
List {
HStack {
Text("1.")
Text("DevTechie.com")
}
HStack {
Text("20.")
Text("Checkout video courses")
}
}
}
}
Notice that the items are not aligned, meaning start position of “DevTechie.com” is not the same as start position of “Checkout video courses”. It would be nice if those two are aligned vertically.
We can fix this by defining a static width to the first Text view in HStack.
struct DevTechiePreferenceKeyExample: View {
var body: some View {
List {
HStack {
Text("1.")
.frame(width: 50, alignment: .leading)
Text("DevTechie.com")
}
HStack {
Text("20.")
.frame(width: 50, alignment: .leading)
Text("Checkout video courses")
}
}
}
}
Besides the fact that this looks awful, it will break for bigger number like this:
struct DevTechiePreferenceKeyExample: View {
var body: some View {
List {
HStack {
Text("1.")
.frame(width: 50, alignment: .leading)
Text("DevTechie.com")
}
HStack {
Text("20.")
.frame(width: 50, alignment: .leading)
Text("Checkout video courses")
}
HStack {
Text("20000.")
.frame(width: 50, alignment: .leading)
Text("Checkout video courses")
}
}
}
}
This is where PreferenceKey can rescue us.
We will start by building struct which will conform to the PreferenceKey protocol. Main goal for this protocol is to collect width from all the child views and keep them cached inside an array.
In our main view, we will add a State variable for width which we want to apply. Idea is that we will collect width from all the child views(who have the preference set) and find the widest among all to set that as the width for all the child views. It’s easier to understand in code.
struct DevTechiePreferenceKeyExample: View {
@State private var width: CGFloat? = nil
var body: some View {
List {
HStack {
Text("1.")
.dtEqualWidth().frame(width: width, alignment: .leading)
Text("DevTechie.com")
}
HStack {
Text("20.")
.dtEqualWidth().frame(width: width, alignment: .leading)
Text("Checkout video courses")
}
HStack {
Text("20000.")
.dtEqualWidth().frame(width: width, alignment: .leading)
Text("Checkout video courses")
}
}
.onPreferenceChange(DevTechieWidthPreferenceKey.self) { widths in
if let width = widths.max() {
self.width = width
}
}
}
}
Build and run
Much better 😃. If you prefer, align text to the trailing edge.
struct DevTechiePreferenceKeyExample: View {
@State private var width: CGFloat? = nil
var body: some View {
List {
HStack {
Text("1.")
.dtEqualWidth()
.frame(width: width, alignment: .trailing)
Text("DevTechie.com")
}
HStack {
Text("20.")
.dtEqualWidth()
.frame(width: width, alignment: .trailing)
Text("Checkout video courses")
}
HStack {
Text("20000.")
.dtEqualWidth()
.frame(width: width, alignment: .trailing)
Text("Checkout video courses")
}
}
.onPreferenceChange(DevTechieWidthPreferenceKey.self) { widths in
if let width = widths.max() {
self.width = width
}
}
}
}
Even better 😃
Use Case 3 — Finding Max Value & Custom Type
We have seen examples of using built-in types with PreferenceKey protocol but what about custom types? Well they are supported as long as the type conforms to the Equatable protocol.
Let’s work with an example to see this in action.
We will define a data structure to capture crazy weather pattern of San Francisco(data is somewhat fictitious 😃).
We will make our custom type conform to Equatable protocol along with Identifiable protocol.
struct DevTechiePreferenceKeyExample: View {
@State private var hottestMonth: Weather? = nil
var body: some View {
NavigationStack {
List {
ForEach(Weather.sampleData) { weather in
HStack {
Text(weather.month)
Spacer()
Text("\(weather.temp)℉")
}
.preference(key: DTHighestValue.self, value: weather)
}
if let hottestMonth {
Text("Month of **\(hottestMonth.month)** was at burning **\(hottestMonth.temp)℉** 🌡")
}
}
.navigationTitle("DevTechie")
.onPreferenceChange(DTHighestValue.self) { hottestWeather in
hottestMonth = hottestWeather
}
}
}
}
Build and run:
With that we have reached the end of this article. Thank you once again for reading. Don’t forget to 👏 and follow 😍. Also subscribe our newsletter at https://www.devtechie.com