无限翻滚scrollView

前不久看到这篇文章:itnext.io/creating-an…

是国外一个大佬处理的一个stackoverflow发问:怎么样发明一个能够无限翻滚的scrollView。我依据这篇文章的首要思想在不运用束缚的情况下作出自己的无限翻滚scrollView,希望能给大家带来协助。

在开端之前,首先向大家说明该项意图前提条件:

  1. 该scrollView应当看起来是能够无限翻滚的,即用户不会看到scrollView的碰撞边缘;
  2. 该scrollView的内存占用应该尽量少,即不能靠制作一个巨大的空间(例如1000000×1000000)来到达无限翻滚的意图;
  3. scrollView内的视图和数据应当经过tile,也就是瓦片来分批生成。

大神思路拆解

  1. 经过运用束缚撑大scrollView的ContentSize
  2. 经过Key-Value Observing机制,监听scrollView的翻滚
  3. 在翻滚的过程中动态地添加tile
  4. 在翻滚完毕的节点(endDragging和endDecelerating)让scrollView的offset恢复到初始状态

大神的具体操作能够去看他对应的文章和github代码,这儿就不具体解释了。

我的思路:

  1. 我需求将整个scrollView经过UIViewRepresentable转换成SwiftUI的view,所以我不能运用storyboard和束缚。经过分析可得,能够经过直接设置scrollView的contentSize来到达这一作用。
  2. 我也不需求运用KVO机制来监听翻滚,只需求经过UIScrollViewDelegate的scrollViewDidScroll办法来处理翻滚事情即可。
  3. 我需求以中心点为起点,向四周来烘托所需的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为100000×100000的scrollView。而且将scrollView的初始Offset设置为了中心。现在暂时还看不出什么,而且因为我封闭了翻滚条显现,咱们甚至不能感受到翻滚的发生。可是别着急,咱们行将开端烘托tile。

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

无限翻滚scrollView

咱们发现为什么坐标有问题?这个方形的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
    }

无限翻滚scrollView

这样一来,烘托就正确了。

代理事情

接下来就是处理翻滚相关的事情了,要害有三个事情钩子: 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()
}

这儿有两个要害点:

  1. endDragging事情是在手脱离屏幕之后触发,当用户快速翻滚scrollView的时分,因为setContentOffset的问题,在画面上会导致卡顿感。所以需求在endDragging事情中,仅当不会减速时(decelerate == false)才让dragging去updateOffset(也许我应该把contentSize再设大一点)
  2. 当用户翻滚scrollView之后再翻滚scrollView而且中止手指动作时,会接连触发endDragging和endDecelerating事情,而因为setContentOffset是一个异步操作(不会立即执行),所以当两个事情同时执行的时分就会有抵触,处理方案是增加一个中间变量,它在updateOffset中会被初始化,而且阻止接连的updateOffset,这就是deltaOffset的用处。
  3. 每次updateOffset之后,除了需求让scrollView复位,还需求更新每个tile的origin

回答一下之前的问题,为什么不在翻滚中处理offset,因为offset是与contentOffset,tile.origin挂钩的数据,如果在翻滚中修改offset,会导致一直在不停地操作scrollView和tile,既糟蹋性能,也会导致页面有卡顿感,因为setContentOffset这个函数在接连触发时有bug,它不能正确记载翻滚的速度和减速度。

烘托tile

最终让咱们来处理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低运转内存占用。

scrollViewManager

最终我还给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