SwiftUI - 计算 ScrollView 内长 GeometryReader 的高度
SwiftUI - Calculate height for long GeometryReader inside ScrollView
基于这个问题:
我使用 GeometryReader
绘制了所需的视图和路径。问题是,现在屏幕显示太长了(有15个这样的视图,但你在这里只能看到6个):
所以...我给整个事情加了一个ScrollView
。在 GeometryReader
之外,否则所有视图都放错了位置。这是代码:
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...")
}
}
(内容基本上就是1.根据屏幕大小计算点,2.沿点画路径,3.在点上画圆)
问题:
现在我明白 GeometryReader
需要从 parent 和 ScrollView
从它的 child 得到它的高度,它变成了“chicken-egg-problem”。所以我想手动计算 GeometryReader
的高度。
我的尝试
第一次尝试
最简单的方法就是取一些固定值,计算出一个非常粗略的值。我只是将其添加到 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))
这行得通。但是现在我有一个很长的屏幕,底部有不必要的 space。根据 phone 模型,它可能看起来完全不同。
第二次尝试
一个潜在的解决方案似乎是在 .body
中使用 DispatchQueue.main.async
并将 @State
变量设置为其中计算出的 children 的高度。如此处所述:
所以:
@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...")
}
}
这根本不起作用。 ScrollView
出于某种原因接收固定值 100 或 10,并且永远不会更改。我尝试将 .background(...)
块放在 children 中的任何一个上,以尝试将每个值相加,但这只会造成无限循环。但似乎它有可能以某种方式解决问题。
第三次尝试
使用 PreferenceKeys
类似于此 tutorial or this answer: 。
这看起来确实可行。我在 GeometryReader
中放置了一个 ZStack
,以便从中获取 height
的视图,并向其中添加了一个类似的 .background(...)
:
.background(GeometryReader { geo in
Color.clear
.preference(key: ViewHeightKey.self,
value: geo.size.height
})
加上一个.onPreferenceChange(...)
就可以了。
但不幸的是,这是所有尝试中效果最差的,因为它根本就没有改变,并且在开始时只被调用一次,基值为 10。我尝试将它放在所有 child 视图上,但只得到了奇怪的结果,所以我已经删除了那个代码。
需要帮助
我现在只想使用愚蠢但有效的第一次尝试解决方案,只是为了让我免于其他两个问题的困扰。
谁能看出我在尝试 2 或 3 中做错了什么以及为什么我没有收到我想要的结果?因为看起来他们应该工作。
完整代码
根据要求,这里是完整的 class(使用第一次尝试技术):
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
}
}
}
}
}
我建议你现在离开 GeometryReader
,尝试只使用一些常量。
这里我使用的是 UIScreen.main.bounds.size
,这是设备大小。而我的相对中心 y
不再局限于 0..1
:因为我们有比一个屏幕更多的项目。不确定您是否需要在您的应用程序中使用它,或者您可以只使用 y
距离的静态值。
现在进行尺寸计算。 Path
不像任何其他视图,它不使用 space 的内容(在本例中是要绘制的点),因为您可以绘制位置小于零的点和更多您需要的点实际绘制。
为了将 height
修饰符应用到 Path
我创建了 heightToFit
扩展:它接收将绘制 Path
(stroke/fill/etc) 的方法,并且绘制后应用 height
修饰符等于 boundingRect.maxY
这是绘制路径的底点
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)
}
}
结果:
首先:好问题! :)
这里(问题、评论和现有答案之间)的很多混淆似乎都是过度设计的问题。我制定的解决方案相当简单,并使用您现有的代码(不需要任何 magic 数字)。
首先,创建一个新的 @State
变量。如您所知,每次使用此 属性 包装器更新变量时,SwiftUI 都会重新绘制您的视图。
@State var finalHeight: CGFloat = 0
将变量的初始值设置为零。假设我们不知道您的对象将发布多少“挑战”,这会安全地为 GeometryReader
提供一个可忽略不计的初始高度(允许您在需要时呈现加载状态)。
然后,将 GeometryReader
的帧高度值更改为先前定义的变量值:
ScrollView {
GeometryReader { proxy in
// Your drawing code
}
.frame(height: finalHeight)
}
最后(这就是“魔法”发生的地方),在您的 drawCircles
函数中,在将圆加载到视图中时更新 finalHeight
变量。
JourneyChlgCircleView(arguments: ...)
.someModifiers()
.onAppear {
if i == points.count - 1 {
finalHeight = points[i].y + circleRadius
}
}
通过在迭代结束时更新最终高度,GeometryReader
将重新绘制自身并以正确的布局显示其子项。
或者,如果您的实施需要这样做,您可以在添加每个圆圈时迭代更新 finalHeight
变量。但是,在我看来,这会导致过度重绘整个视图。
最终结果
请注意,在此结果预览中,GeometryReader 在视图顶部打印其当前高度,并且每个圆都覆盖其当前 Y 位置。
基于这个问题:
我使用 GeometryReader
绘制了所需的视图和路径。问题是,现在屏幕显示太长了(有15个这样的视图,但你在这里只能看到6个):
所以...我给整个事情加了一个ScrollView
。在 GeometryReader
之外,否则所有视图都放错了位置。这是代码:
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...")
}
}
(内容基本上就是1.根据屏幕大小计算点,2.沿点画路径,3.在点上画圆)
问题:
现在我明白 GeometryReader
需要从 parent 和 ScrollView
从它的 child 得到它的高度,它变成了“chicken-egg-problem”。所以我想手动计算 GeometryReader
的高度。
我的尝试
第一次尝试
最简单的方法就是取一些固定值,计算出一个非常粗略的值。我只是将其添加到 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))
这行得通。但是现在我有一个很长的屏幕,底部有不必要的 space。根据 phone 模型,它可能看起来完全不同。
第二次尝试
一个潜在的解决方案似乎是在 .body
中使用 DispatchQueue.main.async
并将 @State
变量设置为其中计算出的 children 的高度。如此处所述:
所以:
@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...")
}
}
这根本不起作用。 ScrollView
出于某种原因接收固定值 100 或 10,并且永远不会更改。我尝试将 .background(...)
块放在 children 中的任何一个上,以尝试将每个值相加,但这只会造成无限循环。但似乎它有可能以某种方式解决问题。
第三次尝试
使用 PreferenceKeys
类似于此 tutorial or this answer:
这看起来确实可行。我在 GeometryReader
中放置了一个 ZStack
,以便从中获取 height
的视图,并向其中添加了一个类似的 .background(...)
:
.background(GeometryReader { geo in
Color.clear
.preference(key: ViewHeightKey.self,
value: geo.size.height
})
加上一个.onPreferenceChange(...)
就可以了。
但不幸的是,这是所有尝试中效果最差的,因为它根本就没有改变,并且在开始时只被调用一次,基值为 10。我尝试将它放在所有 child 视图上,但只得到了奇怪的结果,所以我已经删除了那个代码。
需要帮助
我现在只想使用愚蠢但有效的第一次尝试解决方案,只是为了让我免于其他两个问题的困扰。
谁能看出我在尝试 2 或 3 中做错了什么以及为什么我没有收到我想要的结果?因为看起来他们应该工作。
完整代码
根据要求,这里是完整的 class(使用第一次尝试技术):
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
}
}
}
}
}
我建议你现在离开 GeometryReader
,尝试只使用一些常量。
这里我使用的是 UIScreen.main.bounds.size
,这是设备大小。而我的相对中心 y
不再局限于 0..1
:因为我们有比一个屏幕更多的项目。不确定您是否需要在您的应用程序中使用它,或者您可以只使用 y
距离的静态值。
现在进行尺寸计算。 Path
不像任何其他视图,它不使用 space 的内容(在本例中是要绘制的点),因为您可以绘制位置小于零的点和更多您需要的点实际绘制。
为了将 height
修饰符应用到 Path
我创建了 heightToFit
扩展:它接收将绘制 Path
(stroke/fill/etc) 的方法,并且绘制后应用 height
修饰符等于 boundingRect.maxY
这是绘制路径的底点
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)
}
}
结果:
首先:好问题! :)
这里(问题、评论和现有答案之间)的很多混淆似乎都是过度设计的问题。我制定的解决方案相当简单,并使用您现有的代码(不需要任何 magic 数字)。
首先,创建一个新的 @State
变量。如您所知,每次使用此 属性 包装器更新变量时,SwiftUI 都会重新绘制您的视图。
@State var finalHeight: CGFloat = 0
将变量的初始值设置为零。假设我们不知道您的对象将发布多少“挑战”,这会安全地为 GeometryReader
提供一个可忽略不计的初始高度(允许您在需要时呈现加载状态)。
然后,将 GeometryReader
的帧高度值更改为先前定义的变量值:
ScrollView {
GeometryReader { proxy in
// Your drawing code
}
.frame(height: finalHeight)
}
最后(这就是“魔法”发生的地方),在您的 drawCircles
函数中,在将圆加载到视图中时更新 finalHeight
变量。
JourneyChlgCircleView(arguments: ...)
.someModifiers()
.onAppear {
if i == points.count - 1 {
finalHeight = points[i].y + circleRadius
}
}
通过在迭代结束时更新最终高度,GeometryReader
将重新绘制自身并以正确的布局显示其子项。
或者,如果您的实施需要这样做,您可以在添加每个圆圈时迭代更新 finalHeight
变量。但是,在我看来,这会导致过度重绘整个视图。
最终结果
请注意,在此结果预览中,GeometryReader 在视图顶部打印其当前高度,并且每个圆都覆盖其当前 Y 位置。