UIScrollView 能够滚动显示宽度或高度大于其 bounds 的内容。有些时候,须要有分页效果。每一页有统一的大小,相邻无缝水平或垂直排列。当水平或垂直滚动松开手后,会在其中一页彻底显示的位置停下,滚动的距离是一页宽度或高度的整数倍。具体实现方法分两种状况讨论:分页大小等于、小于 bounds 大小。分页大小大于 bounds 大小的状况,不知道有什么应用场景,不讨论。html
若是分页大小与 bounds 大小相等,把 UIScrollView 的 isPagingEnabled 属性设置为 true 便可。此属性的官方解释git
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 宽度或高度的整数倍。github
用 UIScrollView 和 UICollectionView 实现的方法不同,须要分别讨论。swift
代码已上传 GitHub:https://github.com/Silence-GitHub/PageScrollViewDemo数组
UIScrollView 的 clipsToBounds 属性默认为 true,超出 bounds 的子视图(超出部分)是看不到的。能够把 clipsToBounds 设置为 false,把 isPagingEnabled 设置为 true,把 bounds 设置为须要的分页大小,在视觉上就基本达到分页效果了。然而,这样会出现的问题是:ide
对于第 1 个问题,能够设置 scrollIndicatorInsets 属性的值,调整滚动条位置。或者隐藏滚动条,把 showsVerticalScrollIndicator 和 showsHorizontalScrollIndicator 都设置为 false。能够用 UIPageControl 或自定义控件来显示当前分页在全部分页中的位置。性能
对于第 2 个问题,能够把当前所在 UIViewController 的 view 的 clipsToBounds 设置为 true;或者把 scroll view 放在另外一个 UIView 上,把这个 UIView 的 clipsToBounds 设置为 true。ui
对于第 3 个问题,须要重载 hitTest(_:with:) 方法。此方法的官方介绍this
Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.
此方法返回包含触摸点的最上层视图(UIView),没有则返回nil。触摸屏幕时,屏幕上的视图经过此方法寻找发生触摸的视图。code
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 并肯定 frame 或 bounds 后,须要给 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 用以上的方法实现,出现的问题是,不在 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(_:animated:) 方法,当即以默认速度滚动到终点。
如今,还有一个小问题,就是滚动到最后一页时,滚动中止的位置不固定。最后一页中止的位置有时候靠屏幕左边,有时靠右。从最后一页往回滚动可能会有点奇怪(忽然加速)。解决办法是增长一个 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
decelerationRate 是 UIScrollView 的属性,设置为 UIScrollViewDecelerationRateFast,表示滚动松开手后减速更快(加速度与速度方向相反,加速度的绝对值增大),于是滚动会很快减速并中止。
若是必定要 UICollectionView 显示分页内容,而且彻底有 isPagingEnabled 为 true 的分页效果,能够结合 UIScrollView 来实现。如下是大概思路。
把 UICollectionView 放在底部,正常显示内容。把上文自定义的 PageScrollView 放在顶部,响应触摸范围为 UICollectionView 的范围,设置 UIScrollView 的 contentSize。触摸发生在 scroll view 上。在 UIScrollViewDelegate 的 scrollViewDidScroll(_:) 方法中,让 collection view 跟着 scroll view 滚动。若是要 collection view 响应选中 cell 等操做,须要写其余的代码。
这个方法比较麻烦,要把对 scroll view 的手势传给 collection view,每次刷新数据都要从新设置 scroll view 的 contentSize。具体见 GitHub:https://github.com/Silence-GitHub/PageScrollViewDemo
转载请注明出处:http://www.cnblogs.com/silence-cnblogs/p/6529728.html