如果 UIScrollView 中每一个 Cell 的宽度被设计为与 UIScrollView 等宽,那只需要一行代码就能实现:
1
| scrollView.pagingEnabled = YES;
|
这个方法非常适合用在多 Tab 的页面切换之类的场景。
但是实际项目中经常会有 Cell 的宽度与 UIScrollView 不等宽的设计,如果仍旧按上面的方法实现,会发现每一页滑动后停留的位置并不在正中央:
这是因为 ScrollView 默认的 Paging 实现为每次滚动一屏(即 ScrollView 自身宽度)的距离。
(1)有个比较 hack 的方式:超出边界绘制。
通常对于多个 Cell 的内容列表,设计上除了当前 Cell 之外还会稍微露出一小部份上一个/下一个 Cell 来提示用户,因此通常 UIScrollView 的宽度是大于单个 Cell 的:
- 如果 Cell 与 UIScrollView 等宽,虽然能满足 Paging 的实现,但是就只能显示一个 Cell;
- 如果 UIScrollView 的宽度大于 Cell,虽然能满足设计上显示超出一个 Cell 的要求,但又不能实现 Paging 效果;
为了同时满足以上两条,可以允许 UIScrollView 的 subViews 超出边界绘制:
1 2
| collectionView.pagingEnabled = YES; collectionView.clipsToBounds = NO;
|
如图所示,实际上只有红框内才是 UIScrollView,两边的 Cell 是超出 UIScrollView 边界绘制的。当然这样虽然能达成目的,但不够优雅,其最大的弊端是必须确保 UIScrollView 同一时间最多只能完整显示一个 Cell,这在 iPad 上(尤其是 iPad 横屏时)的适配表现就很糟糕,相当于强行把手机版 UI 放大显示。
(2)作为合格的谷歌搜索工程师,肯定还能抄到这样的作业:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { let x = targetContentOffset.pointee.x let pageWidth = cellWidth let movedX = x - pageWidth * CGFloat(selectedIndex) if movedX < -pageWidth * 0.5 { selectedIndex -= 1 } else if movedX > pageWidth * 0.5 { selectedIndex += 1 } if abs(velocity.x) >= 2 { targetContentOffset.pointee.x = pageWidth * CGFloat(selectedIndex) } else { targetContentOffset.pointee.x = scrollView.contentOffset.x scrollView.setContentOffset( CGPoint( x: pageWidth * CGFloat(selectedIndex), y: scrollView.contentOffset.y ), animated: true ) } }
|
乍一试这个方案可以满足要求:
但如果对这个列表做一些比较极端和边界的操作就会发现有 Bug,复现方式:缓慢拖动一个 View 到超过中线的位置后松手:
3. 理解实现原理
既然抄不到合格的作业,那就研究下如何自己实现吧。首先可以确定的是,要实现滑动过程的自定义定位,一定和这个方法有关:
1 2 3
| - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset;
|
其中,targetContentOffset
直接决定了列表滑动结束后需要停在哪里,因此通过一些逻辑判断,计算出用户操作的预期是停在哪个 Cell,就手动定位到对应位置即可。整个计算思路如下:
1 2 3 4
| 1. 判断当前是往哪个方向滚动; 2. 判断对应方向还有没有可以展示的 Cell; 3. 判断对应的目标 Cell 在第几位; 4. 计算滚动多远距离才能让目标 Cell 显示在居中位置,并手动让 ScrollView 滚动到目标位置;
|
步骤还是很简单的,并且该方案是通过自定义 UIScrollView 实现,理论上对 UITableView、UICollectionView 等任何基于 UIScrollView 的列表都可用。不过由于 UICollectionView 相比之下多了一些 Section 之类的概念所以需要多考虑一些影响,以 UICollectionView 为例,其他列表 UI 根据以下逻辑调整即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
|
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { if (scrollView != self.collectionView) { return; }
float contentOffset = self.collectionView.contentOffset.x; float scrollViewCenterX = self.collectionView.width / 2; float pagingWidth = [self _getPagingWidth]; float cellWidth = [self _getCellWidth];
if (pagingWidth <= 0) { _centerItemRow = 0; } else { float midpointOffset = contentOffset + scrollViewCenterX; _centerItemRow = floor(midpointOffset / pagingWidth); }
float allContentWidth = self.collectionView.contentSize.width; NSInteger newItemRow = _centerItemRow; if (velocity.x == 0) { } else { newItemRow = velocity.x > 0 ? newItemRow + 1 : newItemRow - 1; if (newItemRow < 0) { newItemRow = 0; } if (newItemRow > allContentWidth / pagingWidth) { newItemRow = ceil(allContentWidth / pagingWidth) - 1.0; } _centerItemRow = newItemRow; }
float insetPadding = SECTION_SPACING_HORIZONTAL; float firstPageOffset = scrollViewCenterX - cellWidth / 2 - insetPadding;
targetContentOffset->x = (newItemRow * pagingWidth) - firstPageOffset; }
|
看下成果,不仅滑动丝滑、定位精准、支持慢速逐个滚动与快速多个滚动、还没有抄作业的 Bug:
当然,这里的示例是没有根据不同滚动速度调整切页的速度,但要加上也很简单,只需要根据产品/设计的要求对不同档位的 velocity 做判断、或是算个 log 函数来调整一次切换几个 row 即可。