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.