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