简体   繁体   中英

SwiftUI - Calculate height for long GeometryReader inside ScrollView

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

在此处输入图像描述

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
                    }
        }
    }

  }
}

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

在此处输入图像描述

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.

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:

在此处输入图像描述

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM