SwiftUI - 计算 ScrollView 中长 GeometryReader 的高度

Posted

技术标签:

【中文标题】SwiftUI - 计算 ScrollView 中长 GeometryReader 的高度【英文标题】:SwiftUI - Calculate height for long GeometryReader inside ScrollView 【发布时间】:2021-08-20 14:57:07 【问题描述】:

基于这个问题: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))

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

第二次尝试

一个潜在的解决方案似乎是在.body 中使用DispatchQueue.main.async,并将@State 变量设置为其中计算出的孩子的高度。如此处所述:https://***.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 类似于此tutorial 或此答案:https://***.com/a/64293734/1972372。

这看起来确实应该有效。我在GeometryReader 中放置了一个ZStack,以便从中获取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
                    
        
    

  

【问题讨论】:

我认为您在代码设计中遗漏了一些要点!如果你打算使用 GeometryReader 来阅读所有可用空间来绘制你想要的东西!一定是不用ScrollView来滚动的!因为我们只是使用了所有可用空间来绘制!为什么需要更多空间,然后为什么需要 ScrollView!? @swiftPunk 因为在这种情况下,“所有可用空间”仅表示您在屏幕上看到的当前空间。但是我绘制的布局比适合屏幕的布局要长。现在呢? 啊哈!这是问题!您只想使用可用空间以防万一,而您不需要额外的空间!或者您不能说当前可用空间是否适合绘图,如果不适合则使用更多空间。哪一个? @swiftPunk 是的,第二个。我必须绘制大约 15 个这样的圆圈,并且彼此之间有足够的边距,所以屏幕必须变得可滚动,否则其中一半会消失在下方。这也取决于手机型号。在小 iPhone 上你看到的比在大 iPhone 上要少。 我可以很容易地给出这个问题的答案,但如果我能运行你的代码!你给定的鳕鱼没有建立!您可以编辑您的代码以最小化重新创建问题,然后我可以处理它!我无法想象你的项目,然后开始回答想象中的问题。 【参考方案1】:

首先:好问题! :)

似乎这里的很多混淆(在问题、cmets 和现有答案之间)是过度设计的问题。我制定的解决方案相当简单,并使用您现有的代码(不需要任何 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 位置重叠。

【讨论】:

哇,这就像我的第二次尝试,只是......正确哈哈。我真的很喜欢这个解决方案的清洁度。而且感觉比使用UIScreen 更像“SwiftUI”。我更正了公式以在底部留出更多空间:totalHeight = points[0].y + points[i].y + (circleRadius * 3)。但除此之外,这真是个了不起的人,非常感谢。看起来真的是过度设计了!【参考方案2】:

我建议您此时退出GeometryReader,并尝试使用一些常量。

我在这里使用UIScreen.main.bounds.size,这是设备大小。而且我的相对中心y 不再局限于0..1:因为我们的项目不止一个屏幕。不确定您的应用中是否需要它,或者您可以只使用静态值作为y 距离。

现在进行尺寸计算。 Path 与任何其他视图不同,它不会占用其内容的空间(在这种情况下是要绘制的点),因为您可以绘制位置小于零的点,并且您需要实际绘制更多的点。

height 修饰符应用于Path 我创建了heightToFit 扩展:它接收将绘制Path(stroke/fill/etc) 的方法,并且在绘制后应用height 修饰符等于@ 987654334@是绘制路径的底点

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)
    

结果:

【讨论】:

你好。如果有 SwiftUI 的做法,在 SwiftUI 中使用 UIKit 变量是不是有点“坏习惯”?我认为GeometryReader 是从 Apple 的角度来看的方法。但是是的,如果我可以访问像 UIScreen.main.bounds.size 这样的静态变量,而不必在运行时计算,我可以轻松地重新创建我在我的 android 应用程序中所做的事情。所以谢谢你。我唯一担心的是,这可能不是“首选”的 SwiftUI 方式。 @Big_Chair 我更新了我的代码,以展示如何将这些计算移至init。但这些计算一点也不重。更重要的是,它不会被更新,所以这些计算很可能不会重复。当然,这取决于您的视图层次结构。如果您有许多独立的@State/@Binding 等变量,这些变量会导致值变化的重组,您应该将它们拆分。所以当你改变一个这样的变量时,它只会处理依赖视图的重绘。 @Big_Chair 几何阅读器在性能方面也不是很好:计算大小它首先绘制内容,然后当你得到它的值时,你可能会改变一些东西,所以它应该重新绘制。这不是什么坏事,但是使用诸如屏幕大小之类的常量会更高效。如果你知道你的屏幕是全宽的,那么使用它没有问题。 嘿,再次感谢。我最终接受了 Sam 的想法,因为它看起来更直接并且不使用 UIKit 变量,这对我来说似乎更干净。但我仍然非常感谢您的帮助。 @Big_Chair 确定由您决定。我知道代码看起来更干净,但就性能而言,我的解决方案更好。每次更改@State 的值时,SwiftUI 都会重新渲染屏幕,并且当您对GeometryReader 应用不同的大小时,它也会这样做,并且使用 Sam 的解决方案,每个项目都会发生这种情况。当然,对于静态屏幕,它并不重要,因为在最初进行所有计算后它不会改变,但在未来请记住这一点。

以上是关于SwiftUI - 计算 ScrollView 中长 GeometryReader 的高度的主要内容,如果未能解决你的问题,请参考以下文章

SwiftUI:在 ScrollView 中居中内容

SwiftUI — 如何在 ScrollView 中显示倒计时?

如何在 SwiftUI 的 ScrollView 中制作动画? SwiftUI 中的手风琴风格动画

SwiftUI:在 ScrollView 中显示 HTML 文本

如何在 SwiftUI 中使用 NSAttributedString 和 ScrollView?

带有 ScrollView 外观的 SwiftUI 错误