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