Reputation: 3239
In very basic terms, in my Android app, I have a screen that draws circles and then connects them with curved lines.
I am trying to recreate this in SwiftUI.
I found this question that seems very similar to what I am looking for, but unfortunately the answer is extremely short and even after reading about 10 different blog and 5 videos, I still did not fully understand it.
can I get the position of a `View` after layout in SwiftUI?
So the basic logic is I somehow use GeometryReader
to get the .midX
and .midY
coordinates of each Circle
I create and then draw Paths
between them. My only problem is getting ahold of these coordinates after creating the Circle.
And how do I add the paths to the screen, in a ZStack
with the Circles
in front and the paths as one custom shape in the back?
More info:
On Android the final result looks like this:
Basically I have a Challenge
object that has a name and some detail text and I lay them out like this so it visually represents a "journey" for the user.
So all I really need is to know how to lay out some circles/images (with text on them) and then draw lines connecting them. And each such challenge circle needs to be clickable to open a detail view.
Upvotes: 3
Views: 3897
Reputation: 87794
GeometryReader
gives you information if container view, you can get size like geometry.size
and then calculate middle point, etc
Inside GeometryReader
layout is ZStack
, so all items gonna be one on top of each other
Easies way to draw curves is Path { path in }
, inside this block you can add lines/curves to the path, than you can stoke()
it
You can draw circles in two ways: first is again using Path
, adding rounded rects and fill()
it.
An other option is placing Circle()
and adding an offset
I did it in the first way in blue and in the second one in green with smaller radius. I selected curve control points randomly just to give you an idea
let circleRelativeCenters = [
CGPoint(x: 0.8, y: 0.2),
CGPoint(x: 0.2, y: 0.5),
CGPoint(x: 0.8, y: 0.8),
]
var body: some View {
GeometryReader { geometry in
let normalizedCenters = circleRelativeCenters
.map { center in
CGPoint(
x: center.x * geometry.size.width,
y: center.y * geometry.size.height
)
}
Path { path in
var prevPoint = CGPoint(x: normalizedCenters[0].x / 4, y: normalizedCenters[0].y / 2)
path.move(to: prevPoint)
normalizedCenters.forEach { center in
path.addQuadCurve(
to: center,
control: .init(
x: (center.x + prevPoint.x) / 2,
y: (center.y - prevPoint.y) / 2)
)
prevPoint = center
}
}.stroke(lineWidth: 3).foregroundColor(.blue).background(Color.yellow)
Path { path in
let circleDiamter = geometry.size.width / 5
let circleFrameSize = CGSize(width: circleDiamter, height: circleDiamter)
let circleCornerSize = CGSize(width: circleDiamter / 2, height: circleDiamter / 2)
normalizedCenters.forEach { center in
path.addRoundedRect(
in: CGRect(
origin: CGPoint(
x: center.x - circleFrameSize.width / 2,
y: center.y - circleFrameSize.width / 2
), size: circleFrameSize
),
cornerSize: circleCornerSize
)
}
}.fill()
ForEach(normalizedCenters.indices, id: \.self) { i in
let center = normalizedCenters[i]
let circleDiamter = geometry.size.width / 6
let circleFrameSize = CGSize(width: circleDiamter, height: circleDiamter)
Circle()
.frame(size: circleFrameSize)
.offset(
x: center.x - circleFrameSize.width / 2,
y: center.y - circleFrameSize.width / 2
)
}.foregroundColor(.green)
}.frame(maxWidth: .infinity, maxHeight: .infinity).foregroundColor(.blue).background(Color.yellow)
}
Result:
Inside Path { path in
I can use forEach
, because it's not a scope of view builder anymore.
If you need to make some calculations for your modifiers, you can use next trick:
func circles(geometry: GeometryProxy) -> some View {
var points = [CGPoint]()
var prevPoint: CGPoint?
(0...5).forEach { i in
let point: CGPoint
if let prevPoint = prevPoint {
point = CGPoint(x: prevPoint.x + 1, y: prevPoint.y)
} else {
point = .zero
}
points.append(point)
prevPoint = point
}
return ForEach(points.indices, id: \.self) { i in
let point = points[i]
Circle()
.offset(
x: point.x,
y: point.y
)
}
}
Then you can use it inside body like circles(geometry: geometry).foregroundColor(.green)
Upvotes: 6
Reputation: 111
I'm using the reduce function
of a preferenceKey (CirclePointsKey
) to store all the coordinates into an array of points. The overlay with a geometry reader will read the position of every mid position of every ball. i named the view container frame as ballContainer
just to get the correct relative position.
It's not exactly the curve that you posted but you can change the parameters inside the "path.addCurve" to your needs.
the reduce function will be called only when at least 2 preferenceKey are trying to set a new value. Usually is used to set a new value but in this case i'm appending each value.
struct CirclePointsKey: PreferenceKey {
typealias Value = [CGPoint]
static var defaultValue: [CGPoint] = []
static func reduce(value: inout [CGPoint], nextValue: () -> [CGPoint]) {
value.append(contentsOf: nextValue())
}
}
struct ExampleView: View {
@State var points: [CGPoint] = []
var body: some View {
ZStack {
ScrollView {
ZStack {
Path { (path: inout Path) in
if let firstPoint = points.first {
path.move(to: firstPoint)
var lastPoint: CGPoint = firstPoint
for point in points.dropFirst() {
// path.addLine(to: point)
let isGoingRight = point.x < lastPoint.x
path.addCurve(to: point, control1: CGPoint(x: isGoingRight ? point.x : lastPoint.x,
y: !isGoingRight ? point.y : lastPoint.y),
control2: CGPoint(x: point.x, y: point.y))
lastPoint = point
}
}
}
.stroke(lineWidth: 2)
.foregroundColor(.white.opacity(0.5))
VStack {
VStack(spacing: 30) {
ForEach(Array(0...4).indices) { index in
ball
.overlay(GeometryReader { geometry in
Color.clear
.preference(key: CirclePointsKey.self,
value: [CGPoint(x: geometry.frame(in: .named("ballContainer")).midX,
y: geometry.frame(in: .named("ballContainer")).midY)])
})
.frame(maxWidth: .infinity,
alignment: index.isMultiple(of: 2) ? .leading : .trailing)
}
}
.coordinateSpace(name: "ballContainer")
.onPreferenceChange(CirclePointsKey.self) { data in
points = data
}
Text("points:\n \(String(describing: points))")
Spacer()
}
}
}
}
.foregroundColor(.white)
.background(Color.black.ignoresSafeArea())
}
@ViewBuilder
var ball: some View {
Circle()
.fill(Color.gray)
.frame(width: 70, height: 70, alignment: .center)
.padding(10)
.overlay(Circle()
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5, 10, 10, 5]))
.foregroundColor(.white)
.padding(7)
)
.shadow(color: .white.opacity(0.7), radius: 10, x: 0.0, y: 0.0)
}
}
Upvotes: 2