Search code examples
iosswiftswiftuimobile

Draw a straight line between 2 circles in SwiftUI


I've been trying different ways of drawing a line between 2 circles(first and second circle) in SwiftUI.

My view is basically a grid of 50 circles in a 10 x 5 grid.

Have found some other threads on using preference keys and another suggested GeometryReader but applying it to my view does not seem to help. I decided to implement a custom way to find the CGPoint coordinates for each circle in my view and it works but not the way I want it to.

My intention is to find the CGPoint of the center of each circle in the grid so I can evenly draw lines to each center of the circle however I'm just not getting the calculation right, could anyone help me point out what's wrong here?

This is what I have so far and calculateCirclePosition() is how I'm calculating each coordinate.

enter image description here

This is my view code below for for reference below:

import SwiftUI

struct CircleGridView: View {
    let rows = 10
    let columns = 5
    @State private var coordinates: [Int :CGPoint] = [:]
    
    var body: some View {        
        ZStack {    
            LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columns)) {
                ForEach(0..<rows * columns, id: \.self) { index in                    
                    Circle()
                        .foregroundColor(.blue)
                        .frame(width: 50, height: 50)
                        .onAppear {
                            DispatchQueue.main.async {
                                let circlePosition = calculateCirclePosition(for: index)
                                coordinates[index] = circlePosition
                                print("Index \(index): \(circlePosition)")
                            }
                        }
                }
            }
            .padding(10)
            if let startPoint = coordinates[0], let endPoint = coordinates[1] {
                Path { path in 
                path.move(to: startPoint)
                path.addLine(to:  endPoint)
            }.stroke(Color.red, lineWidth: 2)
        }
        }
    }
        
        func calculateCirclePosition(for index: Int) -> CGPoint {
            let row = index / columns
            let column = index % columns
            let circleSize: CGFloat = 50
            let circleSpacing: CGFloat = 10 
            let yOffset: CGFloat = 85
            let xOffset: CGFloat = 15
            
            let x = CGFloat(column) * (circleSize + circleSpacing) + circleSize / 2 + circleSpacing + xOffset
            let y = CGFloat(row) * (circleSize + circleSpacing) + circleSize / 2 + circleSpacing + yOffset
            
            return CGPoint(x: x, y: y)
        }
}


Solution

  • You can use a GeometryReader to read the circles' frames, in the coordinate space of the ZStack (which is the coordinate space in which the Path is drawn).

    Instead of using onAppear to update the dictionary to track the circles' centres, put the dictionary in a PreferenceKey:

    struct CirclePositionsKey: PreferenceKey {
        static var defaultValue: [Int: CGPoint] { [:] }
        
        static func reduce(value: inout [Int : CGPoint], nextValue: () -> [Int : CGPoint]) {
            value.merge(nextValue(), uniquingKeysWith: { $1 })
        }
    }
    

    Each circle's preference will be a single key-value pair. The reduce implementation merges all the circles' preference dictionaries together, so that we get the whole dictionary in overlayPreferenceValue, where we can draw the path as an overlay.

    In the view, you can do:

    let rows = 10
    let columns = 5
    
    var body: some View {
        LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columns)) {
            ForEach(0..<rows * columns, id: \.self) { index in
                GeometryReader { geo in
                    let frame = geo.frame(in: .named("DrawingSpace"))
                    // read the circle centre here
                    let center = CGPoint(x: frame.midX, y: frame.midY)
                    Circle()
                        .foregroundColor(.blue)
                        .preference(key: CirclePositionsKey.self, value: [
                            index: center
                        ])
                }
                .frame(width: 50, height: 50)
            }
        }
        .padding(10)
        .overlayPreferenceValue(CirclePositionsKey.self) { value in
            // draw the path as an overlay, based on the preference value
            if let p1 = value[0], let p2 = value[1] {
                Path { path in
                    path.move(to: p1)
                    path.addLine(to: p2)
                }.stroke(Color.red, lineWidth: 2)
            }
        }
        .coordinateSpace(.named("DrawingSpace"))
    }
    

    That said, I would use a Canvas to draw the circles and lines instead. Canvas gives you much more control over where exactly to draw things.

    For example:

    let rows = 10
    let columns = 5
    
    var body: some View {
        Canvas { gc, size in
            let radius: CGFloat = 25
            let spacing: CGFloat = 10
            
            // calculate size of the things to draw...
            let totalWidth = CGFloat(columns) * (radius * 2 + spacing) - spacing
            let totalHeight = CGFloat(rows) * (radius * 2 + spacing) - spacing
            // so that we can calculate some offsets in order to center-align the whole drawing
            let xOffset = (size.width - totalWidth) / 2
            let yOffset = (size.height - totalHeight) / 2
            gc.translateBy(x: xOffset, y: yOffset)
            
            for i in 0..<rows {
                for j in 0..<columns {
                    let frame = CGRect(
                        x: CGFloat(j) * (radius * 2 + spacing),
                        y: CGFloat(i) * (radius * 2 + spacing),
                        width: radius * 2,
                        height: radius * 2
                    )
                    gc.fill(Path(ellipseIn: frame), with: .color(.blue))
                }
            }
            
            func centerOfCircle(atColumn col: Int, row: Int) -> CGPoint {
                let x = CGFloat(col) * (radius * 2 + spacing) + radius
                let y = CGFloat(row) * (radius * 2 + spacing) + radius
                return CGPoint(x: x, y: y)
            }
            
            let line = Path {
                $0.move(to: centerOfCircle(atColumn: 0, row: 0))
                $0.addLine(to: centerOfCircle(atColumn: 1, row: 0))
            }
            
            gc.stroke(line, with: .color(.red), lineWidth: 2)
        }
    }