Big_Chair
Big_Chair

Reputation: 3239

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.

Upvotes: 3

Views: 3897

Answers (2)

Phil Dukhov
Phil Dukhov

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

Gry
Gry

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

Related Questions