This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We reimplement SwiftUI's anchors to better understand what they do, starting with the bounds anchor.

00:06 Today we'll start a new series in which we try to reimplement anchors — not because we need to, but because reimplementing anchors helps us understand how they really work, how to use them, and what their limitations are. We also did this with the matched geometry effect a while ago.

00:43 The advantage of anchors is that we can reimplement them in SwiftUI itself. This is different from when we tried to reimplement SwiftUI's layout algorithm, where we had to step outside of SwiftUI and build it from scratch.

01:00 Anchors are an API we don't use very often — and that's why we thought we'd examine them by writing them ourselves and maybe discover new use cases for them as well.

Anchors

01:18 An anchor helps us do two things. First, it communicates a geometric value — like a rect, a point, or a size — from one point in the view tree to another. The value is propagated from the source view up the view tree via a preference, and it can be used in any other part of the view tree. The second thing an anchor can do is convert the geometric value into the local coordinate space of the view where we want to use it.

01:41 A possible use case for this could be the following: Let's say we've measured a point deep inside a nested view and we want to highlight this location. An anchor lets us communicate the point all the way up the view tree. In another part of the tree, we can resolve the anchor to the local coordinate space and put an indicator over the measured point without having to go back down to the place where the point was defined. Under the hood, an anchor seems to store its geometric value in the global coordinate space, i.e. relative to the app window or to the simulator screen.

02:42 Our first step in reimplementing anchors is to write down an example using the existing API. After that, we can look into the value that's being propagated, try to replicate the API, and make our own anchors work.

Example

02:58 Let's say we want to draw an ellipse around the "Hello, world" text of SwiftUI's boilerplate view. We can easily do so by creating an overlay with an ellipse, stroking the ellipse, and giving it some negative padding to make it draw around the source's frame:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .overlay {
                    Ellipse()
                        .stroke(Color.red, lineWidth: 1)
                        .padding(-10)
                }
        }
        .padding()
    }
}

03:38 Perhaps we have a good reason to draw this highlight at a different point in the view hierarchy. For instance, we could want the highlight to always be on top of other views. Or we might want to create a highlight indicator once and animate it from view to view. But if we simply move the overlay onto the VStack, it's obviously going to be drawn around the entire view:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .padding()
        .overlay {
            Ellipse()
                .stroke(Color.red, lineWidth: 1)
                .padding(-10)
        }
    }
}

04:13 An anchor can help us out here. We first create a preference key with an Anchor<CGRect>? as its value. In the key's reduce method, we take the first non-nil value from the view hierarchy:

struct HighlightKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>?

    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = value ?? nextValue()
    }
}

04:38 We create and propagate the anchor by calling the anchorPreference modifier. This function takes our key, an anchor value, and a transform function. For the value, we specify a description of what we want to measure. Using autocomplete, we can easily choose from a list of predefined values, like .bounds, which is a CGRect, or .leading, which is a CGPoint. We pick the view's bounds:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
    }
}

05:15 The transform function turns the anchor value into a value for the preference key. In this case, we receive a non-optional Anchor<CGRect>, but we need an optional anchor of the same type, so we can return $0 to let Swift make the conversion.

05:48 Now we can access the anchor by calling onPreferenceChange with the same preference key and an action closure. In the closure, we can print the anchor out to see what we've got, but this doesn't tell us much more than that we're dealing with an optional anchor of a CGRect. However, we can get some more information if we dump the value:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .onPreferenceChange(HighlightKey.self, perform: {
            dump($0)
        })
    }
}

06:17 This prints the following to the console, and it shows us that the anchor contains a rect whose origin is measured from the top-leading edge of our app, meaning this is a CGRect in the global coordinate space:

▿ Optional(SwiftUI.Anchor<__C.CGRect>(box: SwiftUI.(unknown context at $1cce82530).AnchorValueBox<SwiftUI.UnitRect>))
  ▿ some: SwiftUI.Anchor<__C.CGRect>
    ▿ box: SwiftUI.(unknown context at $1cce82530).AnchorValueBox<SwiftUI.UnitRect> #0
      - super: SwiftUI.AnchorValueBoxBase<__C.CGRect>
      ▿ value: (412.5, 241.0, 75.5, 16.0)
        ▿ origin: (412.5, 241.0)
          - x: 412.5
          - y: 241.0
        ▿ size: (75.5, 16.0)
          - width: 75.5
          - height: 16.0

06:41 Let's try drawing our ellipse around this rect. Rather than calling onPreferenceChange, storing the value in a state property, and then using it in an overlay, we can switch to overlayPreferenceValue to immediately create an overlay with the propagated anchor:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            Ellipse()
                .stroke(Color.red, lineWidth: 2)
                .padding(-10)
            }
        }
    }
}

07:50 This draws an ellipse around the entire VStack, because we aren't yet using the anchor value passed into the closure. To use the anchor, we first need to resolve it with a geometry reader.

08:23 But we also need to make sure that it's non-nil, so we try unwrapping the value first:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {

            }
        }
    }
}

08:58 Then, we add a geometry reader, and we use its proxy to resolve the anchor. This gives us a CGRect whose size we can use for the frame around our ellipse:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                }
            }
        }
    }
}

10:05 The ellipse looks correct in size, but it's not in the right location:

10:12 When we move the anchor over to the globe icon, the ellipse takes on the size of the icon (plus the negative padding). This confirms that the size is determined by the bounds of the anchor's source:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
            Text("Hello, world!")
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                }
            }
        }
    }
}

10:38 After we put the anchor back on the text view and we offset the ellipse with the X and Y position of the resolved rect's origin, the ellipse is drawn where we want it:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                }
            }
        }
    }
}

10:59 The frame we get from resolving the anchor is defined in the local coordinate space of the geometry reader, which is equal to the overlay's coordinate space. That's why we can do an offset with the frame's origin to move the ellipse to the correct spot.

11:30 We've done all of this using SwiftUI's anchors. Now the question is, how do we implement this ourselves?

Recreating the API

11:41 We need an anchor struct, and it should be generic over any type of value. In our example, this type is CGRect, but depending on the anchor's source, it could also be CGPoint or CGSize:

public struct MyAnchor<Value> {

}

11:58 We also need our own version of the anchorPreference method. This method is generic over the anchor's value type and the preference key type. It takes a key and a source for the anchor as its first two parameters. In SwiftUI's version, the source parameter is of type Anchor<Value>.Source, so we need to add a Source type on our anchor as well:

public struct MyAnchor<Value> {
    public struct Source {
    }
}

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, ...) -> some View {

    }
}

12:55 The third parameter is a transform function that turns an anchor into a value that can be propagated with the preference key:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {

    }
}

13:32 The anchor's source describes what we want to measure — in our example, we use it to request the bounds of the source. This is only a description of the value we'll measure, because we don't yet know the actual bounds at the time of initializing the anchor:

public struct MyAnchor<Value> {
    public struct Source {
        var measure: (CGRect) -> Value
    }
}

extension MyAnchor<CGRect>.Source {
    public static var bounds: Self {

    }
}

14:34 To do the measuring, the source struct needs to receive the global frame. With that frame, we can measure things like the bounds and the top-leading points. So we add a measure property, which is a function that takes a CGRect in the global coordinate space and returns an anchor value:

extension MyAnchor<CGRect>.Source {
    public static var bounds: Self {
        Self(measure: { $0 })
    }
}

15:47 The value returned by the measure function is also defined in the global coordinate space. We need to store this value in the anchor:

public struct MyAnchor<Value> {
    var value: Value

    public struct Source {
        var measure: (CGRect) -> Value
    }
}

Implementation

16:39 To implement myAnchorPreference, we need to measure the view on which we're installed, pass the rect into a Source, and then turn that into an anchor and propagate it up.

17:02 To measure the view, we need a geometry reader. We get the view's frame in the global coordinate space from the geometry proxy:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {
        overlay(GeometryReader { proxy in
            let frame = proxy.frame(in: .global)



        })
    }
}

17:35 We pass this frame into the measure function of value, which gives us the geometric value for the anchor:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {
        overlay(GeometryReader { proxy in
            let frame = proxy.frame(in: .global)
            let anchorValue = value.measure(frame)
            let anchor = MyAnchor(value: anchorValue)

        })
    }
}

18:22 We call the transform function to turn this anchor into a preference value and, finally, we propagate the anchor up using the key:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {
        overlay(GeometryReader { proxy in
            let frame = proxy.frame(in: .global)
            let anchorValue = value.measure(frame)
            let anchor = MyAnchor(value: anchorValue)
            Color.clear.preference(key: key, value: transform(anchor))
        })
    }
}

19:22 To compare our results to SwiftUI's implementation, we replicate the example using our own API. We need a different preference key for this, because we want to propagate a MyAnchor instead of an Anchor:

struct MyHighlightKey: PreferenceKey {
    static var defaultValue: MyAnchor<CGRect>?

    static func reduce(value: inout MyAnchor<CGRect>?, nextValue: () -> MyAnchor<CGRect>?) {
        value = value ?? nextValue()
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
                .myAnchorPreference(key: MyHighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                }
            }
        }
        .overlayPreferenceValue(MyHighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                }
            }
        }
    }
}

20:53 We still need a subscript on the geometry proxy to resolve our anchor. This subscript takes a MyAnchor<Value>, and it should return a Value in the local coordinate space:

extension GeometryProxy {
    subscript<Value>(_ anchor: MyAnchor<Value>) -> Value {

    }
}

21:41 Within the geometry proxy, we have access to the view's frame in global coordinates, and we should be able to somehow compute a local anchor value from that frame:

extension GeometryProxy {
    subscript<Value>(_ anchor: MyAnchor<Value>) -> Value {
        let s = frame(in: .global)
        let o = anchor.value

    }
}

22:40 s is the frame of the current view (or self) in global coordinates, and o is the other frame in global coordinates. Assuming the anchor's value is a CGRect, we can offset the o frame to convert it into the local coordinate space of s:

extension GeometryProxy {
    subscript(_ anchor: MyAnchor<CGRect>) -> CGRect {
        let s = frame(in: .global)
        let o = anchor.value
        return o.offsetBy(dx: -s.origin.x, dy: -s.origin.y)
    }
}

Comparing Results

24:32 Let's see if this works. We give a lower opacity to the overlay using our custom implementation. We now see both the red and blue ellipses in the same spot:

25:21 And when we move the anchors to the globe icon, the ellipses get drawn around the icon:

25:41 We add a Boolean state property and a toggle to easily switch between SwiftUI's implementation and our own. The Boolean determines the opacity for each overlay:

struct ContentView: View {
    @State private var myVisibility = true

    var body: some View {
        VStack {
            Toggle("Show MyAnchor implementation", isOn: $myVisibility)
                .padding(.bottom, 30)
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
                .myAnchorPreference(key: MyHighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                        .opacity(myVisibility ? 0 : 1)
                }
            }
        }
        .overlayPreferenceValue(MyHighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                        .opacity(myVisibility ? 1 : 0)
                }
            }
        }
    }
}

27:16 We see no difference when toggling between the two implementations, which means we're on the right track.

27:21 There are more things to be done. To add support for different kinds of values other than CGRect, we'll definitely need to change the subscript on GeometryProxy. We should also think about how a view might be transformed and how this affects the resolution of our anchor. Let's continue next week.

Resources

  • Sample Code

    Written in Swift 5.7

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

140 Episodes · 49h57min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes