前不久看到这篇文章:https://itnext.io/creating-an-infinite-grid-on-ios-2bd6db28c581
是国外一个大佬解决的一个stackoverflow提问:怎么样创造一个可以无限滚动的scrollView。我根据这篇文章的主要**在不使用约束的情况下作出自己的无限滚动scrollView,希望能给大家带来帮助。
在开始之前,首先向大家说明该项目的前提条件:
- 该scrollView应当看起来是可以无限滚动的,即用户不会看到scrollView的碰撞边缘;
- 该scrollView的内存占用应该尽量少,即不能靠绘制一个巨大的空间(例如1000000x1000000)来达到无限滚动的目的;
- scrollView内的视图和数据应当通过tile,也就是瓦片来分批生成。
- 通过使用约束撑大scrollView的ContentSize
- 通过Key-Value Observing机制,监听scrollView的滚动
- 在滚动的过程中动态地添加tile
- 在滚动结束的节点(endDragging和endDecelerating)让scrollView的offset恢复到初始状态
大神的具体操作可以去看他对应的文章和github代码,这里就不详细解释了。
- 我需要将整个scrollView通过UIViewRepresentable转换成SwiftUI的view,所以我不能使用storyboard和约束。通过分析可得,可以通过直接设置scrollView的contentSize来达到这一效果。
- 我也不需要使用KVO机制来监听滚动,只需要通过UIScrollViewDelegate的scrollViewDidScroll方法来处理滚动事件即可。
- 我需要以中心点为起点,向四周来渲染所需的tile。
结合代码来看一下:
import SwiftUI
struct ContentView: View {
var body: some View {
InfiniteScrollView()
}
}
struct InfiniteScrollView: UIViewRepresentable {
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
context.coordinator.setupScrollView(scrollView: scrollView)
return scrollView
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
//
}
class Coordinator: NSObject, UIScrollViewDelegate {
var scrollView: UIScrollView!
let contentSize = CGSize(width: 100000, height: 100000)
// 用于记录 scrollView 的相对偏移量
var offset: CGPoint = .zero
func setupScrollView(scrollView: UIScrollView) {
self.scrollView = scrollView
scrollView.delegate = self
scrollView.scrollsToTop = false
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
resetOffset()
}
func resetOffset() {
scrollView.contentSize = contentSize
let offset = CGPoint(
x: (scrollView.contentSize.width - scrollView.frame.size.width) / 2,
y: (scrollView.contentSize.height - scrollView.frame.size.height) / 2
)
scrollView.setContentOffset(offset, animated: false)
self.offset = .zero
}
}
}
通过这种方法,我们成功使用UIViewRepresentable创建了一个contentSize为100000x100000的scrollView。并且将scrollView的初始Offset设置为了中心。现在暂时还看不出什么,并且由于我关闭了滚动条显示,我们甚至不能感受到滚动的发生。但是别着急,我们即将开始渲染tile。
接下来是绘制tile,与国外大神类似,我给每个tile设置了编号(x, y),依次是它在列和行的排序,从(0,0)开始,向左上延伸为负,向右下延伸为正。每个编号标识一个独立的tile。
tile渲染代码如下(不特殊说明的情况下,以下所有的代码都在Coordinator内):
let tileSize = CGSize(width: 100, height: 100)
// 记录tiles
var tiles: [TileCoordinate:UILabel] = [:]
func createTile(coordinate: TileCoordinate) {
// 计算tile的origin
let origin = CGPoint(
x: (scrollView.contentSize.width - tileSize.width) / 2 + offset.x + coordinate.x * tileSize.width,
y: (scrollView.contentSize.height - tileSize.height) / 2 + offset.y + coordinate.y * tileSize.height
)
// 设置基本属性
let tile = UILabel(frame: CGRect(origin: origin, size: tileSize))
tile.text = "(\(coordinate.x.formatted()), \(coordinate.y.formatted()))"
tile.textAlignment = .center
let isCenter = coordinate.equalTo(.zero)
tile.backgroundColor = UIColor.gray.withAlphaComponent(isCenter ? 1 : 0.5)
tile.layer.borderWidth = 0.5
tile.layer.borderColor = UIColor.black.withAlphaComponent(0.1).cgColor
// 加入渲染
scrollView.addSubview(tile)
// 加入记录
tiles.updateValue(tile, forKey: coordinate)
}
代码很简单,创建一个UILabel,并显示。接下来重点说一下tile的origin的计算:
origin是一个UIView的左上角坐标,但这个坐标是一个绝对坐标,而我们设计的scrollView只有相对坐标(每次拖拽结束后scrollView的contentOffset都会被设置回原来位置,而新增的偏移量则会记录在offset变量中,而offset表示的是一种相对概念,即相对原始位置偏移了多少),所以我们需要将相对坐标转换为绝对坐标。
算法如下: 由于我们设tile(0,0)为中心tile,所以在不考虑移动的情况下可以计算出来tile(0,0)的坐标为:
x: scrollView.contentSize.width / 2 - tileSize.width / 2
y: scrollView.contentSize.height / 2 - tileSize.height / 2
优化一下,使用结合律:
x: (scrollView.contentSize.width - tileSize.width) / 2
y: (scrollView.contentSize.height - tileSize.height) / 2
假设加上移动,则x,y各项需要再加上offset的x,y值,则坐标为:
x: (scrollView.contentSize.width - tileSize.width) / 2 + offset.x
y: (scrollView.contentSize.height - tileSize.height) / 2 + offset.y
接下来我们计算一下tile(1,1)的坐标,tile(1,1)的坐标应当在tile(0,0)的右下方,也就是tile(0,0)坐标向右下方偏移一个tileSize的距离,即:
x: (scrollView.contentSize.width - tileSize.width) / 2 + offset.x + tileSize.width
y: (scrollView.contentSize.height - tileSize.height) / 2 + offset.y + tileSize.height
由此可知tile(x,y)的坐标应当为:
x: (scrollView.contentSize.width - tileSize.width) / 2 + offset.x + x * tileSize.width
y: (scrollView.contentSize.height - tileSize.height) / 2 + offset.y + y * tileSize.height
接下来就让我们看看我们的tile坐标绘制是否正确吧,在UIViewRepresentable的updateView中增加以下代码,先绘制一个中心tile出来:
func updateUIView(_ scrollView: UIScrollView, context: Context) {
context.coordinator.createTile(coordinate: TileCoordinate(x: 0, y: 0))
}
我们发现为什么坐标有问题?这个方形的tile应当渲染在画面正中间才对。然而却渲染到了画面的左上角?原来这是因为在我们进行setupScrollView的时候,scrollView还没有渲染出来,此时scrollView的frame是 .zero
, 所以我们设置的contentScroll并不在画面中心点,而是在ContentSize中心点。
好在iOS17在UIRepresentable协议中提供了sizeThatFits方法,可以让我们获取到建议尺寸。(关于swiftUI的尺寸关系网上有很多相关的文章,这里就不做科普了。)因为我们需要将scrollView撑满整个空间,所以给到建议尺寸后我们直接用该尺寸来设置scrollView的frame即可。
// UIViewRepresentable
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIScrollView, context: Context) -> CGSize? {
guard let width = proposal.width, let height = proposal.height else { return nil }
// 获取到建议尺寸
let size = CGSize(width: width, height: height)
// 设置frame
uiView.frame.size = size
// 重新初始化offset
context.coordinator.resetOffset()
return size
}
这样一来,渲染就正确了。
接下来就是处理滚动相关的事件了,关键有三个事件钩子: scrollViewDidScroll、scrollViewDidEndDragging、scrollViewDidEndDecelerating。分别处理滚动中,拖拽结束,滚动结束三个动作。 我们先不管滚动中的处理,先来看看滚动结束的处理。滚动结束后,我们需要更新offset的值。至于为什么不在滚动中处理,等看完代码就知道了。
var deltaOffset = CGPoint.zero
func scrollViewDidScroll(_ scrollView: UIScrollView) {
deltaOffset = (scrollView.contentSize - scrollView.frame.size) * 0.5 - scrollView.contentOffset
}
var centerOffset: CGPoint {
CGPoint(
x: (scrollView.contentSize.width - scrollView.frame.size.width) / 2,
y: (scrollView.contentSize.height - scrollView.frame.size.height) / 2
)
}
func updateOffset() {
if deltaOffset.equalTo(.zero) { return }
offset = CGPoint(
x: offset.x + deltaOffset.x,
y: offset.y + deltaOffset.y
)
for tile in tiles {
tile.value.frame.origin = CGPoint(
x: tile.value.frame.origin.x + deltaOffset.x,
y: tile.value.frame.origin.y + deltaOffset.y
)
}
deltaOffset = .zero
scrollView.setContentOffset(centerOffset, animated: false)
}
// 停止拖拽
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if (!decelerate) {
updateOffset()
}
}
// 停止减速(scroll停止)
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updateOffset()
}
这里有两个关键点:
- endDragging事件是在手脱离屏幕之后触发,当用户快速滚动scrollView的时候,由于setContentOffset的问题,在画面上会导致卡顿感。所以需要在endDragging事件中,仅当不会减速时(decelerate == false)才让dragging去updateOffset(也许我应该把contentSize再设大一点)
- 当用户滚动scrollView之后再滚动scrollView并且停止手指动作时,会连续触发endDragging和endDecelerating事件,而由于setContentOffset是一个异步操作(不会立即执行),所以当两个事件同时执行的时候就会有冲突,解决方案是增加一个中间变量,它在updateOffset中会被初始化,并且阻止连续的updateOffset,这就是deltaOffset的用途。
- 每次updateOffset之后,除了需要让scrollView复位,还需要更新每个tile的origin
回答一下之前的问题,为什么不在滚动中处理offset,因为offset是与contentOffset,tile.origin挂钩的数据,如果在滚动中修改offset,会导致一直在不停地操作scrollView和tile,既浪费性能,也会导致页面有卡顿感,因为setContentOffset这个函数在连续触发时有bug,它不能正确记录滚动的速度和减速度。
最后让我们来处理tile的渲染问题: 首先我们需要计算出需要渲染的tile的范围,然后逐个渲染每个tile,并且删除不在范围内的tile。
// 计算需要渲染的tile
func populateTiles() {
let frame = scrollView.frame.size
let left = Int(round((-frame.width / 2 - offset.x - deltaOffset.x) / tileSize.width))
let right = Int(round((frame.width / 2 - offset.x - deltaOffset.x) / tileSize.width))
let top = Int(round((-frame.height / 2 - offset.y - deltaOffset.y) / tileSize.height))
let bottom = Int(round((frame.height / 2 - offset.y - deltaOffset.y) / tileSize.height))
renderTiles(rows: top...bottom, cols: left...right)
}
// 处理绘制
func renderTiles(rows: ClosedRange<Int>, cols: ClosedRange<Int>) {
for row in rows {
for col in cols {
if !tiles.keys.contains(TileCoordinate(x: col, y: row)) {
createTile(coordinate: TileCoordinate(x: col, y: row))
}
}
}
removeTiles(rows: rows, cols: cols)
}
// 删除不在范围内的tile
func removeTiles(rows: ClosedRange<Int>, cols: ClosedRange<Int>) {
for coordinate in tiles.keys {
if !rows.contains(Int(coordinate.y)) || !cols.contains(Int(coordinate.x)) {
let tile = tiles[coordinate]
tile?.removeFromSuperview()
tiles.removeValue(forKey: coordinate)
continue
}
}
}
看着代码是不是感觉很简单,实际上populateTiles我琢磨了三天,主要还是因为自己的空间想象力有所欠缺。
假设没有offset的情况下,由于中心tile的编号为(0,0),所以左上角tile的编号应该是 x: Int(-frame.size.width / 2 / tileSize.width)
y: Int(-frame.size.height / 2 / tileSize.height) 这表示了左上角tile相对于tile(0,0)的偏移量,同理右下角tile的编号应该是: x: Int(frame.size.width / 2 / tileSize.width)
y: Int(frame.size.heigth / 2 / tileSize.height) 因为绘制的范围从-frame.size / 2 ~ frame.size / 2(以中心分割),所以加上偏移量后应当为:
left: Int(round((-frame.width / 2 - offset.x - deltaOffset.x) / tileSize.width))
right: Int(round((frame.width / 2 - offset.x - deltaOffset.x) / tileSize.width))
top: Int(round((-frame.height / 2 - offset.y - deltaOffset.y) / tileSize.height))
bottom: Int(round((frame.height / 2 - offset.y - deltaOffset.y) / tileSize.height))
offset偏移量只在scrollView停止滚动后刷新,deltaOffset则记录了滚动时的偏移量。
最后是绘制范围内的tile,以及删除范围外的tile。
至此基本完成了无限滚动scrollView的开发,并且可以在运行资源占用中看到,我这个无限滚动scrollView拥有和大神一样的30mb低运行内存占用。
最后我还给scrollView增加了一个scrollViewManager对象,用来将scrollView回到中心位置。
@Observable
final class InfiniteScrollViewManager {
weak var coordinator: InfiniteScrollView.Coordinator!
func backCenter() {
guard let coordinator, let scrollView = coordinator.scrollView else { return }
scrollView.setContentOffset((scrollView.contentSize - scrollView.frame.size) * 0.5 + (coordinator.offset + coordinator.deltaOffset), animated: true)
}
}
代码相对简单,我就不展开了。
详细代码可以去我的github上查看: github