UIScrollView 和 UICollectionView 分页效果

UIScrollView 和 UICollectionView 分页效果

UIScrollView可以滚动显示宽度或高度大于其bounds的内容。有些时候,需要有分页效果。每一页有统一的大小,相邻无缝水平或垂直排列。当水平或垂直滚动松开手后,会在其中一页完全显示的位置停下,滚动的距离是一页宽度或高度的整数倍。具体实现方法分两种情况讨论:分页大小等于、小于bounds大小。分页大小大于bounds大小的情况,不知道有什么应用场景,不讨论。

分页大小等于 bounds 大小

如果分页大小与 bounds 大小相等,把UIScrollViewisPagingEnabled属性设置为true即可。此属性的官方解释

If the value of this property is true, the scroll view stops on multiples of the scroll view’s bounds when the user scrolls.

每一页的大小为bounds的大小,每次水平或垂直滚动的距离是bounds宽度或高度的整数倍。

分页大小小于 bounds 大小

UIScrollViewUICollectionView实现的方法不一样,需要分别讨论。

代码已上传 GitHub:https://github.com/Silence-GitHub/PageScrollViewDemo

UIScrollView 分页

UIScrollViewclipsToBounds属性默认为true,超出bounds的子视图(超出部分)是看不到的。可以把clipsToBounds设置为false,把isPagingEnabled设置为true,把bounds设置为需要的分页大小,在视觉上就基本达到分页效果了。然而,这样会出现的问题是:

  1. 滚动条只在bounds以内显示(所以分页效果只是视觉上“基本达到”)
  2. UIScrollView显示的内容会超出所在UIViewController的view所在范围,当UINavigationController发生 push 或 pop 时,可能会看到超出部分,不美观
  3. 触摸bounds以外的区域没有响应

对于第 1 个问题,需要隐藏滚动条,把showsVerticalScrollIndicatorshowsHorizontalScrollIndicator都设置为false。既然要分页效果,滚动条就没必要显示。可以用UIPageControl或自定义控件来显示当前分页在所有分页中的位置。非要显示滚动条的情况不讨论。

对于第 2 个问题,可以把当前所在UIViewControllerviewclipsToBounds设置为true;或者把 scroll view 放在另一个UIView上,把这个UIViewclipsToBounds设置为true

对于第 3 个问题,需要重载func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?方法。此方法的官方介绍

Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.

此方法返回包含触摸点的最上层视图(UIView),没有则返回nil。触摸屏幕时,屏幕上的视图通过此方法寻找发生触摸的视图。

Points that lie outside the receiver’s bounds are never reported as hits, even if they actually lie within one of the receiver’s subviews. This can occur if the current view’s clipsToBounds property is set to false and the affected subview extends beyond the view’s bounds.

当触摸点在bounds之外,此方法返回nil,表示当前视图不是发生触摸的视图。这就是问题的原因。需要自定义UIScrollView,重载此方法,让此方法在bounds之外触摸当前视图也返回被触摸的视图。自定义类PageScrollView

class PageScrollView: UIScrollView {
    
    var interactionAreaNotInBounds: [CGRect] = [] // Use bounds coordinate system
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        clipsToBounds = false
        isPagingEnabled = true
        showsVerticalScrollIndicator = false
        showsHorizontalScrollIndicator = false
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // Bounds is changed when scrolling
        // Update interaction area not in bounds according to current bounds
        let bounds = self.bounds
        let areas = interactionAreaNotInBounds.map { (rect) -> CGRect in
            return CGRect(x: bounds.minX + rect.minX,
                          y: bounds.minY + rect.minY,
                          width: rect.width,
                          height: rect.height)
        }
        // Find area contains point
        for area in areas where area.contains(point) {
            // Check subview
            for subview in subviews {
                // Convert point from current coordinate system to that of subview
                let convertedPoint = convert(point, to: subview)
                // Hit-test subview and return it if it is hit
                if let view = subview.hitTest(convertedPoint, with: event) {
                    return view
                }
            }
            // Return self if no subview is hit
            return self
        }
        // No area contains point
        // Do super hit-test
        return super.hitTest(point, with: event)
    }
}

初始化PageScrollView并确定framebounds后,需要给interactionAreaNotInBounds属性赋值。把bounds之外会响应触摸的区域(用bounds最初的坐标)写成数组进行赋值。例如,frame(30, 0, 100, 100),要让左边宽 30、高 100 的区域为响应区域,则给interactionAreaNotInBounds赋值为[CGRect(x: -30, y: 0, width: 30, height: 100)]

当要分页的页数较少、每页内容不多的时候,可以用这个方法实现。如果要显示很多页的内容,一次把所有分页视图加到 scroll view 上,影响性能。这种情况可以用UICollectionView实现,UICollectionViewCell是重用的,节约资源。用UICollectionView实现的方法不同。

UICollectionView 分页

如果UICollectionView用以上的方法实现,出现的问题是,不在bounds之内的UICollectionViewCell可能消失。因为 cell 是重用的,移出bounds之后可能就被移除而准备重用。UICollectionView继承自UIScrollView,可以通过UIScrollViewDelegate的方法,模拟分页效果。具体实现方法与分页大小有关。

分页较大

当分页较大时,比如水平滚动,一页宽度大于屏幕宽度一半,每次滚动的最远距离就限制到相邻分页。这样的限制与isPagingEnabled的效果基本符合。实现UIScrollViewDelegate的一个方法即可。

private var selectedIndex: Int = 0 // index of page displayed
private let cellWidth: CGFloat = UIScreen.main.bounds.width - 100
private let cellHeight: CGFloat = 100

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    // Destination x
    let x = targetContentOffset.pointee.x
    // Page width equals to cell width
    let pageWidth = cellWidth
    // Check which way to move
    let movedX = x - pageWidth * CGFloat(selectedIndex)
    if movedX < -pageWidth * 0.5 {
        // Move left
        selectedIndex -= 1
    } else if movedX > pageWidth * 0.5 {
        // Move right
        selectedIndex += 1
    }
    if abs(velocity.x) >= 2 {
        targetContentOffset.pointee.x = pageWidth * CGFloat(selectedIndex)
    } else {
        // If velocity is too slow, stop and move with default velocity
        targetContentOffset.pointee.x = scrollView.contentOffset.x
        scrollView.setContentOffset(CGPoint(x: pageWidth * CGFloat(selectedIndex), y: scrollView.contentOffset.y), animated: true)
    }
}

selectedIndex表示当前分页序号,默认显示最左边的一页,因此初始化为 0。如果最开始显示其他页,需要改变selectedIndex的值。通过selectedIndex的值,将要停下来的坐标x,计算出位移movedX。当位移绝对值大于分页宽度的一半时,滚动到位移方向的相邻页。

targetContentOffset.pointee.x赋值,改变滚动终点的x坐标。宽度较大的分页效果滚动速率不能太慢,所以当速率小于 2 时,给targetContentOffset.pointee.x赋值为当前位置即停止滚动,调用setContentOffset(_ contentOffset: CGPoint, animated: Bool)方法,立即以默认速度滚动到终点。

现在,还有一个小问题,就是滚动到最后一页时,滚动停止的位置不固定。最后一页停止的位置有时候靠屏幕左边,有时靠右。从最后一页往回滚动可能会有点奇怪(突然加速)。解决办法是增加一个UICollectionViewCell放到最后,cell 的宽度为屏幕宽度减分页宽度,使最后一页滚动的停止位置都靠屏幕左边。假设分页数量(UICollectionViewCell的数量)为numberOfItems,以下是 cell 的大小

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    switch indexPath.item {
    case numberOfItems:
        return CGSize(width: UIScreen.main.bounds.width - cellWidth, height: cellHeight)
    default:
        return CGSize(width: cellWidth, height: cellHeight)
    }
}

分页较小

当分页较小时,屏幕宽度可以显示好几个分页,就不能把滚动距离限制到相邻分页。直接判断滚动终点离哪个分页比较近,以近的分页为终点。

private let cellWidth: CGFloat = 100
private let cellHeight: CGFloat = 100

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    // Destination x
    let x = targetContentOffset.pointee.x
    // Page width equals to cell width
    let pageWidth = cellWidth
    // Destination page index
    var index = Int(x / pageWidth)
    // Check whether to move to next page
    let divideX = CGFloat(index) * pageWidth + pageWidth * 0.5
    if x > divideX {
        // Should move to next page
        index += 1
    }
    // Move to destination
    targetContentOffset.pointee.x = pageWidth * CGFloat(index)
}

同样需要在最后增加一个 cell,防止滚动到最后一页出问题。假设屏幕宽度最多能容纳 n 个 cell (n + 1 个就超出屏幕),那么 cell 的宽度为屏幕宽度减 n 个 cell 的宽度。以下是 cell 的大小

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    switch indexPath.item {
    case numberOfItems:
        let n = Int(UIScreen.main.bounds.width / cellWidth)
        let d = UIScreen.main.bounds.width - cellWidth * CGFloat(n)
        return CGSize(width: d, height: cellHeight)
    default:
        return CGSize(width: cellWidth, height: cellHeight)
    }
}

现在滚动效果的问题是,从松开手到停止滚动的时间太长。加上一句代码就能解决

collectionView.decelerationRate = UIScrollViewDecelerationRateFast

decelerationRateUIScrollView的属性,设置为UIScrollViewDecelerationRateFast,表示滚动松开手后减速更快(加速度与速度方向相反,加速度的绝对值增大),因而滚动会很快减速并停止。

UIScrollView + UICollectionView 分页

如果一定要UICollectionView显示分页内容,并且完全有isPagingEnabledtrue的分页效果,可以结合UIScrollView来实现。以下是大概思路。

UICollectionView放在底部,正常显示内容。把上文自定义的PageScrollView放在顶部,响应触摸范围为UICollectionView的范围,设置UIScrollViewcontentSize。触摸发生在 scroll view 上。在UIScrollViewDelegatescrollViewDidScroll(_ scrollView: UIScrollView)方法中,让 collection view 跟着 scroll view 滚动。如果要 collection view 响应选中 cell 等操作,需要写其他的代码。

这个方法比较麻烦,要把对 scroll view 的手势传给 collection view,每次刷新数据都要重新设置 scroll view 的contentSize。具体见 GitHub:https://github.com/Silence-GitHub/PageScrollViewDemo

相关推荐