SwiftUI 如何将惰性加载的可变滚动内容“一滚到底”?

198 阅读5分钟

在这里插入图片描述

概述

在 SwiftUI 的布局中,我们往往需要使用滚动视图(ScrollView)来让超长内容尽收眼底,此时一种常见的操作就是让用户可以直接滚动到最底部。

在这里插入图片描述

不过,如果 ScrollView 内部的子视图是惰性加载、且高度在滚动时会发生变化,则滚动到底这一操作很可能“半途而废”,无法到达预期效果。

在本篇博文中,您将学到如下内容:

  1. ScrollView 中的可变内容
  2. 目不转睛:让“可变”成为“不可变”!
  3. 触底后无法再次滚动的解决

相信学完本课后,小伙伴们倘若今后遇到此等问题必能胸有成竹,迎刃而解!那还等什么呢?让我们马上开始滚动大冒险吧!

Let‘s go!!!;)


1. ScrollView 中的可变内容

小伙伴们可能不知道,目前在 SwiftUI 中如果用 Text 直接显示超长内容(比如 100000 位的 π),会导致视图一片“空白”。

一种解决办法是,分段显示它们并让用户按需展开每个片段:

@State var piString = "..."
let digits = 10000
let chunkSize = 1000

private func calcChunkTitle(i: Int, count: Int) -> String {
    "\(i * chunkSize) - \(i * chunkSize + count)"
}

ScrollView(.vertical, showsIndicators: true) {
    LazyVStack {
        ForEach(Array(piString.chunks(chunkSize).enumerated()), id: \.0) { i, chunk in
            DisclosureGroup(calcChunkTitle(i: i, count: chunk.count)) {
                Text(chunk)
                    .monospaced()
                    .font(.title.bold())
                    .foregroundStyle(.gray)
                    
            }
            .border(.gray)
        }
    }
}

在上面的代码中,我们将 10000 位的 π 用 DisclosureGroup 容器分为了 10 个小片段,从而可以让用户决定展开任意片段以查看 π 具体的位:

在这里插入图片描述

理所当然的是,我们需要增加让用户可以直接跳转到底部以查看 π 最后几位的功能。所幸这在 Swift 6.0(iOS 18)里很容易,因为苹果添加了一个新的 ScrollPosition 结构,可以让我们根据边界(edge)或每个独立视图(通过 id)来滚动到指定位置。

在这里插入图片描述

于是乎,上面的代码可以做如下“加工”:

@State var piString = ""
let digits = 10000
let chunkSize = 1000

@State var scrollPos = ScrollPosition(edge: .top)

private func calcChunkTitle(i: Int, count: Int) -> String {
    "\(i * chunkSize) - \(i * chunkSize + count)"
}

ScrollView(.vertical, showsIndicators: true) {
    LazyVStack {
        ForEach(Array(piString.chunks(chunkSize).enumerated()), id: \.0) { i, chunk in
            DisclosureGroup(calcChunkTitle(i: i, count: chunk.count)) {
                Text(chunk)
                    .monospaced()
                    .font(.title.bold())
                    .foregroundStyle(.gray)
                    
            }
            .border(.gray)
            .id(i)
        }
    }
}
.toolbar {
    Button("滚动到底部") {
        // 在这个例子中可以保证滚动视图中最后一个子视图的索引为 9,为了简便下面采用硬编码
        scrollPos = .init(id: 9, anchor: .bottom)
    }
}
.animation(.bouncy, value: scrollPos)

这样做可以“勉强”达到目的,不过有些“强颜欢笑”的感觉,原因有两个:

  1. 最后一个元素的 id 需要计算出来;
  2. 由于 ScrollView 中所有子视图的高度都是可变的,而且它们都是惰性加载(LazyVStack)的,可能会导致用户无法真正的滚动到底。

关于上面的第 2 点再多啰嗦几句:目前在 SwiftUI 中滚动视图 + 惰性容器(LazyVStack 或 LazyVGrid 等)的布局组合在底层并不会转换为 UIKit 中的任何对应物(比如 UITableView 或 UICollectionView)。由于惰性加载的缘故,在滚动前计算好的底部 y 坐标可能会发生改变,这就是滚动不到位的根本原因。

2. 目不转睛:让“可变”成为“不可变”!

为了最大限度的改善这个问题,我们有多种方法。

一种最简单的解决之道就是避免使用惰性容器,比如把 LazyVStack 换为 VStack。在上面例子中这是可行的,因为我们使用若干 DisclosureGroup 容器将原本就不多的段落隐藏了起来,不会造成渲染性能问题。

但是,在一些包含大量子视图的滚动容器布局上,这就有点“挥霍无度”了。这时,我们可以在 ScrollView 底部,但是在惰性容器的外部添加一个“站桩 Stub”视图作为“滚动哨兵”:

ScrollView(.vertical, showsIndicators: true) {
    LazyVStack {
        ForEach(Array(piString.chunks(chunkSize).enumerated()), id: \.0) { i, chunk in
            DisclosureGroup(calcChunkTitle(i: i, count: chunk.count)) {
                ...
            }
        }
    }
    
    // 底部的“站桩”视图,注意:它放在 LazyVStack 的外部
    Text("BottomStub")
        .frame(height: 0)
        .hidden()
        .border(.green)
        .id(999)
}
.toolbar {
    Button("滚动到底部") {
        scrollPos = .init(id: 999, anchor: .bottom)
    }
}
.animation(.bouncy, value: scrollPos)

这样做的好处是:“站桩”视图的 id 总是可知的,而且因为它在惰性视图之外,所以它不会被惰性加载,总是会被立即显示。

在这里插入图片描述

如上图所示,现在无论 ScrollView 中的内容如何“瞬息万变”,我们都可以妥妥的“一滚到底”啦。

3. 触底后无法再次滚动的解决

不过,上面的实现稍微有点问题:如果滚到底后最底部视图的高度发生增长,则不会再触发滚动行为了。

在这里插入图片描述

观察上面的演示图可以发现,当我们滚动到底后,接着展开最底部的 DisclosureGroup 容器,这时整个滚动视图的底部会发生变化,这时再次点击“滚动到底部”按钮却不会有任何动作了。

其实这修复起来也很简单,我们只需在每次滚动到底之前“假”滚动一次就行了:

Button("滚动到底部") {
    // 滚动方向无所谓,一次小小的“假”滚动即可
    scrollPos = .init(x: -5)
    DispatchQueue.main.async {
        scrollPos = .init(id: 999, anchor: .bottom)
    }
}

现在,即使触底之后滚动容器产生新的底部,我们也可以继续完成“一滚到底”的操作啦,棒棒哒!💯

在这里插入图片描述

总结

在本篇博文中,我们讨论了在 SwiftUI 滚动容器中如何将惰性(Lazy)加载、高度可变的滚动内容“一滚到底”,小伙伴们值得拥有。

感谢观赏,再会啦!8-)