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