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:

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
:

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:


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!)

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 theList
- so it can be drawn anywhere on screen, and can visually overlay multipleList
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:

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 CoordinateSpace
s 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:

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:

First published 27 August 2022