4

Based off of this question: SwiftUI - Drawing (curved) paths between views

I draw the needed views and paths using the GeometryReader. The problem is, now it becomes too long for the screen to show it all (there are 15 such views but you can only see 6 here):

enter image description here

So ... I add a ScrollView to the whole thing. Outside the GeometryReader because otherwise all views are misplaced. Here is the code:

var body: some View {
        if (challengeViewModel.challengeAmount != 0) {
            ScrollView {
                GeometryReader { geometry in
                    let points = calculatePoints(geometry: geometry)
                    drawPaths(geometry: geometry, points: points)
                            .stroke(lineWidth: 3).foregroundColor(.yellow)
                    drawCircles(geometry: geometry, points: points)
                }
            }
                 .background(Color.black)
                 .navigationBarTitle("Journey")

        } else {
            Text("Loading...")
        }
    }

(The content is basically just 1. calculate points based on screen size, 2. draw path along points, 3. place circles on points)

Problem:
Now I understand that GeometryReader needs to get its height from the parent and ScrollView from its child, which becomes a "chicken-egg-problem". So I want to calculate the height of the GeometryReader manually.


My Attempts

1st Attempt

The easiest way is to take some fixed values and calculate a veeery rough value. I just add this to GeometryReader:

GeometryReader { geometry in
                    let points = calculatePoints(geometry: geometry)
                    drawPaths(geometry: geometry, points: points)
                            .stroke(lineWidth: 3).foregroundColor(.yellow)
                    drawCircles(geometry: geometry, points: points)
                }
                        .frame(height: JourneyView.SPACING_Y_AXIS * CGFloat(1.2) * CGFloat(challengeViewModel.challengeAmount))

This works. But now I have a very long screen with unnecessary space at the bottom. And it might look completely different depending on phone model.

2nd Attempt

One potential solution seems to be using DispatchQueue.main.async inside .body and setting a @State variable to the height of the calculated children in there. As is described here: https://stackoverflow.com/a/61315678/1972372

So:

@State private var totalHeight: CGFloat = 100

var body: some View {
        if (challengeViewModel.challengeAmount != 0) {
            ScrollView {
                GeometryReader { geometry in
                    let points = calculatePoints(geometry: geometry)
                    drawPaths(geometry: geometry, points: points)
                            .stroke(lineWidth: 3).foregroundColor(.yellow)
                    drawCircles(geometry: geometry, points: points)
                }
                     .background(GeometryReader { gp -> Color in
                            DispatchQueue.main.async {
                                // update on next cycle with calculated height of ZStack !!!
                                print("totalheight read from Geo = \(totalHeight)")
                                self.totalHeight = gp.size.height
                            }
                            return Color.clear
                        })
            }
                 .background(Color.black)
                 .frame(height: $totalHeight.wrappedValue)
                 .navigationBarTitle("Journey")

        } else {
            Text("Loading...")
        }
    }

This does not work at all. The ScrollView receives a fixed value of either 100 or 10 for some reason and never changes. I tried placing that .background(...) block on any of the children to try and add up the value of each, but that only created an infinite loop. But it seems like it had the potential to work out somehow.

3rd Attempt

Using PreferenceKeys similarly to this tutorial or this answer: https://stackoverflow.com/a/64293734/1972372.

This really seemed like it should work. I placed a ZStack inside the GeometryReader to have a view to grab height from and added a similar .background(...) to it:

.background(GeometryReader { geo in
                    Color.clear
                        .preference(key: ViewHeightKey.self, 
                            value: geo.size.height
                })

Together with an .onPreferenceChange(...) to it.

But unfortunately this worked the least of all attempts, because it simply never changed and only got called once at the start with a base value of 10. I tried placing it on all child views but only got weird results so I already deleted the code for that.


Help Needed

I now feel like just going with the stupid but working 1st attempt solution, just to spare me the headaches of the other two.

Can someone see what I did wrong in attempt 2 or 3 and why I did not manage to receive the results I wanted? Because it seems like they should work.


Full Code

As requested, here is the full class (using the 1st attempt technique):

struct JourneyView: View {

    // MARK: Variables
    @ObservedObject var challengeViewModel: ChallengeViewModel
    @State private var selectedChlgID: Int = 0
    @State private var showChlgDetailVIew = false

    // Starting points
    private static let START_COORD_RELATIVE_X_LEFT: CGFloat = 0.2
    private static let START_COORD_RELATIVE_X_RIGHT: CGFloat = 0.8
    private static let START_COORD_RELATIVE_Y: CGFloat = 0.05
    // Y axis spacing of chlg views
    private static let SPACING_Y_AXIS: CGFloat = 120


    // MARK: UI
    var body: some View {
        if (challengeViewModel.challengeAmount != 0) {
            ScrollView {
                GeometryReader { geometry in
                    let points = calculatePoints(geometry: geometry)
                    drawPaths(geometry: geometry, points: points)
                            .stroke(lineWidth: 3).foregroundColor(.yellow)
                    drawCircles(geometry: geometry, points: points)
                }
                        .frame(height: JourneyView.SPACING_Y_AXIS * CGFloat(1.2) * CGFloat(challengeViewModel.challengeAmount))
                NavigationLink("", destination: ChallengeView(challengeID: selectedChlgID), isActive: $showChlgDetailVIew)
                        .opacity(0)
            }
                    .background(Color.black)
                    .navigationBarTitle("Journey")
        } else {
            Text("Loading...")
        }
    }

    // MARK: Functions
    init() {
        challengeViewModel = ChallengeViewModel()
    }

    func calculatePoints(geometry: GeometryProxy) -> [CGPoint] {
        // Calculated constants
        let normalizedXLeft = JourneyView.START_COORD_RELATIVE_X_LEFT * geometry.size.width
        let normalizedXRight = JourneyView.START_COORD_RELATIVE_X_RIGHT * geometry.size.width
        let normalizedY = JourneyView.START_COORD_RELATIVE_Y * geometry.size.height

        let startPoint = CGPoint(x: geometry.size.width / 2, y: normalizedY)
        var points = [CGPoint]()
        points.append(startPoint)

        (1...challengeViewModel.challengeAmount).forEach { i in
            let isLeftAligned: Bool = i % 2 == 0 ? false : true

            let nextPoint = CGPoint(x: isLeftAligned ? normalizedXRight : normalizedXLeft,
                    y: normalizedY + JourneyView.SPACING_Y_AXIS * CGFloat(i))
            points.append(nextPoint)
        }

        return points
    }

    func drawPaths(geometry: GeometryProxy, points: [CGPoint]) -> some Shape {
        // Connection paths
        Path { path in
            path.move(to: points[0])
            points.forEach { point in
                path.addLine(to: point)
            }
        }
    }

    func drawCircles(geometry: GeometryProxy, points: [CGPoint]) -> some View {
        let circleRadius = geometry.size.width / 5

        return ForEach(0...points.count - 1, id: \.self) { i in
            JourneyChlgCircleView(chlgName: "Sleep" + String(i), color: Color(red: 120, green: 255, blue: 160))
                    .frame(width: circleRadius, height: circleRadius)
                    .position(x: points[i].x, y: points[i].y)
                    .onTapGesture {
                        selectedChlgID = i
                        showChlgDetailVIew = true
                    }
        }
    }

  }
}
Big_Chair
  • 2,781
  • 3
  • 31
  • 58
  • I think you are missing some point in your code design! If you are going to use GeometryReader to read all available space for drawing what you what! it must be no need to ScrollView to scroll! because we just used all available space to draw! why would you need more space and then why would you need ScrollView!? – ios coder Aug 20 '21 at 20:07
  • @swiftPunk Because "all available space" in this case means only the current space that you see on the screen. But my drawn layout is longer than what fits on the screen. What now? – Big_Chair Aug 20 '21 at 20:23
  • Aha! here is the question! you want use just the available space in case and you not need extra space! Or you cannot say if the current available space would fit the drawing and if it did not fit then use more space. which one? – ios coder Aug 20 '21 at 20:26
  • @swiftPunk Yes, the second one. I have to draw about 15 of such circles with enough margin from each other, so the screen has to become scrollable, otherwise half of them disappear below. And it depends on phone model too. On a small iPhone you see less than on a big one. – Big_Chair Aug 20 '21 at 20:53
  • I can gave the answer for this issue so easily, but if I could run your codes! your given cods does not build! You can edit your codes for minimum recreating issue, then I can work on it! I cannot imaging your project and then start answering for imaginary question or issue. – ios coder Aug 23 '21 at 11:53
  • @swiftPunk That makes sense, I added the full class code. – Big_Chair Aug 23 '21 at 13:07

2 Answers2

5

First of all: great question! :)

It seems that a lot of the confusion here (between the question, comments, and existing answers) is a matter of over-engineering. The solution I worked out is rather simple, and uses your existing code (without the need for any magic numbers).

First, create a new @State variable. As you may know, SwiftUI will redraw your view each time a variable with this property wrapper gets updated.

@State var finalHeight: CGFloat = 0

Set the variable's initial value to zero. Assuming we don't know how many "challenges" will be published by your object, this safely provides the GeometryReader with a negligible initial height (allowing you to present a loading state if desired).

Then, change your GeometryReader's frame height value to the previously defined variable's value:

ScrollView {
    GeometryReader { proxy in
        // Your drawing code
    }
    .frame(height: finalHeight)
}

Finally (this is where the "magic" happens), in your drawCircles function, update the finalHeight variable as you load circles into your view.

JourneyChlgCircleView(arguments: ...)
    .someModifiers()
    .onAppear {
        if i == points.count - 1 {
            finalHeight = points[i].y + circleRadius
        }
    }

By updating the final height when you reach the end of your iterations, the GeometryReader will redraw itself and present its children in the correct layout.

Alternatively, you could iteratively update the finalHeight variable as each circle is added if your implementation requires such a thing. But, in my opinion, that would result in excessive redraws of your entire view.


Final Result

enter image description here

Note that in this result preview the GeometryReader prints its current height at the top of the view, and each circle is overlaid with its current Y position.

Sam Spencer
  • 8,492
  • 12
  • 76
  • 133
  • 1
    Wow, this is just like my 2nd attempt, only ... in correct haha. I really like the cleanliness of this solution. And it feels a lot more "SwiftUI-like" than using `UIScreen`. I corrected the formula to give a little more space at the bottom: `totalHeight = points[0].y + points[i].y + (circleRadius * 3)`. But otherwise this is really great man, thank you a lot. It really was over-engineered it seems! – Big_Chair Aug 25 '21 at 18:35
1

I suggest you going out of GeometryReader at this point, and try just use some constants.

Here I'm using UIScreen.main.bounds.size which is device size. And my relative center y is not limited to 0..1 anymore: because we have more items than one screen. Not sure if you need that in your app or you could just use static values for y distance.

Now to the size calculations. Path doesn't act like any other view, it not taking space of its content(in this case points to draw), because you may draw points with location less than zero and more that you need to actually draw.

To apply height modifier to Path I've created heightToFit extension: it receives method which will draw Path(stroke/fill/etc), and after drawing applying height modifier equal to boundingRect.maxY which is bottom point of drawn path

struct ContentView: View {
    private static let basicSize = UIScreen.main.bounds.size
    
    private let linesPath: Path
    private let circlesPath: Path
    
    init() {
        let circleRelativeCenters = [
            CGPoint(x: 0.8, y: 0.2),
            CGPoint(x: 0.2, y: 0.5),
            CGPoint(x: 0.8, y: 0.8),
            CGPoint(x: 0.2, y: 1.0),
            CGPoint(x: 0.8, y: 1.2),
            CGPoint(x: 0.2, y: 1.5),
            CGPoint(x: 0.8, y: 1.8),
        ]
        let normalizedCenters = circleRelativeCenters
            .map { center in
                CGPoint(
                    x: center.x * Self.basicSize.width,
                    y: center.y * Self.basicSize.height
                )
            }
        linesPath = Path { path in
            var prevPoint = CGPoint(x: 0, y: 0)
            path.move(to: prevPoint)
            normalizedCenters.forEach { center in
                path.addLine(to: center)
                prevPoint = center
            }
        }
        circlesPath = Path { path in
            let circleDiamter = Self.basicSize.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
                )
            }
        }
    }
    
    var body: some View {
        ScrollView {
            ZStack(alignment: .topLeading) {
                linesPath
                    .heightToFit(draw: { path in
                        path.stroke(lineWidth: 3)
                    })
                    .frame(width: Self.basicSize.width)
                circlesPath
                    .heightToFit(draw: { path in
                        path.fill()
                    })
                    .frame(width: Self.basicSize.width)
            }
        }.foregroundColor(.blue).background(Color.yellow)
    }
}

extension Path {
    func heightToFit<DrawnPath: View>(draw: (Path) -> DrawnPath) -> some View {
        draw(self).frame(height: boundingRect.maxY)
    }
}

Result:

enter image description here

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • Hello again. Is it not somewhat "bad practice" to use UIKit variables in SwiftUI if there is a SwiftUI way of doing it? I thought `GeometryReader` was the way to go from Apple's perspective. But yeah if I have access to a static variable like `UIScreen.main.bounds.size` that doesn't have to be calculated at runtime, I can easily recreate what I did in my Android app. So thanks for that. My only concern is still that this might not the "preferred" SwiftUI way. – Big_Chair Aug 23 '21 at 13:13
  • @Big_Chair I've updated my code to show how you can move these calculations to `init`. But these calculations are not heavy at all. And which is more important, it's not gonna be updated, so these calculations most probably won't gonna repeat. For sure it depends on your view hierarchy. If you have many independent `@State`/`@Binding` etc variables, that are gonna cause recomposition on value change, you should split them. So when you change one such variable it only handles redraw of dependant views. – Phil Dukhov Aug 23 '21 at 13:28
  • @Big_Chair also geometry reader isn't quite good in terms of performance: to calculate size it draw content at first, then when you get it value you probably gonna change something so it should redraw again. It's not something bad, but using constants such as screen size is way more performant. If you know your screen takes full width, no problem in using it. – Phil Dukhov Aug 23 '21 at 13:31
  • Hey thanks again. I ended up going with Sam's idea, as it seems more straightforward and doesn't use UIKit variables, which seems cleaner to me. But I still really appreciate your help. – Big_Chair Aug 25 '21 at 18:37
  • 1
    @Big_Chair sure it's up to you. I understand that code looks cleaner, but in terms of performance my solution is better. Each time `@State` value gets changed, SwiftUI re-renders screen, and also it does the same when you apply different size to `GeometryReader`, and with Sam's solution it's gonna happen for each item. Of course for a static screen it doesn't really maters, because it's not gonna change after all calculations were made initially, but in future keep that in mind. – Phil Dukhov Aug 25 '21 at 18:44
  • Yeah I actually ended up coming back to using the UIScreen for the spacing, because it's more dynamic than just setting a fixed value, and for that I need specific screen sizes, not just the geometry reader. So I combined Sam's solution with your UIScreen constant `let spacingYAxis = UIScreen.main.bounds.size.height * 0.2`. – Big_Chair Aug 28 '21 at 11:16