watchOS With SwiftUI by Tutorials!

Build awesome apps for Apple Watch with SwiftUI,
the declarative and modern way.

Home iOS & Swift Tutorials

SwiftUI View Preferences Tutorial for iOS

Learn how SwiftUI view preferences allow views to send information back up the view hierarchy and the possibilities that opens up for your apps.

5/5 8 Ratings

Version

  • Swift 5.5, iOS 15, Xcode 13

Much has been said about the declarative, top-down nature of SwiftUI. You write a view, which contains other views, which contains further views, and so on. Relevant information flows from the root view down to the descendant views; a list knows about all of the entries it contains, but a single cell only knows about one entry, and the name label in that cell only knows about a single piece of text. Now and then, however, it’s really useful for an ancestor view to get some information back in the opposite direction. There’s no responder chain, delegates or superview referencing in SwiftUI like there is in UIKit. Instead, there are SwiftUI view preferences. At first glance, it’s an oddly named, sparsely documented and easily dismissed part of the framework. But view preferences are the solution to sending data the “wrong” way in SwiftUI.

In this tutorial, you’ll learn how to use view preferences to achieve the following:

  1. Report layout information from child views to ancestors.
  2. Use that information to position views from separate parts of the hierarchy relative to each other.
  3. Create a dynamic view overlay which updates with your main view automatically.
  4. Encapsulate a dynamic overlay into a custom view modifier.
Note: This intermediate-level tutorial assumes you’re comfortable using SwiftUI and that you have previously built an iOS app using Xcode and Swift.

Getting Started

Meet Buzzy, a sweet new game you’re developing! Buzzy is an intrepid worker bee looking for pollen-filled flowers, and she needs the player’s help to find them. Once you’re done, this game is sure to create a buzz.

Download the starter project using the Download Materials button at the top or bottom of this tutorial. You’re going to take a quick tour around the project to get familiar with it.

Exploring Buzzy

Build and run the project, and you’ll see Buzzy hovering near her hive, waiting for directions. Below her, there’s a field of flowers.

Buzzy opening game screen

ContentView is the main view of the app. It contains a Bee, a Hive, and a FlowerField, which contains a number of Flowers. Each flower has a unique identifier.

Right now, tapping a flower just prints that flower’s ID to the debug console. What you want it to do is tell Buzzy to fly down to the flower you tapped. To make that work, you’ll need the flower’s frame within the game screen. You also want the number of flowers to be dynamic and their layout unpredictable.

Your head might be abuzz with ways to do this! The bee is part of the main content view, as is the flower field. How do you tell the bee where to go when the player taps? SwiftUI is doing layout for you — will you need to take over that work and generate the layout and push that data down from the top? You could. And that’s probably the style you’re used to. But you’ll learn how to do this a different way, an opposite way.

Creating View Preferences to Switch Data Directions

The most common direction data flows in SwiftUI is top-down — for example, @Environment and @State. If you want a bidirectional data flow, you can use bindings, but you still have to pass the binding down from the top. Enter view preferences. View preferences offer a different way for data to flow, and you may have already used them.

If you’ve ever used preferredColorScheme(_:) on a view, you’ve used view preferences. In preferredColorScheme(_:), the child view communicates up to a presenting ancestor view, sending a color scheme preference. That’s the only publicly documented preference key, but there are undoubtedly others in use behind the scenes. Anywhere you specify information on a view that actually takes effect further up the tree (for example, adding toolbar items), SwiftUI is probably using the preferences mechanism behind the scenes.

How do preferences work? Imagine the view hierarchy of an app: At the top sits the queen view and down at the bottom are all the worker views. View preferences allow those little worker views to raise their hands and make suggestions. If the queen is listening, those suggestions will be heard.

There are three parts to making and using a view preference:

  1. Defining the preference key and the type of value it represents.
  2. Reporting a value for the preference key by a child view.
  3. Listening for those values on an ancestor view.

First, you’ll define the preference key.

Making a Preference Key

Start a new Swift file called TargetPreferenceKey.swift and add the following:

import SwiftUI

The value type you’ll need for the preference key needs to store an identifier for the flower and information for its frame:

struct TargetModel: Equatable, Identifiable {
  let id: Int
  let anchor: Anchor<CGRect>
}

TargetModel is a simple structure that stores an Int to match the flower’s ID and an Anchor containing a CGRect for the bounds of the view.

Anchor is a convenient type for obtaining geometry information from a view. You can use that information in different contexts to obtain a specific, relative geometry value for that view. You’ll see how useful it can be shortly.

TargetModel conforms to Equatable and Identifiable which you’ll need to support observing changes in the value.

Next, add a new PreferenceKey:

// 1.
struct TargetPreferenceKey: PreferenceKey {
  // 2.
  static var defaultValue: [TargetModel] = []
  // 3.
  static func reduce(
    value: inout [TargetModel], 
    nextValue: () -> [TargetModel]
  ) {
    value.append(contentsOf: nextValue())
  }
}
  1. Your preference key must conform to PreferenceKey.
  2. The first requirement of the protocol is a static defaultValue to set the initial value for the key.
  3. The second requirement is the static reduce(value:nextValue:). Whenever a view sets a value for a preference key, SwiftUI calls this method, passing in the key’s current value as an inout. It’s your job to call nextValue to obtain the new preference value and store it appropriately. The plan is that each flower will set a value for the key, reporting its ID and Anchor. The reducer gathers all of the values into a single array, which becomes the value for the preference key.

Your key’s type — TargetPreferenceKey.self in this case — is its global identity. There is only one value for the key across your entire app. Any number of descendant views may set a value for the key, and any number of ancestor views may be observing the key.

SwiftUI calls reduce(value:nextValue:) in view-tree order, building a single value from every view that reports a preference value. Reporting a preference value is what you’re doing in the next section.

Setting the Child’s View Preference Key Value

You’ll now add a way for each flower to report a value for TargetPreferenceKey. Open Flower.swift and add the following modifier after the onTapGesture closure:

.anchorPreference(key: TargetPreferenceKey.self, value: .bounds) { anchor in
  [TargetModel(id: id, anchor: anchor)]
}

anchorPreference(key:value:) is a convenient view modifier that allows you to obtain an Anchor for the view and transform it into a value you can store in a preference key. In the code above, you’re taking the view’s bounds as the Anchor and returning an array of [TargetModel], which is what your preference key will append to the global array for the key. If you were interested in single points instead of the entire rectangle, there are also anchors for the center, top, bottom and so on which return single points.

OK. You have two out of three pieces in place. The last piece is the handler to observe changes to your preference key.

Adding a Handler to Observe Preference Key Values

Open ContentView.swift and add the following modifier to ZStack in body:

.onPreferenceChange(TargetPreferenceKey.self) { value in
  print(value)
}

This is how you define your preference key handler. Now, each time the key’s value changes, SwiftUI calls this closure. Right now, all the closure does is print the value.

Build and run. Watch the Xcode console and you’ll see the value printed:

[
Buzzy.TargetModel(id: 0, anchor: SwiftUI.Anchor<__C.CGRect>(...)), 
Buzzy.TargetModel(id: 1, anchor: SwiftUI.Anchor<__C.CGRect>(...)), 
Buzzy.TargetModel(id: 2, anchor: SwiftUI.Anchor<__C.CGRect>(...)), 
Buzzy.TargetModel(id: 3, anchor: SwiftUI.Anchor<__C.CGRect>(...)), 
Buzzy.TargetModel(id: 4, anchor: SwiftUI.Anchor<__C.CGRect>(...)), 
Buzzy.TargetModel(id: 5, anchor: SwiftUI.Anchor<__C.CGRect>(...))
]

You can see an ID value for each flower, then a lot of nonsense representing the anchor. You’re seeing information being sent back up the view tree — nowhere in ContentView have you specified how many flowers should be in the app, or what their identifiers are. The preference key is working! Next, you need to turn those anchors into something useful.

Using Preference Key Values

In this section, you’re going to use your reported preference values to finally get Buzzy flying around her field.

Start by adding a new property to ContentView:

@State var targets: [TargetModel] = []

This is the state property that stores the preference key value when it updates.

Next, remove print(value) from onPreferenceChange(_:perform:) and replace it with:

targets = value

This ensures ContentView updates the state property for all the flower locations. Any time the layout of the flowers changes, you’ll have the latest information ready to use.

Using Preference Key Values to Help Buzzy Navigate

Currently, a tap signals to Buzzy that she should go to the tapped flower, but she doesn’t know how to get there. Next, you’ll help Buzzy find her way to the tapped flower.

In ContentView, update the onChange(of:) modifier where you listen to changes of selectedTargetId. Replace the print statement with the following code:

// 1.
guard let target = targets.first(where: { $0.id == newValue }) else { 
  return 
}
// 2.
let targetFrame = geometry[target.anchor]
// 3.
beePosition = CGPoint(x: targetFrame.midX, y: targetFrame.midY)

Here’s all that’s happening:

1. The handler uses the new ID value to find the TargetModel that matches the flower.
2. geometry is the GeometryProxy for the current view. This is why you are using anchors in the preference value — you can use an anchor as a subscript on geometry and get back the flower’s frame converted to the local coordinate space. Isn’t that just amazingly useful?
3. Using the flower’s frame, you’re calculating the midpoint of the flower and setting beePosition to match. The existing animation takes over and Buzzy takes flight.

Build and run. Tap all the flowers and check if Buzzy makes it around the field.

Buzzy travelling across the flower field

Pretty sweet! (Well, for Buzzy anyway.)

Change the number of flowers in FlowerField or rotate the simulator or device so the layout changes. No matter where the flowers are on the screen, Buzzy will always know where to go now, thanks to your use of view preferences.

Buzzy flying over five flowers

The stinger in PreferenceKey‘s tail: Be careful how you use PreferenceKey. You may have noticed when you printed the value to the console that all six flower values were output at once. That’s because when SwiftUI computes the body of the whole view graph, it includes the preference keys. If you take a preference key value and use it in a way that triggers another reevaluation of the view body, that changes the preference value again, and you’ll find yourself in a real hornets’ nest of trouble. You may trigger SwiftUI exceptions for updating the view graph mid-update or potentially create an infinite loop.

In the case of Buzzy, you’re just storing the value until a tap uses it so you’re safe here. Just be aware. If you’re not careful, you’ll find out that PreferenceKey has a little stinger in its tail!

Showing Buzzy the Way Home With View Preferences

You may have noticed a glaring omission: Buzzy is stuck going from flower to flower and can’t go home to her hive. Considering that Hive is also a view, you can set a view preference for it to handle taps so Buzzy can get home.

First, you need a special ID for the hive. Open TargetPreferenceKey.swift and add a static property to TargetModel:

static let hiveID = 999

Next, open Hive.swift and add the required properties to Hive:

let onTap: Binding<Int>
private let id = TargetModel.hiveID

Hive now has an ID and a binding in the same way as the flowers do. You are using a let of type Binding instead of an @Binding property because you don’t need this view to be recomputed when the value changes. You’re only interested in sending the results of a tap up to the parent view.

Fix the build error in the preview by updating the initializer:

Hive(onTap: .constant(0))

Hive will also need to register its anchor preference and have a tap handler. Add the following modifiers to the ZStack in the view body:

.anchorPreference(key: TargetPreferenceKey.self, value: .bounds) { anchor in
  // 1
  [TargetModel(id: id, anchor: anchor)]
}
.onTapGesture {
  // 2
  onTap.wrappedValue = id
}

Here’s what’s happening in the code above:

  1. Just like the flowers, the hive registers its frame anchor and identifier with your preference key.
  2. When the user taps the hive, the hive’s identifier is sent up to the parent via the binding.

Open ContentView.swift and fix the compiler error by changing the line where Hive is initialized:

Hive(onTap: $selectedTargetID)

Build and run. Tap the hive and call Buzzy back to her home sweet honeycomb.

Buzzy returning home

Using view preferences has allowed you to gather information about the layout of your view tree, in order to tell Buzzy where to go. In the next section, you’ll use all of that information in another way.

Generating a Minimap Overlay

You’re going to use the frame information you’ve collected from the flower field and hive to make a minimap and add it to the game screen. A minimap is a small representation of a larger area — lots of games have them, and even Xcode has gotten in on the action recently — you can show a minimap of the file you’re working in by pressing Control-Shift-Command-M.

Open ContentView.swift and add a new property to ContentView:

let miniMapScale: CGFloat = 0.25

You’ll use this value to scale the frame information from the TargetModels and draw a minimap.

After the call to onChange(of:perform:), add:

// 1.
.overlayPreferenceValue(TargetPreferenceKey.self) { mapTargets in
  // 2.
  ZStack {
    RoundedRectangle(cornerRadius: 8, style: .circular)
      .stroke(Color.black)
  }
  // 3.
  .frame(
    width: geometry.size.width * miniMapScale,
    height: geometry.size.height * miniMapScale)
  .position(x: geometry.size.width - 80, y: 100)
}

Here’s what this code is doing:

  1. overlayPreferenceValue(_:_:) is a view modifier that lets you transform the values of a preference key into an overlay view.
  2. At the moment, your overlay is just a rounded rectangle, you’ll add more interesting content shortly.
  3. You use miniMapScale to create a scaled-down version of the content view’s frame using the geometry reader.

Build and run. You’ll see the minimap rectangle in the top right.

Buzzy minimap rectangle

The minimap is empty right now. Next, you’ll fill it with icons to match the game screen.

Adding Minimap Icons

You probably noticed the unused mapTargets parameter in the overlayPreferenceValue closure. This parameter contains the current value of the preference key. For you, that value is an array of TargetModels containing the frames and identifiers of all the flowers and the hive. You’re going to transform the frames into relative sizes and positions within the minimap using the scale value, then populate the minimap with icons.

In ContentView, add a ForEach to your minimap view after the RoundedRectangle, but within the same ZStack:

// 1.
ForEach(mapTargets) { target in
  // 2.
  let targetFrame = geometry[target.anchor]
  // 3.
  let mapTargetFrame = targetFrame.applying(
    CGAffineTransform(scaleX: miniMapScale, y: miniMapScale))

  // 4.
  switch target.id {
  // 5.
  case TargetModel.hiveID:
    Image(systemName: "house.fill")
      .foregroundColor(.purple)
      .frame(width: mapTargetFrame.width, height: mapTargetFrame.height)
      .position(x: mapTargetFrame.midX, y: mapTargetFrame.midY)
  // 6.
  default:
    Image(systemName: "seal.fill")
      .foregroundColor(.yellow)
      .frame(width: mapTargetFrame.width, height: mapTargetFrame.height)
      .position(x: mapTargetFrame.midX, y: mapTargetFrame.midY)
  }
}

This is a lot of code, so here’s a breakdown of what is happening:

  1. ForEach is going to create a view for each TargetModel.
  2. GoemetryProxy extracts the frame for each target (and again, marvel at how useful and easy this is!).
  3. You use an affine transform to make a minimap-relative frame by scaling the original frame of the target down.
  4. You switch on each target’s ID to distinguish between flowers and the hive.
  5. house.fill adds a purple icon for the hive, and uses the map target frame to specify a suitable size and position.
  6. seal.fill adds a yellow icon for all the other targets (i.e. flowers) and assigns a size and a position for them too.

Build and run. The minimap now shows a scaled-down version of the game screen.

Buzzy minimap with icons

Play around with the number of flowers in the field or rotate the device. You’ll see that the minimap is always correct!

Buzzy minimap with 7 flowers

But, wait! Something’s missing from the minimap. Buzzy’s not there. She’ll need a target ID to make it on the minimap.

Adding Buzzy’s Flight Path to the Minimap

To add Buzzy to the minimap, open TargetPreferenceKey.swift and add the ID to TargetModel, like you did for the hive:

static let beeID = 100

In Bee.swift, add the anchor preference to the call to beeBody(rect:) in body:

.anchorPreference(key: TargetPreferenceKey.self, value: .bounds) { anchor in
  [TargetModel(id: TargetModel.beeID, anchor: anchor)]
}

Like you did with the flowers and the hive, you’re extracting the bounds of Buzzy’s body in Anchor.

Surprisingly, there’s only one more step! Back in ContentView.swift, add a new case to the minimap’s ForEach before default:

case TargetModel.beeID:
  Image(systemName: "circle.fill")
    .foregroundColor(.orange)
    .frame(width: mapTargetFrame.width, height: mapTargetFrame.height)
    .position(x: mapTargetFrame.midX, y: mapTargetFrame.midY)

A simple orange circle now represents Buzzy on the minimap. The circle’s relative position and size within the minimap will match Buzzy on the game screen.

Build and run. Tap flowers to send Buzzy around the flower field and watch her matching path on the minimap.

Buzzy on the minimap

You’re technically done. But your minimap is maxi-sized in terms of code. Next, you’ll learn how to clean up the work you’ve done with view preferences.

Converting to a Custom View Modifier

It’s generally good SwiftUI practice to extract large blocks of code into separate views or modifiers to make them more portable and to make views more readable. For your minimap, you’re going to add a custom view modifier to separate the minimap code from the main view.

Add a new Swift file called Minimap.swift, then import SwiftUI and add an empty ViewModifier:

import SwiftUI
  
struct MiniMap: ViewModifier {
  func body(content: Content) -> some View {
    content
  }
}

A ViewModifier takes a view, does things to it and returns a new view. Currently, you simply return the view you’ve been given.

Next, add two properties to the struct:

let geometry: GeometryProxy
let miniMapScale: CGFloat = 0.25

Other than your preference key, these are the two required elements the minimap needs to calculate its contents.

Next, cut the entire .overlayPreferenceValue(_:_:) modifier (including the closure) from ContentView and paste it into the body in Minimap.swift, so it’s applied to content.

Finally, add a custom View extension to the top of Minimap.swift:

extension View {
  func miniMap(geometry: GeometryProxy) -> some View {
    self.modifier(MiniMap(geometry: geometry))
  }
}

This makes it easy to apply your minimap to any view. Back in ContentView.swift, where .overlayPreferenceValue(TargetPreferenceKey.self) { ... } used to be, add the call to your new custom view extension:

.miniMap(geometry: geometry)

Build and run. The minimap functions perfectly and the code inContentView is now much neater.

Buzzy on the minimap

Where to Go From Here?

Nice work! With your help, Buzzy is the most productive bee in the hive.

You can download the completed project using the Download Materials button at the top or bottom of this tutorial.

If you’re interested, there are a few more interesting methods worth exploring in the view preferences API:

  • You used anchorPreference(key:value:transform:) in Buzzy to set preference values relating to layout, but you can also use preference(key:value:) to set preference values that aren’t related to view layouts. You can send any data you like back up the tree — a title to be used in a container view, for example.
  • The two modifiers above can’t be called multiple times on the same view — only the latest value will be registered. If you need to add multiple pieces of information to your preference value, you must use transformPreference(_:_:) and transformAnchorPreference(key:value:transform:). For example, you might want to obtain multiple anchor values for a single preference key. Buzzy could have used the center anchor for navigation, and the bounds anchor for the minimap.
  • You used an overlay to place the minimap on top of the view. If you want to use preference values to create a contextual background instead, use backgroundPreferenceValue(_:_:) to generate a view’s background by transforming a preference key value.

With only a few modifications, you could also turn the Buzzy minimap view modifier into a more general one that you can apply to any view hierarchy in any app! You could use PreferenceKey to allow a view to set not only its frame and ID but also an icon or a color. The minimap could then get everything it needs to render just from the preference values.

If you’re buzzing about view preferences and looking for more reasons to use them, you can try:

  • Automatically generating table of contents from heading views.
  • Generating an outline with collapsible top-level items, including a counter overlay badge.
  • Controlling layout guides, based on biggest or smallest child views.
  • Changing a view’s background based on the number of child views within.

I hope you enjoyed this tutorial about view preferences in SwiftUI. If you have any questions or comments, please join the forum discussion below.

More like this

Contributors

Comments