SwiftUI Pro Tips: PreferenceKey

Ryan Jennings
Ancestry Product & Technology
7 min readMay 10, 2022

This article is part of a series on SwiftUI pro tips. To read the last article in the series, click here.

SwiftUI makes it easy to develop beautiful layouts with zero complications (no nasty unsatisfiable constraint warnings to worry about). There is no need for you to know the inner workings of the layout system, or be concerned with the end positioning or sizing of your views. Things just work. But what if you do want sizing and positioning information? What if, for instance, you wanted to overlay your views with additional information or maybe a tutorial that explains how to use a particular button and presents a callout that directly points to that button? You’d need to know the exact location of that button onscreen. Today’s set of tips will explain exactly how to do this.

Old key” by SusanKing is licensed under CC BY 2.0

Pro tip #7: Advanced sizing using PreferenceKey

As you may or may not know, you can use GeometryReader to get the relative size of containers like this:

public var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
Text("Left")
.frame(width: geometry.size.width * 0.33)
Text("Right")
.frame(width: geometry.size.width * 0.67)
}
}
.frame(height: 50)
}

The above GeometryReader will return the rendered width of the container (the height is hardcoded via the frame). Knowing the width of the GeometryReader, you can then set the inner Text views width to some fraction of the full width.

But GeometryReader will always only return its own width and height. And a GeometryReader takes up as much space as it can. The reason for the .frame(height: 50) above is because, without it, that GeometryReader would fill the entire height of the body, and would return that entire height if geometry.size.height was referenced.

Let’s say you want the exact width and height of a Text view. Using a GeometryReader will not work because it will only return its own size. Let’s also say that text was centered within its container, and just a short bit of text, like “Hello world”. How do we accurately get the width and height of that Text view?PreferenceKey comes to our rescue! But its implementation is a bit more involved than a simple geometry reader.

First, create a custom PreferenceKey that will be used to reference the size of the container:

struct ViewSizeKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}

Next, create a view which will calculate its size and assign it to the ViewSizeKey:

struct ViewGeometry: View {
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: ViewSizeKey.self, value: geometry.size)
}
}
}

You can now easily use ViewGeometry to get a view’s width and height like this:

VStack(alignment: .center) {
Text("Hello world")
.background(ViewGeometry())
.onPreferenceChange(ViewSizeKey.self) { size in
print("Text width:", size.width)
print("Text height:", size.height)
}
}

This will accurately print the exact width and height of “Hello world”.

Special thanks to pawello2222 Stack Overflow answer here https://stackoverflow.com/a/64452757/215845 for originally solving this problem for me!

Pro tip #8: Getting view anchors using PreferenceKey

You can also get the origin of a container using a different PreferenceKey. The origin is the top, left coordinate of a container. Unlike size, to obtain this value, you do need a GeometryReader.

A GeometryReader returns a GeometryProxy. This proxy can be used to get the size of the GeometryReader. It also contains all of the anchors of the subviews of the GeometryReader. One relevant anchor is the origin.

First, create a custom PreferenceKey that will be used to reference an anchor of the container.

struct ViewAnchorKey: PreferenceKey {
static let defaultValue = ViewAnchorData()
static func reduce(value: inout ViewAnchorData, nextValue: () -> ViewAnchorData) {
value.anchor = nextValue().anchor ?? value.anchor
}
}
struct ViewAnchorData: Equatable {
var anchor: Anchor<CGRect>? = nil
static func == (lhs: ViewAnchorData, rhs: ViewAnchorData) -> Bool {
return false
}
}

Next, create a view which will calculate the anchor and assign it to the ViewAnchorKey. You can actually add this new key to the ViewGeometry created earlier:

struct ViewGeometry: View {
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: ViewSizeKey.self, value: geometry.size)
.anchorPreference(key: ViewAnchorKey.self, value: .bounds) {
ViewAnchorData(anchor: $0)
}
}
}
}

You can now use ViewGeometry to access a container’s origin like this:

struct ContentView: View {
var body: some View {
GeometryReader { geo in
VStack(alignment: .center, spacing: 0) {
Rectangle()
.foregroundColor(.black)
.frame(height: 200)
Text("Hello")
.background(ViewGeometry())
.onPreferenceChange(ViewAnchorKey.self) { data in
guard let anchor = data.anchor else { return }
print("Text 1 origin:", geo[anchor].origin)
}
.onPreferenceChange(ViewSizeKey.self) { size in
print("Text 1 size:", size)
}
Text("World")
.background(ViewGeometry())
.onPreferenceChange(ViewAnchorKey.self) { data in
guard let anchor = data.anchor else { return }
print("Text 2 origin:", geo[anchor].origin)
}
.onPreferenceChange(ViewSizeKey.self) { size in
print("Text 2 size:", size)
}
}
}
}
}

And that will print out something like:

Text 1 size: (39.0, 20.333333333333332)
Text 1 origin: (175.66666666666666, 200.0)
Text 2 size: (45.0, 20.333333333333332)
Text 2 origin: (172.66666666666666, 220.33333333333331)

Keep in mind that the anchors are relative to the GeometryReader. So if you’re interested in screen coordinates, ContentView (above) needs to be fullscreen and the GeometryReader needs to include the entire subview hierarchy, not be nested within a subview.

More about PreferenceKey

When you do a Google search for PreferenceKey, one of the first things you’ll read about is Apple’s own use case, which is to allow you to set a navigation title from anywhere in your SwiftUI code.

struct RootView: View {
var body: some View {
NavigationView {
Text("Content")
.navigationTitle("Title")
}
}
}

That .navigationTitle does not directly affect the text it is attached to, but instead sets the title of the navigation bar. This would not be possible without PreferenceKey because normally information flows down the view heirarchy. And modifiers only affect the container to which they are attached. But the navigationTitle modifier is sending the string up the flow to a view that isn’t even a part of the body.

I spent a while trying to figure out when you’d ever really want to do something like this in the real world. I mean, if you intelligently composed your SwiftUI, something like this shouldn’t really ever be necessary.

I did find one potential use case, and if you’re interested, keep reading…

A PreferenceKey experiment

If you’ve used our Ancestry app recently, you’ve hopefully noticed a new feature that allows you to create stories based on people, photos or events from your tree.

This “story builder” includes a number of slide templates that the author can add to their story, for example, BuilderTextSlide. The templates share common “editing” functionality which is stored in a different view called EditableSlide. EditableSlide includes a number of things, the most important of which is a “Done” button, which saves the slide when it is done being created. In order for each template to access the “editable” features, the slides are defined like this:

struct BuilderTextSlide: View {
@State var text: String
var body: some View {
EditableSlide {
TextEditor(text: text)
// The structure of BuilderTextSlide is much more complicated than this, but this gives you an idea
}
}
}

And EditableSlide is defined like this:

struct EditableSlide<Content: View>: View {private let content: Contentinit(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
content
.zIndex(0)
VStack {
HStack {
Spacer()
Button("Done") {
// save the slide
}
}
Spacer()
}
.zIndex(1)
}
}
}

EditableSlide is a “view builder”. Defining how a view builder works is outside the scope of this article. For more information, check out https://swiftontap.com/viewbuilder

Since the “Done” button is part of EditableSlide, the text slide itself does not know when the button is tapped. But sometimes we want the slide to do some extra, slide specific, work in response to the button tap. And this is where PreferenceKey could come into play. We essentially just need a way to send information that the button was tapped up to BuilderTextSlide.

Here’s how this could work using PreferenceKey:

struct Slide: View {
@State private var tapCount = 0
var body: some View {
EditableSlide {
Text("Tap count \(tapCount)")
}
.onPreferenceChange(TapPreferenceKey.self) { value in
tapCount += 1
}
}
}
struct EditableSlide<Content: View>: View {

let content: Content
@State private var tapped = false

init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
VStack {
content
Button("Tap") {
tapped.toggle()
}
.preference(key: TapPreferenceKey.self, value: tapped)
}
}
}
struct TapPreferenceKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue()
}
}

Create a preference key called TapPreferenceKey, bind it to a state variable, and whenever that state changes, TapPreferenceKey will “change”. Then you can react to that change via the .onPreferenceChange modifier.

This can all also be done via bindings like this:

struct Slide2: View {
@State private var tapped = false
@State private var tapCount = 0
var body: some View {
EditableSlide2(tapped: $tapped) {
Text("Tap count \(tapCount)")
}
.onChange(of: tapped) { _ in
tapCount += 1
}
}
}
struct EditableSlide2<Content: View>: View {

let content: Content
@Binding var tapped: Bool

init(tapped: Binding<Bool>, @ViewBuilder content: () -> Content) {
self._tapped = tapped
self.content = content()
}

var body: some View {
VStack {
content
Button("Tap") {
tapped.toggle()
}
}
}
}

Using bindings is technically less code, plus you don’t need to clutter it up with custom preference keys.

In closing, preference keys are very useful for grabbing the size and origin of views. But past that, I don’t know of a good use for them. Maybe you do! If so, please let us know in the comments!

If you’re interested in joining Ancestry, we’re hiring! Feel free to check out our careers page for more info.

--

--