swiftswiftui

How can I define a custom coordinate system and ensure that other views maintain their positions relative to it?


I have a black color, let's say a black screen, and I want to make this black screen the base coordinate system for my app—not the GeometryReader, but the black screen itself. I created the code below to test if the black screen is being used as the true coordinate system. I want the top-left corner of the black screen to be the origin, and for it to work like SwiftUI's Path coordinate system, where the axes are consistent.

However, my code has some issues. First, when I move my circle to the top-left corner of the black screen, I don't get (0, 0) as the CGPoint, which suggests the custom coordinate system isn't working as expected. Second, when I place my circle on the screen, I want it to maintain its position relative to the top and left sides of the black screen, even if I resize the window. Currently, the circle's position changes when I resize the window.

import SwiftUI

struct ContentView: View {
    
    let circleSize: CGFloat = 50.0
    let randomValue = CGFloat.random(in: 50.0...100.0)
    
    var body: some View {
        GeometryReader { geometryValue in
            ZStack {
                
                Color.black
                    .coordinateSpace(name: "BlackScreen")
                
                CircleView(circleSize: circleSize) { newValue in

                    let localPoint = CGPoint(
                        x: newValue.x + geometryValue.frame(in: .named("BlackScreen")).width/2.0 - circleSize/2.0,
                        y: newValue.y + geometryValue.frame(in: .named("BlackScreen")).height/2.0 - circleSize/2.0
                    )
                    print(localPoint)
                }
            }
        }
        .padding(randomValue)
    }
}

struct CircleView: View {
    
    let circleSize: CGFloat
    var point: (CGPoint) -> Void
    
    @State private var lastOffset: CGSize = CGSize()
    @State private var currentOffset: CGSize = CGSize()
    
    var body: some View {
        Circle()
            .fill(Color.red.opacity(0.5))
            .frame(width: circleSize, height: circleSize)
            .overlay(Circle().fill(Color.white.opacity(0.5)).frame(width: 4.0, height: 4.0))
            .offset(currentOffset)
            .gesture(dragGesture)
    }
    
    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0, coordinateSpace: .named("BlackScreen"))
            .onChanged { gestureValue in
                currentOffset = CGSize(
                    width: gestureValue.translation.width + lastOffset.width,
                    height: gestureValue.translation.height + lastOffset.height
                )
                
                point(gestureValue.location)
            }
            .onEnded { gestureValue in
                lastOffset = currentOffset
            }
    }
}

First issue: Let's say our tolerance with hand and mouse is 1.0. As you can see, almost zero-zero has a significant difference when being used as the base coordinate system.

enter image description here


  1. issue:

enter image description here

enter image description here


Solution

  • The first problem is because CircleView is returning the location of the mouse pointer. That is what gestureValue.location means. It is not the location of the center of the circle.

    The second problem is because the ZStack is using .center alignment. The center of the ZStack moves as you resize the window, but the circle's offset does not change, so the circle moves as well.

    Both of these problems can be solved, if you just align the circle in such a way that a currentOffset of .zero means the center of the circle is at the top left.

    First, use .topLeading as the ZStack alignment. The "top leading" point of the ZStack never changes as you resize the window.

    ZStack(alignment: .topLeading) {
        Color.black
        CircleView(circleSize: circleSize) {
            print($0)
        }
    }
    .padding(randomValue)
    

    Then in CircleView you can use alignmentGuide to make the center of the circle to be the alignment guides for .top and .leading.

    var body: some View {
        Circle()
            .fill(Color.red.opacity(0.5))
            .frame(width: circleSize, height: circleSize)
            .overlay(Circle().fill(Color.white.opacity(0.5)).frame(width: 4.0, height: 4.0))
            .alignmentGuide(.top) { $0[VerticalAlignment.center] }
            .alignmentGuide(.leading) { $0[HorizontalAlignment.center] }
            .offset(currentOffset)
            .gesture(dragGesture)
    }
    

    Finally, convert currentOffset to a CGPoint before passing it to point.

    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0, coordinateSpace: .named("BlackScreen"))
            .onChanged { gestureValue in
                currentOffset = CGSize(
                    width: gestureValue.translation.width + lastOffset.width,
                    height: gestureValue.translation.height + lastOffset.height
                )
                
                point(CGPoint(x: currentOffset.width, y: currentOffset.height))
            }
            .onEnded { gestureValue in
                lastOffset = currentOffset
            }
    }
    

    There is no need for coordinateSpaces and GeometryReaders, because Color.black fills the whole ZStack.