3

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.

Big_Chair
  • 2,781
  • 3
  • 31
  • 58
  • 1
    Can you provide the code for the layout of the circles? – George Aug 15 '21 at 15:20
  • @George I am not sure myself yet what the best approach would be. If I only had the circles, I would just lay each one out in an `HStack` and then put them all in a `VStack` but I think that would make drawing the lines impossible. – Big_Chair Aug 15 '21 at 15:23
  • @George I think the only realistic approach would be to draw them all together inside a `GeometryReader` block and place them based on screen width percentage (that's how I did it on Android). – Big_Chair Aug 15 '21 at 15:24
  • Is it possible to share a minimal payload that would serve as a model for drawing the circles (and/or lines)? – Alladinian Aug 15 '21 at 15:27
  • @Alladinian Sorry I don't really understand, are you asking me to provide more about how I plan to lay it all out? Or is that a suggestion? In that case I am fully open to ideas on how to most efficiently implement this. The final result would basically be to have circles with text on them and them being clickable for a detail page. – Big_Chair Aug 15 '21 at 15:39
  • Sorry if I wasn't clear. I mean, you probably have some data structure that would be visually reflected (something describing essentially your layout). Can you share a minimal example of such a structure? – Alladinian Aug 15 '21 at 15:52
  • @Alladinian I added more information. For how the things would be laid out in code _Philip_ down below provided a great working example! – Big_Chair Aug 15 '21 at 16:03

2 Answers2

5

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)

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • 1
    Thank you so much for the effort! This is definitely a different logic approach than what I was trying to do (first draw the circles and then get their coordinates). You are basically setting the "coordinates" before manually and then normalizing them. I think I can make this work in a `ForEach` by setting up the starting point and then adding to it inside the loop + normalizing it. – Big_Chair Aug 15 '21 at 16:09
  • 1
    I'll try it out and report back in a few hours to accept your answer! – Big_Chair Aug 15 '21 at 16:10
  • I nearly got it working but I got stuck on this probably obvious issue (I'm still somewhat new to SwiftUI): Using `ForEach` I manage to draw the line and then the circle. Then I want to increase the values for the next loop run, like `startPoint = nextPoint`. **But** of course I cannot run any such code inside a `ForEach`. You cleverly used the `.forEach` of the array, but that would mean I have to declare every single coordinate beforehand. But I need it to be dynamic and calculate each point after the previous one was drawn. Do you have a suggestion? – Big_Chair Aug 15 '21 at 20:06
  • Okay I managed to do something somewhat correct, but it's ugly code-wise. I guess I am misusing SwiftUI in a way. I probably have to go your route and run a `func()` in the `init()` that pre-calculates all the points for me so that I can just .map it at the start of `GeometryReader`. I'll see how I can improve it and maybe I'll share it as a separate answer. Anyway, thank you man, you really helped me a lot! – Big_Chair Aug 15 '21 at 20:42
  • @Big_Chair I've added an explanation and an example which may solve your problem – Phil Dukhov Aug 16 '21 at 05:44
  • I wanted to start a bounty to offer you more points for your great help, but unfortunately can't anymore on my own question. Thank you a lot! – Big_Chair Aug 16 '21 at 07:54
1

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)
    }
}
Gry
  • 101
  • 6
  • Ahh, I read about this approach [here](https://developer.apple.com/forums/thread/124220), but couldn't really wrap my head around it - now I get it. I decided to still go with Philip's answer as it is easier for my mind to comprehend, but I thank you a lot for a different perspective! – Big_Chair Aug 16 '21 at 07:58