Inspecting the View Tree – Part 2: AnchorPreferences

In the first part of this article, we introduced the use of preferences. These are very useful to communicate information upwards (from children to ancestors). By defining the associated type of our PreferenceKey, we now know that we can put anything we want in there.

In this second part, the moment for Anchor Preferences to make their appearance has arrived. At the time of writing, I could not find a single document, blog post or article, that would explain how to use these elusive tools. So please join me in exploring this uncharted territory.

Anchor Preferences are not so intuitive at first, but once we get to know them, we won’t be able to let them go. To make things simple, we are going to address the same problem we solve in the first part. It is good you are already familiar with the challenge, so you can concentrate on all these exciting new features. Unlike the previous solution, we will no longer need to use space coordinates, and we will be replacing .onPreferenceChange() with something else.

So, here it is again: we want to create a border that moves from one month name to another, using an animation:

Example

Anchor Preferences

Put your hands together, and give a warm welcome to: Anchor<T>. This is an opaque type that holds a value of type T, and T can be either CGRect or CGPoint. We normally use Anchor<CGRect> to access the bounds of a view, and Anchor<CGPoint> for other view properties such as top, topLeading, topTrailing, center, trailing, bottom, bottomLeading, bottomTrailing and leading.

Because it is an opaque value, we cannot use it by itself. Remember that GeometryProxy subscript getter from GeometryReader to the Rescue? Well now you know what it is for. When using the Anchor<T> value as an index to the geometry proxy, you get the represented CGRect or CGPoint value. And as a plus, you get it already translated to the coordinate space of the GeometryReader view.

We start by modifying the data handled by our PreferenceKey. In this case we replace CGRect by Anchor<CGRect>:

struct MyTextPreferenceData {
    let viewIdx: Int
    let bounds: Anchor<CGRect>
}

Our PreferenceKey remains unaltered:

struct MyTextPreferenceKey: PreferenceKey {
    typealias Value = [MyTextPreferenceData]
    
    static var defaultValue: [MyTextPreferenceData] = []
    
    static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

The MonthView is much more simple now. Instead of using .preference(), we call the modifier .anchorPreference(). Unlike the other method, here we can specify a value (in this case .bounds). This means that our transform closure gets an Anchor<CGRect> representing the bounds of the modified view. Similar to what we’ve done with normal preferences, we use it ($0) to create our MyTextPreferenceData value. This way, we no longer need to use GeometryReader inside a .background() modifier in order to get the bounds of the text view.

To understand it better, lets see the code:

sstruct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int
    
    var body: some View {
        Text(label)
            .padding(10)
            .anchorPreference(key: MyTextPreferenceKey.self, value: .bounds, transform: { [MyTextPreferenceData(viewIdx: self.idx, bounds: $0)] })
            .onTapGesture { self.activeMonth = self.idx }
    }
}

Finally, we update our ContentView. There are a couple of changes here. For starters, we no longer use .onPreferenceChange(). Instead, we call .backgroundPreferenceValue(). This is a modifier similar to .background() but with one big improvement: we get access to the preference array of the entire view tree. This way, we get all the bounds of all the month views, and we can use them to calculate where the border needs to be drawn.

In Xcode 11 beta 5, Apple silently remove Anchor<Value>‘s conformance with Equatable. That makes it a problem, if you want to use .onPreferenceChange(). That function, as you can probably imagine, requires the preference key value to be Equatable. Fortunately for us, in this example we are not using .onPreferenceChange(). Since the conformance was never deprecated and silently removed, I am hopeful it will eventually return in the future, before the GM is released. I submitted a bug report (FB6912036). I encourage you to do the same.

There is still one place where we need to use GeometryReader. And that is to make sense of the Anchor<CGRect> values. Notice that we no longer need to worry about coordinate spaces, GeometryReader takes care of it.

struct ContentView : View {
    
    @State private var activeIdx: Int = 0
    
    var body: some View {
        VStack {
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "January", idx: 0)
                MonthView(activeMonth: $activeIdx, label: "February", idx: 1)
                MonthView(activeMonth: $activeIdx, label: "March", idx: 2)
                MonthView(activeMonth: $activeIdx, label: "April", idx: 3)
            }
            
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "May", idx: 4)
                MonthView(activeMonth: $activeIdx, label: "June", idx: 5)
                MonthView(activeMonth: $activeIdx, label: "July", idx: 6)
                MonthView(activeMonth: $activeIdx, label: "August", idx: 7)
            }
            
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "September", idx: 8)
                MonthView(activeMonth: $activeIdx, label: "October", idx: 9)
                MonthView(activeMonth: $activeIdx, label: "November", idx: 10)
                MonthView(activeMonth: $activeIdx, label: "December", idx: 11)
            }
            
            Spacer()
        }.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
            GeometryReader { geometry in
                self.createBorder(geometry, preferences)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            }
        }
    }
    
    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceData]) -> some View {
        
        let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
        
        let bounds = p != nil ? geometry[p!.bounds] : .zero
                
        return RoundedRectangle(cornerRadius: 15)
                .stroke(lineWidth: 3.0)
                .foregroundColor(Color.green)
                .frame(width: bounds.size.width, height: bounds.size.height)
                .fixedSize()
                .offset(x: bounds.minX, y: bounds.minY)
                .animation(.easeInOut(duration: 1.0))
    }
}

The .backgroundPreferenceValue() has its counterpart .overlayPreferenceValue(). It does the same thing, but instead of drawing behind, it does so in front of the modified view.

Multiple Anchor Preferences with one PreferenceKey

We now know there is more than one Anchor<T> value. There’s bounds, but we also have topLeading, center, bottom, etc. There may be a case where we need to get more than one of these values. However, as we will learn, it is not as easy as just calling .anchorPreference() on all of them. To illustrate, let’s solve the problem yet again.

This time though, instead of using Anchor<CGRect> to get the bounds of the month views, we will get two separate Anchor<CGPoint> values. One for the topLeading and the other for the bottomTrailing of the month view rect. Mind you, getting Anchor<CGRect> was a better approach for this specific problem. However, the reason we are using this third method, is just to learn how to get more than one anchor preference on the same view.

We start by modifying MyTextPreferenceData to hold both extremes of the rect. This time we need to make them optional, because they both cannot be set at the same time.

struct MyTextPreferenceData {
    let viewIdx: Int
    var topLeading: Anchor<CGPoint>? = nil
    var bottomTrailing: Anchor<CGPoint>? = nil
}

The PreferenceKey remains the same:

struct MyTextPreferenceKey: PreferenceKey {
    typealias Value = [MyTextPreferenceData]
    
    static var defaultValue: [MyTextPreferenceData] = []
    
    static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

Our MonthView now needs to set two anchor preferences. However, if we add more than one call to .anchorPreference() on the same view, only the last one survives. Instead, we need to call .anchorPreference() once. Then we use .transformAnchorPreference() to fill the missing data:

struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int
    
    var body: some View {
        Text(label)
            .padding(10)
            .anchorPreference(key: MyTextPreferenceKey.self, value: .topLeading, transform: { [MyTextPreferenceData(viewIdx: self.idx, topLeading: $0)] })
            .transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
                value[0].bottomTrailing = anchor
            })
            
            .onTapGesture { self.activeMonth = self.idx }
    }
}

Finally, we update .createBorder() accordingly, so it does its math with the two points, instead of a rect:

struct ContentView : View {
    
    @State private var activeIdx: Int = 0
    
    var body: some View {
        VStack {
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "January", idx: 0)
                MonthView(activeMonth: $activeIdx, label: "February", idx: 1)
                MonthView(activeMonth: $activeIdx, label: "March", idx: 2)
                MonthView(activeMonth: $activeIdx, label: "April", idx: 3)
            }
            
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "May", idx: 4)
                MonthView(activeMonth: $activeIdx, label: "June", idx: 5)
                MonthView(activeMonth: $activeIdx, label: "July", idx: 6)
                MonthView(activeMonth: $activeIdx, label: "August", idx: 7)
            }
            
            Spacer()
            
            HStack {
                MonthView(activeMonth: $activeIdx, label: "September", idx: 8)
                MonthView(activeMonth: $activeIdx, label: "October", idx: 9)
                MonthView(activeMonth: $activeIdx, label: "November", idx: 10)
                MonthView(activeMonth: $activeIdx, label: "December", idx: 11)
            }
            
            Spacer()
        }.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
            GeometryReader { geometry in
                self.createBorder(geometry, preferences)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            }
        }
    }
    
    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceData]) -> some View {
        let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
        
        let aTopLeading = p?.topLeading
        let aBottomTrailing = p?.bottomTrailing
        
        let topLeading = aTopLeading != nil ? geometry[aTopLeading!] : .zero
        let bottomTrailing = aBottomTrailing != nil ? geometry[aBottomTrailing!] : .zero
        
        
        return RoundedRectangle(cornerRadius: 15)
            .stroke(lineWidth: 3.0)
            .foregroundColor(Color.green)
            .frame(width: bottomTrailing.x - topLeading.x, height: bottomTrailing.y - topLeading.y)
            .fixedSize()
            .offset(x: topLeading.x, y: topLeading.y)
            .animation(.easeInOut(duration: 1.0))
    }
}

Nested Views

So far, we have been working with preferences in sibling views (or cousins). However, things are more challenging, when we need to set preferences on nested views. The .transformAnchorPreference() becomes even more important then. For example, if you have two views, which are parent and child respectively, setting .anchorPreference() on both will not work. The child’s closure will not be executed. To solve this, you need to specify anchorPreference on the child and transformAnchorPreference on the parent. But don’t worry, we will see that in detail.

What’s Next

In the final part of this series, we will work a different example that will illustrate that. We are going to build a mini map view. The mini map will be constructed by reading the tree view of a form. We will also see how modifying the form’s tree view, will have its immediate effect on the mini map, which is only reacting to changes in the preferences of the form’s tree views.

Here you have a sneak peek of what’s to come:

Mini Map

Feel free to comment, and make sure you come back for the last part of this article. If you would like to be notified, follow me on twitter. Link is below. Until next time.

27 thoughts on “Inspecting the View Tree – Part 2: AnchorPreferences”

  1. Wow!
    You drawing the map to this uncharted territory and found a door…..and opened it!!
    Creaking SwiftUI door opening slowly…..and a bright light is beginning to be case on the treasure within.
    Nice research and writing style (as a side: I like the typeface and point size of the website)

    We all learn by sharing what we know

    Reply
    • Thank you! Next week we will end this series, and we will finally come out of the dark forest 😉

      Nice of you to notice the typeface and point size. After staring so much at the code, my eyes need relaxing!

      Cheers,
      Javier.-

      Reply
  2. Hi Javier, like Smartdog mentioned about the font, it reads very well.
    I am busy to make a graphical node’s view. With your insight written here, I see how can I make more progress. Thank you. Apple is always sparing in samples.

    I hope to see more articles from you about the graphic drawing world around SwiftUI. Especially mouse drag movements and connecting between two graphical elements through a line and stay connected during dragging.

    Thank you for your articles, and looking forward to the third episode.
    Have a nice weekend.

    Reply
  3. This really is done well – thanks for the great effort.

    I’m hoping you can help me understand the anchors better.

    You wrote “For example, if you have two views, which are parent and child respectively, setting .anchorPreference() on both will not work. The child’s closure will not be executed.” Relative to that, I’m wondering why it wouldn’t work to have different preference data types for the separate views to permit using anchorPreference on both.

    If it would not (I didn’t try to test that yet), it suggests that the anchor has some more global scope. Do you know how the namespacing works such that anchors are actually separate, and how we make sense of the associated scoping and lifetimes?

    Reply
    • Hi Barry, you can totally have separate PreferenceKeys and then you can use multiple anchorPreference calls even on the same view. The challenge is not setting the values, but retrieving them together. Maybe in your scenario, you use one PreferenceKey for something in one place, and then a different PreferenceKey for something else, somewhere else. If that is the case, then go ahead, it will work. In the example of this article, however, I was trying to demonstrate how to set two different anchors on the same preference and view.

      You should know that in addition to your custom preference keys, the framework uses its own (private) preference keys for all sorts of things. We cannot seem them, but our preferences live together with all system preferences. So having more than one key is absolutely possible.

      One example of the framework using PreferenceKeys is the .navigationBarTitle(Text("My Bar")). When you use this method on a descendent of a NavigationView, you are simply setting a private preference that NavigationView will query, so it can set the title of the pushed view. Of course you can use your own view, and it will not interfere with the system’s preference for the title bar.

      I hope this helps. The best way to understand this though, is to actually try to use it. At least this is how I figure it out.

      Cheers,
      Javier.

      Reply
    • Nice. I am glad you found the information in this blog useful! Thank you for the link, I’ll definitely check it out.

      Cheers,
      Javier.-

      Reply
  4. This blog is a fantastic resource. Thank you!

    I didn’t like the `!= nil` + force unwrapping pattern in this article though. You don’t also have some secret trick to refer to subscript getters as closures, do you? Otherwise I’ll go with `[$0]`:

    “`
    let topLeading = aTopLeading.map { geometry[$0] } ?? .zero
    “`

    Reply
    • Indeed. The Anchor conformance to Equatable was never restored. If you need to use preferences with Anchor, the only way is through .backgroundPreferenceValue() and .overlayPreferenceValue(), instead of .onPreferenceChange().

      Reply
    • Hi YangXu, I’m not sure what you mean. The anchorPreference closure will get called when the device is rotated (provided the view changes some aspect of its geometry). However, for the anchorPreference to be called, preferences need to be consumed somewhere. For example, by having an overlayPreferenceValue or backgroundPreferenceValue. If the preferences being set are not used anywhere in your code, SwiftUI is smart enough not to execute the anchorPreference closure.

      Reply
  5. Thank you for this great series! This is the best SwiftUI reference I’ve ever found on the internet.

    I have a question: Is there a reason you wrapped `createBorder` with `ZStack` inside the `GeometryReader`?

    Reply
    • Well spotted. To be honest, I don’t remember where it came from. Perhaps the original code was more complex and I reduced it until the ZStack became superfluous. I fixed the code already, thanks.

      Reply
  6. First I’d like to say thank you for these posts, they are very interesting so far.

    I realize I’m late to add this comment, but as I was reading through the “Multiple Anchor Preferences with one PreferenceKey” I noticed in the headers that there is also an extension on Anchor.Source to initialize it with an array of the same anchor source types (there is another that allows for T to be optional, which is curious as well):

    public init(_ array: [Anchor.Source]) where Value == [T]

    You can use this to create an Anchor<Array>, representing whichever points you want in a single anchor (in this case obviously, .topLeading and .bottomTrailing). Then you can avoid the transformAnchorPreferences step as well as having optional types in MyTextPreferenceData. Forgive the poor formatting, but here are the modified parts of the code:

    struct MyTextPreferenceData {
    let viewIdx: Int

    // Index 0 is the .topLeading anchor, and index 1 is .bottomTrailing anchor
    var anchors: Anchor<Array>
    }

    struct ContentView: View {
    // Other parts of this are unchanged

    func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceData]) -> some View {
    let p = preferences.first(where: { $0.viewIdx == self.activeIdx })

    let points = p != nil ? geometry[p!.anchors] : [.zero, .zero]
    let topLeading = points[0]
    let bottomTrailing = points[1]

    return RoundedRectangle(cornerRadius: 15)
    .stroke(lineWidth: 3.0)
    .foregroundColor(Color.green)
    .frame(width: bottomTrailing.x – topLeading.x, height: bottomTrailing.y – topLeading.y)
    .fixedSize()
    .offset(x: topLeading.x, y: topLeading.y)
    .animation(.easeInOut(duration: 1.0))
    }
    }

    struct MonthView: View {
    @Binding var activeMonth: Int
    let label: String
    let idx: Int

    var body: some View {
    Text(label)
    .padding(10)
    .anchorPreference(key: MyTextPreferenceKey.self, value: .init([.topLeading, .bottomTrailing]), transform: { [MyTextPreferenceData(viewIdx: self.idx, anchors: $0)] })
    .onTapGesture { self.activeMonth = self.idx }
    }
    }

    Reply
    • I read through all the comments before leaving this, but failed to notice the one right above mine from Pan which calls out the same initializer I discovered. My mistake.

      Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close