Search code examples
iosswiftswiftuigeometryreader

Path drawn using GeometryReader is not accurate


I'm working on a simple view in which onAppear a path is drawn up from the 3rd circle up to the first and then to the second. I am using GeometryReader and PreferenceKey to get the actual positions. It looks like I'm correctly getting the center of the circle but it seems to have offset a bit lower than it should be.
Could anyone point out what is wrong?

Simulator screenshotof draw result

Here's my code:

struct ScreenPath: View {
    @State private var end = CGFloat.zero
    
    var positions: [CGPoint]
    var body: some View {
        Path { lineBuilder in
            let pathSequence = [positions[2], positions[0], positions[1]]
            if let start = pathSequence.first {
                lineBuilder.move(to: start)
                lineBuilder.addLine(to: pathSequence[1])
                lineBuilder.addLine(to: pathSequence[2])
            }
        }
        .trim(from: 0, to: end)
        .stroke(Color.green, lineWidth: 4)
        .animation(.easeIn(duration: 1.5))
        .onAppear {
            self.end = 1
        }
    }
}

struct HomeScreen: View {
    @State private var circlePositions: [CGPoint] = []
    @State private var colors: [Color] = [.white, .white, .white, .white]
    @State private var showPath: Bool = false
    
    var body: some View {
        GeometryReader { geo in
            
            ZStack {
                if showPath {
                    ScreenPath(positions: circlePositions)
                }  
                VStack(spacing: 30) {
                    VStack(spacing: 45) {
                        HStack {
                            Circle()
                                .fill(colors[0])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .global).center])
                                })
                            
                            Spacer().frame(width: 50)
                            
                            Circle()
                                .fill(colors[1])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .global).center])
                                })
                        }
                        
                        HStack {
                            Circle()
                                .fill(colors[2])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .global).center])
                                })
                            
                            Spacer().frame(width: 50)
                            
                            Circle()
                                .fill(colors[3])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .global).center])
                                })
                        }
                    }
                    
                    
                    
                }
                .frame(width: geo.size.width, height: geo.size.height, alignment: .center)
            }
            .onPreferenceChange(CirclePositionKey.self) { positions in
                if positions.count == 4 {
                    circlePositions = positions
                    for position in positions {
                        print("Circle position: \(position)")
                    }
                }
            }
            .onAppear {                
                colors[0] = .green
                colors[1] = .green
                colors[2] = .green
                
                showPath.toggle()
            }
        }
        .border(Color.red)
    }
}

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

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

Solution

  • You are taking the frame in the .global coordinate space. The origin of this coordinate space is the very top left of the screen, ignoring the safe area.

    When drawing a Path, the coordinate space is the local coordinate space of the Path view, which is safe area-aware. The origin of this coordinate space is inside the safe area.

    So a simple way to fix this is to do ignoresSafeArea on the path:

    Path {
        ...
    }
    .trim(from: 0, to: end)
    .stroke(Color.green, lineWidth: 4)
    .ignoresSafeArea()
    .animation(.easeIn(duration: 1.5), value: end)
    .onAppear {
        self.end = 1
    }
    

    That said, I'd recommend declaring your own coordinate space instead, like in this question, so that you have more control.

    Here is an example using the VStack's coordinate space and using backgroundPreferenceValue to put the path below the circles (this way you don't need the ZStack):

    struct ContentView: View {
        @State private var colors: [Color] = [.white, .white, .white, .white]
        @State private var showPath: Bool = false
        
        var body: some View {
            GeometryReader { geo in
                
                VStack(spacing: 30) {
                    VStack(spacing: 45) {
                        HStack {
                            Circle()
                                .fill(colors[0])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .named("DrawingArea")).center])
                                })
                            
                            Spacer().frame(width: 50)
                            
                            Circle()
                                .fill(colors[1])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .named("DrawingArea")).center])
                                })
                        }
                        
                        HStack {
                            Circle()
                                .fill(colors[2])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .named("DrawingArea")).center])
                                })
                            
                            Spacer().frame(width: 50)
                            
                            Circle()
                                .fill(colors[3])
                                .frame(width: 30, height: 30)
                                .background(GeometryReader { circleGeo in
                                    Color.clear.preference(key: CirclePositionKey.self, value: [circleGeo.frame(in: .named("DrawingArea")).center])
                                })
                        }
                    }
                }
                .frame(width: geo.size.width, height: geo.size.height, alignment: .center)
                .coordinateSpace(.named("DrawingArea"))
                .backgroundPreferenceValue(CirclePositionKey.self) { positions in
                    if showPath {
                        ScreenPath(positions: positions)
                    }
                }
                .onAppear {
                    colors[0] = .green
                    colors[1] = .green
                    colors[2] = .green
                    
                    showPath.toggle()
                }
            }
            .border(Color.red)
        }
    }