swiftmacosswiftui

Accessing Coordinate Space Across Different Hierarchies


I have a circle view that is not in the same hierarchy where I can access the needed coordinateSpace in another hierarchy. My app's UI does not allow me to place my circle view within the hierarchy of the coordinateSpace. Even if I did, there are two different coordinateSpace instances in two separate hierarchies. The issue is how to access and use the coordinateSpace from another hierarchy to set my circle's position at that origin. I am using macOS 12.

import SwiftUI

struct ContentView: View {
    @State private var rectWhite: CGRect = CGRect()
    @State private var rectBlue: CGRect = CGRect()
    @State private var circlePoint: CGPoint = CGPoint()
    var body: some View {
        VStack {
            HStack {
                Color.white
                    .frame(width: 200.0, height: 200.0)
                    .background {
                        GeometryReader { geometryValue in
                            let frame = geometryValue.frame(in: .named("white"))
                            Color.clear
                                .onAppear {
                                    print("onAppear white:", frame)
                                    rectWhite = frame
                                }
                                .onChange(of: frame) { newValue in
                                    print("onChange white:", newValue)
                                    rectWhite = newValue
                                }
                            
                        }
                    }
                    .coordinateSpace(name: "white")
                    .padding(25)
                    .border(Color.black)
                
                Spacer()
                
                Color.blue
                    .frame(width: 200.0, height: 200.0)
                    .background {
                        GeometryReader { geometryValue in
                            let frame = geometryValue.frame(in: .named("blue"))
                            Color.clear
                                .onAppear {
                                    print("onAppear blue:", frame)
                                    rectBlue = frame
                                }
                                .onChange(of: frame) { newValue in
                                    print("onChange blue:", newValue)
                                    rectBlue = newValue
                                }
                            
                        }
                    }
                    .coordinateSpace(name: "blue")
                    .padding(25)
                    .border(Color.black)


            }

            Circle()
                .fill(Color.red)
                .opacity(0.75)
                .frame(width: 50, height: 50)
                .position(circlePoint)
            
            HStack {
                Button("set on white origin") {
                    circlePoint = rectWhite.origin
                }
                
                Button("set on blue origin") {
                    circlePoint = rectBlue.origin
                }
            }
        }
        .padding()

    }
}

Solution

  • Views can only read the coordinate spaces of their parents. You should put .coordinateSpace(name:) at the parent of all three views you are interested in, and work in that coordinate space only.

    What complicates things is that the coordinate space that .position is working in, is a bit weird. As I explained in this answer, .position first takes up as much space as possible, and the CGPoint you give it is treated as a point in the rectangle it ends up taking up.

    I'd recommend restructuring the view so that the circle is in a ZStack, so that .position would use the same coordinate space as the ZStack, as the circle will be the same size as the ZStack.

    struct ContentView: View {
        // all these are in the parent coordinate space
        @State private var rectWhite: CGRect = CGRect()
        @State private var rectBlue: CGRect = CGRect()
        @State private var circlePoint: CGPoint = CGPoint()
    
        var body: some View {
            ZStack {
                VStack {
                    HStack {
                        Color.white
                            .frame(width: 200.0, height: 200.0)
                            .modifier(FrameReader(frame: $rectWhite, coordinateSpace: .named("parent")))
                            .padding(25)
                            .border(Color.black)
                        Spacer()
                        Color.blue
                            .frame(width: 200.0, height: 200.0)
                            .modifier(FrameReader(frame: $rectBlue, coordinateSpace: .named("parent")))
                            .padding(25)
                            .border(Color.black)
                        
                        
                    }
                    HStack {
                        Button("set on white origin") {
                            circlePoint = rectWhite.origin
                        }
                        
                        Button("set on blue origin") {
                            circlePoint = rectBlue.origin
                        }
                    }
                }
                Circle()
                    .fill(Color.red)
                    .opacity(0.75)
                    .frame(width: 50, height: 50)
                    .position(circlePoint)
            }
            .coordinateSpace(name: "parent")
            .padding()
        }
    }
    

    If you want to keep the circle in a VStack, you would also need to read the circle's frame relative to the parent's coordinate space, and do some conversions before passing it to .position.

    struct ContentView: View {
        // all these are in the parent coordinate space
        @State private var rectWhite: CGRect = CGRect()
        @State private var rectBlue: CGRect = CGRect()
        @State private var circleFrame: CGRect = CGRect() // New @State here
        @State private var circlePoint: CGPoint = CGPoint()
        var body: some View {
            VStack {
                HStack {
                    Color.white
                        .frame(width: 200.0, height: 200.0)
                        .modifier(FrameReader(frame: $rectWhite, coordinateSpace: .named("parent")))
                        .padding(25)
                        .border(Color.black)
                    
                    Spacer()
                    
                    Color.blue
                        .frame(width: 200.0, height: 200.0)
                        .modifier(FrameReader(frame: $rectBlue, coordinateSpace: .named("parent")))
                        .padding(25)
                        .border(Color.black)
    
    
                }
    
                Circle()
                    .fill(Color.red)
                    .opacity(0.75)
                    .frame(width: 50, height: 50)
    
                    // note this conversion we are doing
                    .position(x: circlePoint.x - circleFrame.origin.x, y: circlePoint.y - circleFrame.origin.y)
                    .modifier(FrameReader(frame: $circleFrame, coordinateSpace: .named("parent")))
    
                HStack {
                    Button("set on white origin") {
                        circlePoint = rectWhite.origin
                    }
                    
                    Button("set on blue origin") {
                        circlePoint = rectBlue.origin
                    }
                }
            }
            .coordinateSpace(name: "parent")
            .padding()
    
        }
    }
    

    In both of the above code snippets, I have extracted the GeometryReader into its own view modifier, presented below for completeness.

    struct FrameReader: ViewModifier {
        @Binding var frame: CGRect
        let coordinateSpace: CoordinateSpace
        
        func body(content: Content) -> some View {
            content
                .background {
                    GeometryReader { geometryValue in
                        let frame = geometryValue.frame(in: coordinateSpace)
                        Color.clear
                            .onAppear {
                                self.frame = frame
                            }
                            .onChange(of: frame) { newValue in
                                self.frame = newValue
                            }
                        
                    }
                }
        }
    }