Search code examples
iosswiftswiftuilinegeometryreader

SwiftUI - Drawing (curved) paths between views


In very basic terms, in my Android app, I have a screen that draws circles and then connects them with curved lines.

sketch of what I need

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:

enter image description here

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.


Solution

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