swiftswiftui

Why can't the children of a parent view conform to the parent's coordinate space in SwiftUI?


I defined a custom coordinate space at the parent level using a GeometryReader. Then, I tried to use that coordinate space in my child views, called RectangleView. However, it seems the children cannot access the coordinate space I defined. Instead, they fall back to the global coordinate system.

My goal is for the custom coordinate system to be independent and have its own freedom of coordinates. To test this, I defined a drag gesture for the GeometryReader, representing the custom coordinate space. My theory was that if I drag the GeometryReader, the origin of the custom coordinate space should remain at zero, and the frame or CGRect of the GeometryReader should remain unchanged. I also expected the coordinate system of the children to stay unaffected by the drag gesture.

However, in the actual test, I observed that the GeometryReader's frame and its children’s frames were affected by the drag. This is not what I expected. I wanted the GeometryReader and its children to maintain their original coordinate space values, regardless of the drag gesture of the parent view(But I want their origin to change if I drag them individually using their own drag gesture).

What am I doing wrong? Why are the coordinate values of the GeometryReader and its children changing with the drag gesture of parent GeometryReader?

Also, why can’t the children see the custom coordinate space even without the drag?

import SwiftUI

struct ContentView: View {
    
    @State private var array: [CustomCGRectType] = [CustomCGRectType]()
    private let coordinateSpaceName: String = "GeometryReader"
    
    @State private var screenCGRect: CGRect = CGRect()
    
    @State private var currentOffset: CGSize = .zero
    @State private var lastOffset: CGSize = .zero
    
    var body: some View {
        
        
        VStack(spacing: 10.0) {
            
            Text("GeometryReader: " + CustomCGRectType(id: coordinateSpaceName, rect: screenCGRect).description).font(.title3.monospaced())
            
            GeometryReader { geometryValue in
                
                ZStack {
                    
                    Color.white.opacity(0.5).border(Color.black)
                    
                    VStack {
                        
                        RectangleView(id: "Blue Rectangle", color: Color.blue, size: CGSize(width: 50.0, height: 100.0), coordinateSpace: .named(coordinateSpaceName))
                        
                        Spacer()
                        
                        RectangleView(id: "Red Rectangle", color: Color.red, size: CGSize(width: 150.0, height: 50.0), coordinateSpace: .named(coordinateSpaceName))
                        
                    }
                    
                    HStack {
                        
                        RectangleView(id: "Green Rectangle", color: Color.green, size: CGSize(width: 100.0, height: 100.0), coordinateSpace: .named(coordinateSpaceName))
                        
                        Spacer()
                        
                        RectangleView(id: "Yellow Rectangle", color: Color.yellow, size: CGSize(width: 50.0, height: 100.0), coordinateSpace: .named(coordinateSpaceName))
                        
                    }
                    
                }
                .onAppear {
                    screenCGRect = geometryValue.frame(in: .named(coordinateSpaceName))
                }
                .onChange(of: geometryValue.frame(in: .named(coordinateSpaceName))) { newValue in
                    screenCGRect = newValue
                }
                .onPreferenceChange(CustomCGRectPreferenceKey.self) { newValue in
                    array = newValue
                }
                
            }
            .coordinateSpace(name: coordinateSpaceName)
            .offset(currentOffset)
            .gesture(dragGesture)
            
            HStack(spacing: .zero) {
                
                VStack(alignment: .trailing) {
                    ForEach(array) { item in
                        Text(item.id + ": ").font(.footnote.bold().monospaced())
                    }
                }
                
                VStack(alignment: .leading) {
                    ForEach(array) { item in
                        Text(item.description).font(.footnote.monospaced())
                    }
                }
            }
            
            
        }
        .padding()
        
    }
    
    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0, coordinateSpace: .local)
            .onChanged { gestureValue in
                currentOffset = CGSize(
                    width: gestureValue.translation.width + lastOffset.width,
                    height: gestureValue.translation.height +  lastOffset.height
                )
            }
            .onEnded { gestureValue in
                lastOffset = currentOffset
            }
    }
    
}

struct RectangleView: View {
    
    let id: String
    let color: Color
    let size: CGSize
    let coordinateSpace: CoordinateSpace
    
    @State private var currentOffset: CGSize = .zero
    @State private var lastOffset: CGSize = .zero
    
    var body: some View {
        
        Rectangle()
            .fill(color.opacity(0.75))
            .frame(width: size.width, height: size.height)
            .captureFrame(with: id, in: .named(coordinateSpace), using: CustomCGRectPreferenceKey.self)
            .offset(currentOffset)
            .gesture(dragGesture)
        
    }
    
    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0, coordinateSpace: .named(coordinateSpace))
            .onChanged { gestureValue in
                currentOffset = CGSize(
                    width: gestureValue.translation.width + lastOffset.width,
                    height: gestureValue.translation.height +  lastOffset.height
                )
            }
            .onEnded { gestureValue in
                lastOffset = currentOffset
            }
    }
    
}


struct CustomCGRectType: Identifiable, Equatable, CustomStringConvertible {
    
    init(id: String, rect: CGRect) {
        self.id = id
        self.rect = rect
    }
    
    init() {
        self.id = String()
        self.rect = CGRect()
    }
    
    let id: String
    var rect: CGRect
    
    static func ==(lhs: Self, rhs: Self) -> Bool {
        return (lhs.id == rhs.id) && (lhs.rect == rhs.rect)
    }
    
    var description: String {
        return "(" + String(format: "%.2f", self.rect.origin.x) + ", " + String(format: "%.2f", self.rect.origin.y) + ", " + String(format: "%.2f", self.rect.size.width) + ", " + String(format: "%.2f", self.rect.size.height) + ")"
    }
    
}


struct CustomCGRectPreferenceKey: PreferenceKey {
    static var defaultValue: [CustomCGRectType] = [CustomCGRectType]()
    static func reduce(value: inout [CustomCGRectType], nextValue: () -> [CustomCGRectType]) {
        value.append(contentsOf: nextValue())
    }
}


extension View {
    func captureFrame(with id: String, in coordinateSpace: CoordinateSpace, using key: CustomCGRectPreferenceKey.Type) -> some View {
        self.background(
            GeometryReader { geometryValue in
                Color.clear.preference(key: key, value: [CustomCGRectType(id: id, rect: geometryValue.frame(in: coordinateSpace))])
            }
        )
    }
}

The Answer works but sometime I have this issue of -0.00, I am trying to fix this:

enter image description here


Solution

  • This is a typo that could be rather hard to spot. You wrote

    .captureFrame(with: id, in: .named(coordinateSpace), using: CustomCGRectPreferenceKey.self)
    

    But it should be

    .captureFrame(with: id, in: coordinateSpace, using: CustomCGRectPreferenceKey.self)
    

    .named(coordinateSpace) is referring to a different (and non-existent) coordinate space with the name of coordinateSpace. The coordinate space you want to refer to has the String "GeometryReader" as its name, not another CoordinateSpace.

    You made the same typo in the DragGesture, but it doesn't really matter there because you only use gestureValue.translation, which doesn't depend on where the origin is.


    After this change, you can still see the frames of the rectangles jumping around, but it will always be roughly the same value. This is because the screen only has a limited scale, but the DragGesture is a lot more accurate.

    Two views with offset(x: 0.00001) and offset(x: 0) will be placed at exactly the same place, for example. But frame(in:) will take that 0.00001 into account if it sees that a coordinate space has been offsetted.

    To fix this, you can "snap" the offsets according to the screen's scale. For example, if the screen scale is 1, that means the offset should be a whole number. If the screen scale is 2, the offset should be a whole number multiple of 0.5, and so on.

    // change the offset to:
    .offset(snapOffset(currentOffset))
    
    // where snapOffset is:
    
    @Environment(\.displayScale) var scale
    func snapOffset(_ offset: CGSize) -> CGSize {
        var snapped = offset
        snapped.width = floor(offset.width * scale) / scale
        snapped.height = floor(offset.height * scale) / scale
        return snapped
    }