首页 > 解决方案 > SwiftUI - 计算 ScrollView 中长 GeometryReader 的高度

问题描述

基于这个问题:SwiftUI - Drawing (curved) paths between views

我使用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需要从父母和ScrollView孩子那里得到它的高度,这变成了一个“鸡蛋问题”。所以我想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))

这行得通。但是现在我有一个很长的屏幕,底部有不必要的空间。根据手机型号,它可能看起来完全不同。

第二次尝试

一种潜在的解决方案似乎是使用DispatchQueue.main.asyncinside.body并将@State变量设置为计算出来的孩子的高度。如此处所述:https ://stackoverflow.com/a/61315678/1972372

所以:

@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(...)块放在任何一个孩子身上以尝试将每个孩子的值相加,但这只会创建一个无限循环。但它似乎有可能以某种方式解决。

第三次尝试

使用PreferenceKeys类似于本教程或此答案:https ://stackoverflow.com/a/64293734/1972372 。

这似乎真的应该工作。我在ZStack里面放置了GeometryReader一个可以抓取的视图height并添加了一个类似.background(...)的:

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

一起来吧.onPreferenceChange(...)

但不幸的是,这在所有尝试中工作得最少,因为它根本没有改变,只在开始时调用一次,基值为 10。我尝试将它放在所有子视图上,但结果很奇怪,所以我已经删除了代码那。


需要帮助

我现在觉得只是使用愚蠢但有效的第一次尝试解决方案,只是为了让我免于其他两个的头痛。

有人可以看到我在尝试 2 或 3 中做错了什么以及为什么我没有设法收到我想要的结果吗?因为看起来他们应该工作。


完整代码

根据要求,这是完整的课程(使用第一次尝试技术):

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

  }
}

标签: iosswiftuigeometryreaderswiftui-scrollview

解决方案


首先:好问题!:)

似乎这里的很多混淆(在问题、评论和现有答案之间)是过度设计的问题。我制定的解决方案相当简单,并且使用您现有的代码(不需要任何数)。

首先,创建一个新@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 位置重叠。


推荐阅读