Swift Talk # 354

Connecting Lines with Anchors (Part 1)

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 use SwiftUI's anchors to draw connecting lines between multiple views in the view hierarchy.

00:06 In the previous three episodes, we examined how SwiftUI's anchors work by reimplementing them. Today, we want to look at an example for which anchors come in handy.

00:19 Something you see in mapping or transportation apps is a list of stops. On the left of each stop, there's a dot or an icon, and the dots are connected with vertical lines. Drawing this in SwiftUI isn't trivial, because we don't necessarily know the height of each row or the location of the dots. This is where anchor preferences come in: they let us propagate the frames of those dots or icons to a common ancestor view, where we can connect them.

01:06 Using a geometry reader and regular preferences would also work, but we'd have to manually convert the measured frames into the global coordinate space, and then into the coordinate space of the view where we'll draw the connecting lines. With anchors, we don't have to worry about these coordinate space conversions.

Directions List

01:31 Let's get started by rendering some sample data into a list view. We've already defined a DirectionItem, which provides an icon and a name for a destination:

struct DirectionItem: Identifiable {
    var id = UUID()
    var icon: Image
    var text: String
}

let sample: [DirectionItem] = [
    .init(icon: Image(systemName: "location.circle.fill"), text: "My Location"),
    .init(icon: Image(systemName: "pin.circle.fill"), text: "Berlin Hauptbahnhof"),
    .init(icon: Image(systemName: "pin.circle.fill"), text: "Westend")
]

struct ContentView: View {
    var body: some View {
        DirectionList(items: sample)
            .padding()
    }
}

01:51 We write DirectionList as a list view that loops over the direction items we pass into it:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    item.icon
                    Text(item.text)
                }
            }
        }
    }
}

02:38 We apply the .inset list style with alternating background colors. We also add some vertical padding to each row:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    item.icon
                    Text(item.text)
                }
                .padding(.vertical, 5)
            }
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
    }
}

TODO screenshot at 02:59

03:05 The icons seem to all have the same size, but we're not entirely sure. To make sure they're centered horizontally, we give them a fixed width of 40 points. Normally, we'd want to take the current type size into account when determining how large the icons should be displayed, but that's outside this episode's scope:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    item.icon
                        .frame(width: 40)
                    Text(item.text)
                }
                .padding(.vertical, 5)
            }
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
    }
}

Propagating Bounds Anchors

03:52 To draw lines between the icons, we want to propagate their coordinates up, so we call the anchorPreference modifier. This takes a preference key, a value, and a transform function. We'll define the key in a bit. For value, we choose the view's .bounds, so that we can draw a connection from one view's bottom to another view's top:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    item.icon
                        .frame(width: 40)
                        .anchorPreference(key: ItemBoundsKey.self, value: .bounds, transform: /*...*/)
                    Text(item.text)
                }
                .padding(.vertical, 5)
            }
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
    }
}

04:54 With the preference key, we define the type of value that will be propagated. Because we'll need to know which frame belongs to which DirectionItem, we use a dictionary with item IDs as the keys and rect anchors as the values:

struct ItemBoundsKey: PreferenceKey {
    static let defaultValue: [DirectionItem.ID: Anchor<CGRect>] = [:]
    // ...
}

05:59 In the key's reduce method, we need to merge two dictionaries into one. The merge method on Dictionary takes a function that can decide which value should be used in case the same key is found in both dictionaries. This should never happen in our case, because the IDs are unique, so it doesn't matter which value we pick:

struct ItemBoundsKey: PreferenceKey {
    static let defaultValue: [DirectionItem.ID: Anchor<CGRect>] = [:]
    static func reduce(value: inout [DirectionItem.ID : Anchor<CGRect>], nextValue: () -> [DirectionItem.ID : Anchor<CGRect>]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}

06:27 Then we need a transform function that takes an Anchor<CGRect> and turns it into the type of dictionary value the preference key expects:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    item.icon
                        .frame(width: 40)
                        .anchorPreference(key: ItemBoundsKey.self, value: .bounds, transform: { [item.id: $0 ]})
                    Text(item.text)
                }
                .padding(.vertical, 5)
            }
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
    }
}

06:55 Each list row now propagates up a dictionary with a single key-value pair. One level up, the preference key's reduce function merges the dictionaries into one large dictionary. By calling overlayPreferenceValue with the preference key and a view builder, we can read this large dictionary and immediately build an overlay view with it:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            // ...
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
        .overlayPreferenceValue(ItemBoundsKey.self) { bounds in



        }
    }
}

Drawing Lines

07:40 In the overlay view builder, we can loop over pairs of values from the items property and look up the anchor for each item in the dictionary. To resolve the anchors, we'll need a geometry reader, so it makes sense to wrap the whole overlay in one of those. Inside the geometry reader, we'll need to loop over pairs of items so that we can draw lines between them. We create these pairs by zipping the items array with a copy of itself, dropping the first item from the copy:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            // ...
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
        .overlayPreferenceValue(ItemBoundsKey.self) { bounds in
            GeometryReader { proxy in
                let pairs = Array(zip(items, items.dropFirst()))

            }
        }
    }
}

09:53 We pass the pairs array into a ForEach view, and we provide a key path to the identifier of the pair's first item:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            // ...
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
        .overlayPreferenceValue(ItemBoundsKey.self) { bounds in
            GeometryReader { proxy in
                let pairs = Array(zip(items, items.dropFirst()))
                ForEach(pairs, id: \.0.id) { (item, next) in

                }
            }
        }
    }
}

10:27 Now we can look up the anchors of both items in the dictionary:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            // ...
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
        .overlayPreferenceValue(ItemBoundsKey.self) { bounds in
            GeometryReader { proxy in
                let pairs = Array(zip(items, items.dropFirst()))
                ForEach(pairs, id: \.0.id) { (item, next) in
                    if let from = bounds[item.id], let to = bounds[next.id] {

                    }
                }
            }
        }
    }
}

11:13 We can now draw a line between the two items. For this, we create a Shape that takes two points — from and to:

struct Line: Shape {
    var from: CGPoint
    var to: CGPoint

    func path(in rect: CGRect) -> Path {
        Path { p in
            p.move(to: from)
            p.addLine(to: to)
        }
    }
}

11:48 To construct a line, we need to extract a CGPoint from both anchors using the geometry proxy's subscript, which takes an anchor. The proxy's subscript gives us the rect from the anchor in the local coordinate space of the geometry reader. For now, we just take the origins of these rects, and we pass them into a Line shape:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            // ...
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
        .overlayPreferenceValue(ItemBoundsKey.self) { bounds in
            GeometryReader { proxy in
                let pairs = Array(zip(items, items.dropFirst()))
                ForEach(pairs, id: \.0.id) { (item, next) in
                    if let from = bounds[item.id], let to = bounds[next.id] {
                        Line(from: proxy[from].origin, to: proxy[to].origin)
                            .stroke()
                            .foregroundColor(.secondary)
                    }
                }
            }
        }
    }
}

12:53 This draws lines between the origins of the icons in the list view. But we want to draw lines between the bottom of the first item and the top of the second item, and so on. To make it easy to extract specific points from a rect, we write a subscript on CGRect that takes a unit point and returns a CGPoint. This helper — we've already seen it a few times before — allows us to specify predefined constants on UnitPoint, such as .top and .bottom:

extension CGRect {
    subscript(unitPoint: UnitPoint) -> CGPoint {
        CGPoint(x: minX + width * unitPoint.x, y: minY + height * unitPoint.y)
    }
}

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            // ...
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
        .overlayPreferenceValue(ItemBoundsKey.self) { bounds in
            GeometryReader { proxy in
                let pairs = Array(zip(items, items.dropFirst()))
                ForEach(pairs, id: \.0.id) { (item, next) in
                    if let from = bounds[item.id], let to = bounds[next.id] {
                        Line(from: proxy[from][.bottom], to: proxy[to][.top])
                            .stroke()
                            .foregroundColor(.secondary)
                    }
                }
            }
        }
    }
}

TODO screenshot at 14:29

Padding

14:23 Lines are now drawn between the icons. And they connect all the way to the edges of the icons, but it'd be prettier to have some space between the lines and the icons. We can try adding a bit of vertical padding to the icon. This basically modifies the bounds we're measuring:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    item.icon
                        .frame(width: 40)
                        .padding(.vertical, 3)
                        .anchorPreference(key: ItemBoundsKey.self, value: .bounds, transform: { [item.id: $0 ]})
                    Text(item.text)
                }
                .padding(.vertical, 5)
            }
        }
        // ...
    }
}

TODO screenshot at 15:05

15:02 We might also try adding the padding to the Line instead:

struct DirectionList: View {
    var items: [DirectionItem]

    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    item.icon
                        .frame(width: 40)
                        .anchorPreference(key: ItemBoundsKey.self, value: .bounds, transform: { [item.id: $0 ]})
                    Text(item.text)
                }
                .padding(.vertical, 5)
            }
        }
        .listStyle(.inset(alternatesRowBackgrounds: true))
        .overlayPreferenceValue(ItemBoundsKey.self) { bounds in
            GeometryReader { proxy in
                let pairs = Array(zip(items, items.dropFirst()))
                ForEach(pairs, id: \.0.id) { (item, next) in
                    if let from = bounds[item.id], let to = bounds[next.id] {
                        Line(from: proxy[from][.bottom], to: proxy[to][.top])
                            .stroke()
                            .foregroundColor(.secondary)
                            .padding(.vertical, 3)
                    }
                }
            }
        }
    }
}

15:21 But this doesn't really do what we want. That's because the line shape's frame is a child of the geometry reader, and so it gets proposed the entire size of the geometry reader. By padding the line shape, it gets inset by three points, which pushes the line down three points, but it doesn't change the size of the drawn line. So, it doesn't make sense to add the padding here.

16:10 We could also modify the points passed to Line to create some space between the lines and the icons. But because we'll try to draw a different line style later anyway — the idea is to draw dotted lines, and we'll explore different ways of doing so — we'll leave the lines as they are for now.

Resources

  • Sample Code

    Written in Swift 5.7

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

136 Episodes · 48h46min

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