SwiftUI Pro Tips: PreferenceKey
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.
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.