Matthew's Dev Blog

Using CoordinateSpace to draw over a SwiftUI List

Update 27th September 2022:
This article is incorrect. The correct way to track the position of a SwiftUI View is to use Anchor Preferences. Thank-you to Rolfrider showing me the light.
I'll leave the rest of the article here, as an exploration of coordinate spaces.

My Nearly Departed app has a view which shows all the calling points (stations) for a train service, along with the arrival or departure time for each calling point - but in the first implementation, it wasn't clear where a train actually was in its journey.

I needed something to highlight where a train is, in relation to nearby stations, and I thought that a blue pulsing location indicator would work well. This is fine for when a train is currently at a station, because we can draw an extra view inside the List row - but if a train is between stations then this indicator view would need to span two rows.

How do we do that?

How not to do it

My first attempt at doing this was to use the .offset(x:y:) modifier to shift a view vertically - but unfortunately SwiftUI's List rows are aggressively clipped:

SwiftUI List rows are clipped, so we can't use the .offset modifier

This means that the indicator view will need to live outside the SwiftUI List.

Using CoordinateSpace to track a view's position on screen

In UIKit, we would use UICoordinateSpace.convert(_,to:) or the older UIView.convert(_,to:) functions, and happily there's a SwiftUI equivalent in CoordinateSpace.

Here's my first attempt at tracking the position of a view inside a List:

extension CGRect {
	var midPoint: CGPoint {
		CGPoint(x: self.midX, y: self.midY)
	}
}

struct ViewLocationPreferenceKey: PreferenceKey {
	static var defaultValue: CGPoint? = nil

	static func reduce(value: inout CGPoint?, nextValue: () -> CGPoint?) {
		guard let nextValue = nextValue() else { return }
		value = nextValue
	}
}

struct ContentView: View {
	@State private var viewLocation: CGPoint?

    var body: some View {
		List {
			Text("Row 1")
			Text("Row 2")

			GeometryReader { proxy in
				Color.red.opacity(0.2)
					.preference(key: ViewLocationPreferenceKey.self,
								value: proxy.frame(in: CoordinateSpace.global).midPoint)
			}
			.frame(width: 20)

			Text("Row 4")
			Text("Row 5")
		}
		.onPreferenceChange(ViewLocationPreferenceKey.self) { viewLocation in
			self.viewLocation = viewLocation
		}
		.overlay {
			Text(String(describing: self.viewLocation))
		}
    }
}

This is a List containing five rows. The middle row, a red square, has some extra code to track its position relative to the .global coordinate space. A GeometryReader is used to find the frame of the red square, but that frame is relative to the List row itself - so it won't change as we scroll the list.

We need to use proxy.frame(in: CoordinateSpace.global) to convert this local coordinate into the .global coordinate space, which represents a superview. Which superview? We'll find out later!

There's also a PreferenceKey which we use to share the view location with other views. (Remember: PreferenceKey values are propagated up the view hierarchy to a View's superviews, Environment values are propagated down the view hierarchy to a View's subviews.)

Finally, I've put a Text view on the screen to display the position of the red square.

It looks like this - and the position is updated when we scroll the List:

Tracking the position of a View inside a List

Yay, progress!

Drawing an overlay View which tracks another View

To draw another View above the List, it's tempting to use .overlay - but the default position would be relative to the centre of the List, and we'd want its position to be relative to the top leading corner, because that's where the (0,0) position would be in the global CoordinateSpace.

So let's use a ZStack instead, with a .topLeading alignment. I'll omit the CGRect extension and ViewLocationPreferenceKey because they haven't changed.

struct ContentView: View {
	@State private var viewLocation: CGPoint?

    var body: some View {
		NavigationView {
			ZStack(alignment: .topLeading) {
				List {
					Text("Row 1")
					Text("Row 2")

					GeometryReader { proxy in
						Color.red.opacity(0.2)
							.preference(key: ViewLocationPreferenceKey.self,
										value: proxy.frame(in: CoordinateSpace.global).midPoint)
					}
					.frame(width: 20)

					Text("Row 4")
					Text("Row 5")
				}
				.onPreferenceChange(ViewLocationPreferenceKey.self) { viewLocation in
					self.viewLocation = viewLocation
				}

				Circle()
					.foregroundColor(.orange)
					.frame(width: 20, height: 20)
					.offset(x: -10, y: -10)
					.offset(x: self.viewLocation?.x ?? 0, y: self.viewLocation?.y ?? 0)
			}
			.navigationTitle("Tracking rows")
			.navigationBarTitleDisplayMode(.inline)
		}
	}
}

This attempts to draw an orange circle on top of the red square. (The circle has two .offset modifiers: the first adjusts the centrepoint by removing its radius, the second moves the Circle view as the red square moves inside the List).

Here's what it looks like, in portrait and landscape:

A tracking view overlayed abover the List, in portrait
A tracking view overlayed abover the List, in landscape

Uh-oh, that's not great. In portrait, the X position looks correct, but Y is incorrect. In landscape, both are wrong.

If you're reading this in dark mode, you won't be able to see the notch in the landscape image - switch to light mode to see it. This is an important insight that you won't want to ignore.

The problem here is due to safe areas. In portrait, the safe area extends to the screen edges horizontally, but vertically is affected by the NavigationBar. In landscape, the NavigationBar isn't as tall - meaning the vertical error is less - but there's a horizontal error due to the notch's affect on the horizontal safe area.

Solution: ignore the safe areas when drawing the circle:

Circle()
	.foregroundColor(.orange)
	.frame(width: 20, height: 20)
	.offset(x: -10, y: -10)
	.offset(x: self.viewLocation?.x ?? 0, y: self.viewLocation?.y ?? 0)
	.ignoresSafeArea(.all, edges: .all)

Here's what it now looks like in landscape. (Trust me that portrait is also fine!)

When the overlay view ignores safe areas, it's drawn in the correct place

I'll admit that this doesn't look particularly special - an orange circle overlaying a red square. But remember that the red square is inside a List row, and the orange circle is outside the List - so it can be drawn anywhere on screen, and can visually overlay multiple List rows.

Of course it'll work in a split-view on iPad, right? Right?

Remember my vague comment about CoordinateSpace.global representing a superview? Which superview is it? Let's find out, by putting this in the detail pane of a split-view on iPad:

struct MainSplitView: View {
	var body: some View {
		NavigationView {
			Text("Primary pane")
			SecondaryView()
		}
	}
}

struct SecondaryView: View {
	@State private var viewLocation: CGPoint?

    var body: some View {
		ZStack(alignment: .topLeading) {
			List {
				Text("Row 1")
				Text("Row 2")

				GeometryReader { proxy in
					Color.red
						.preference(key: ViewLocationPreferenceKey.self,
									value: proxy.frame(in: CoordinateSpace.global).midPoint)
				}
				.frame(width: 20)

				Text("Row 4")
				Text("Row 5")
			}
			.onPreferenceChange(ViewLocationPreferenceKey.self) { viewLocation in
				self.viewLocation = viewLocation
			}

			Circle()
				.foregroundColor(.orange)
				.frame(width: 20, height: 20)
				.offset(x: -10, y: -10)
				.offset(x: self.viewLocation?.x ?? 0, y: self.viewLocation?.y ?? 0)
				.ignoresSafeArea(.all, edges: .all)
		}
		.navigationTitle("Tracking rows")
		.navigationBarTitleDisplayMode(.inline)
	}
}

Here's what our View looks like, when it's in the secondary position of a split-view:

In the detail part of a split view, the horizontal position is incorrect

Yeah, it's broken again.

This is because CoordinateSpace.global represents the whole screen, but our orange circle is being drawn relative to the top-left corner of the detail pane.

Let's try defining our own CoordinateSpace for the detail pane

If you've investigated CoordinateSpaces previously, you may have found the View.coordinateSpace(name:) modifier. We're going to use this to define our own coordinate space for the detail pane:

struct SecondaryView: View {
	@State private var viewLocation: CGPoint?

    var body: some View {
		ZStack(alignment: .topLeading) {
			List {
				Text("Row 1")
				Text("Row 2")

				GeometryReader { proxy in
					Color.red
						.preference(key: ViewLocationPreferenceKey.self,
									value: proxy.frame(in: CoordinateSpace.named("Secondary")).midPoint)
				}
				.frame(width: 20)

				Text("Row 4")
				Text("Row 5")
			}
			.onPreferenceChange(ViewLocationPreferenceKey.self) { viewLocation in
				self.viewLocation = viewLocation
			}

			Circle()
				.foregroundColor(.orange)
				.frame(width: 20, height: 20)
				.offset(x: -10, y: -10)
				.offset(x: self.viewLocation?.x ?? 0, y: self.viewLocation?.y ?? 0)
				.ignoresSafeArea(.all, edges: .all)
		}
		.coordinateSpace(name: "Secondary")
		.navigationTitle("Tracking rows")
		.navigationBarTitleDisplayMode(.inline)
	}
}

This gives the detail pane its own named coordinate space ("Secondary"), and translates the frame of the red square into the coordates of that named space.

But there's bad news: it doesn't work.

I don't know whether it's by design, or a SwiftUI bug, but the named coordinate space defined inside our SecondaryView is affected by the split view.

We'll need to do something else.

Using a second PreferenceKey to find the true origin for our SecondaryView

The best solution I've found is:

  • track the position of the scrolling target (the red square) using a preference
  • store the position of the top-left origin of our detail pane, relative to the CoordinateSpace.global in another preference
  • do some maths to work out where the overlay view (orange circle) should be

As a small bonus, we don't need the orange circle overlay view to ignore safe areas, because those disappear when we're calculating the correct location for the overlay.

Sigh.

Here's what the final code looks like:

/// CGRect extension for getting the midpoint and origin for a rect
extension CGRect {
	var midPoint: CGPoint {
		CGPoint(x: self.midX, y: self.midY)
	}

	var topLeadingPoint: CGPoint {
		CGPoint(x: self.minX, y: self.minY)
	}
}

/// PreferenceKey for tracking the midpoint of a View
struct MidpointPreferenceKey: PreferenceKey {
	static var defaultValue: CGPoint? = nil

	static func reduce(value: inout CGPoint?, nextValue: () -> CGPoint?) {
		guard let nextValue = nextValue() else { return }
		value = nextValue
	}
}

/// View extension for tracking the midpoint of a View
extension View {
	func setMidpointPreference() -> some View {
		self
			.overlay {
				GeometryReader { proxy in
					Color.clear.preference(key: MidpointPreferenceKey.self,
										   value: proxy.frame(in: CoordinateSpace.global).midPoint)
				}
			}
	}
}

/// PreferenceKey for tracking the origin (top-leading corner) of a View
struct SecondaryViewOriginPreferenceKey: PreferenceKey {
	static var defaultValue: CGPoint? = nil

	static func reduce(value: inout CGPoint?, nextValue: () -> CGPoint?) {
		guard let nextValue = nextValue() else { return }
		value = nextValue
	}
}

/// View extension for tracking the origin (top-leading corner) of a View
extension View {
	func setOriginPreference() -> some View {
		self
			.overlay {
				GeometryReader { proxy in
					Color.clear.preference(key: SecondaryViewOriginPreferenceKey.self,
										   value: proxy.frame(in: CoordinateSpace.global).topLeadingPoint)
				}
			}
	}
}

/// This is the main View of our app
struct MainSplitView: View {
	var body: some View {
		NavigationView {
			Text("Primary pane")
			SecondaryView()
		}
	}
}

struct SecondaryView: View {
	// the origin (top-leading corner) of our List view, relative to the global coordinate space
	@State private var secondaryViewOrigin: CGPoint?

	// the midpoint of the View we're tracking inside the List
	@State private var trackedViewMidpoint: CGPoint?

	// the calculated position of our _orange circle_ overlay view
	@State private var overlayViewLocation: CGPoint?

    var body: some View {
		ZStack(alignment: .topLeading) {
			List {
				Text("Row 1")
				Text("Row 2")

				Color.red
					.frame(width: 20, height: 20)
					.setMidpointPreference()

				Text("Row 4")
				Text("Row 5")
			}
			.setOriginPreference()

			Circle()
				.foregroundColor(.orange)
				.frame(width: 20, height: 20)
				.offset(x: -10, y: -10)
				.offset(x: self.overlayViewLocation?.x ?? 0, y: self.overlayViewLocation?.y ?? 0)
		}
		.onPreferenceChange(SecondaryViewOriginPreferenceKey.self) { origin in
			self.secondaryViewOrigin = origin
			self.updateOverlayViewLocation()
		}
		.onPreferenceChange(MidpointPreferenceKey.self) { midpoint in
			self.trackedViewMidpoint = midpoint
			self.updateOverlayViewLocation()
		}
		.navigationTitle("Tracking rows")
		.navigationBarTitleDisplayMode(.inline)
	}

	private func updateOverlayViewLocation() {
		guard let secondaryViewOrigin = self.secondaryViewOrigin,
			  let trackedViewMidpoint = self.trackedViewMidpoint else {
			return
		}

		self.overlayViewLocation = CGPoint(x: trackedViewMidpoint.x - secondaryViewOrigin.x,
										   y: trackedViewMidpoint.y - secondaryViewOrigin.y)
	}
}

And the app now looks like this on iPad:

Complete example app on iPad

How did I use this technique in Nearly Departed app?

This is what the blue location view looks like in Nearly Departed. When a train is between two stations, the BluePulseView is drawn spanning two List rows:

Train location indicator in Nearly Departed
Tagged with:

First published 27 August 2022