skip to Main Content

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)
        }
}

2

Answers


  1. 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 onPreferenceChange.

    n the view, you can do:

    let rows = 10
    let columns = 5
    
    // use a state to track the start and end points of the line
    @State private var line = Line()
    
    var body: some View {
        ZStack {
            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("ZStack"))
                        // 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)
            .onPreferenceChange(CirclePositionsKey.self) { value in
                if let p1 = value[0], let p2 = value[1] {
                    // update the line's points in onPreferenceChange
                    line.start = p1
                    line.end = p2
                }
            }
            
            Path { path in
                path.move(to: line.start)
                path.addLine(to: line.end)
            }.stroke(Color.red, lineWidth: 2)
        }
        .coordinateSpace(.named("ZStack"))
    }
    
    struct Line {
        var start: CGPoint = .zero
        var end: CGPoint = .zero
    }
    

    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)
        }
    }
    
    Login or Signup to reply.
  2. As Sweeper has commented, an easier way to draw the grid might be to use a Canvas.

    However, if you really want to use a LazyVGrid, then the lines can simply be drawn using overlays over the circles. This way, the positioning is automatic and the computation is completely eliminated.

    • A GeometryReader can be used to find the size of each grid cell.
    • An offset can be used to overlap with a preceding circle in a row or a column.
    • Adjustment needs to be made for grid spacing. Alternatively, you could set the spacing to 0 and use padding around the circles instead.
    struct CircleGridView: View {
        let rows = 10
        let columns = 5
        let spacing: CGFloat = 10
    
        var body: some View {
            ZStack {
                LazyVGrid(
                    columns: Array(
                        repeating: GridItem(.flexible(), spacing: spacing),
                        count: columns
                    ),
                    spacing: spacing
                ) {
                    ForEach(0..<rows * columns, id: .self) { index in
                        Circle()
                            .foregroundColor(.blue)
                            .frame(width: 50, height: 50)
                            .frame(maxWidth: .infinity)
                            .overlay {
                                GeometryReader { proxy in
                                    ZStack {
                                        if (index / columns) > 0 {
                                            Color.orange
                                                .frame(width: 2)
                                                .offset(y: -(proxy.size.height + spacing) / 2)
                                        }
                                        if !index.isMultiple(of: columns) {
                                            Color.red
                                                .frame(height: 2)
                                                .offset(x: -(proxy.size.width + spacing) / 2)
                                        }
                                    }
                                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                                }
                            }
                    }
                }
                .padding(10)
            }
        }
    }
    

    Screenshot

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search