iOS-UIScrollView翻页效果

iOS-UIScrollView翻页效果

1. Cell与UIScrollView等宽

如果 UIScrollView 中每一个 Cell 的宽度被设计为与 UIScrollView 等宽,那只需要一行代码就能实现:

1
scrollView.pagingEnabled = YES;

这个方法非常适合用在多 Tab 的页面切换之类的场景。


2. Cell与UIScrollView不等宽

但是实际项目中经常会有 Cell 的宽度与 UIScrollView 不等宽的设计,如果仍旧按上面的方法实现,会发现每一页滑动后停留的位置并不在正中央:

默认 Paging 实现为滚动 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;

Cell 与 UIScrollView 不等宽

如图所示,实际上只有红框内才是 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
// 引用来源:https://www.cnblogs.com/silence-cnblogs/p/6529728.html

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

乍一试这个方案可以满足要求:

网上方案的滑动效果

但如果对这个列表做一些比较极端和边界的操作就会发现有 Bug,复现方式:缓慢拖动一个 View 到超过中线的位置后松手:

网上方案的 Bug


3. 理解实现原理

既然抄不到合格的作业,那就研究下如何自己实现吧。首先可以确定的是,要实现滑动过程的自定义定位,一定和这个方法有关:

1
2
3
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
withVelocity:(CGPoint)velocity // 滑动的速度
targetContentOffset:(inout CGPoint *)targetContentOffset; // UIScrollView 目标的停止位置坐标

其中,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
// 部份宏定义可以根据命名理解其作用。

/**
* @Note
* 计算松手后的瞬间哪个 Cell 最接近正中间,将其作为需要自动校正前的 Cell,
* 因此这个计算逻辑必须放在松手后(即该回调方法)再判断,而不是 Scroll 刚开始的时候,
* 因为从动画执行到真正改变 offset 需要一段时间,立即获取 contentOffset 仍可能是滑动前的。
*/
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint *)targetContentOffset {
if (scrollView != self.collectionView) {
return;
}

/// @Note 1. 计算当前中心位置的点属于第几个 Item。
///
/// contentOffset 表示的是列表头部(而不是列表中心)距离内容起点的距离,
/// 对于横向列表则是列表最左侧距离起点的距离,
/// 所以列表中心的实际偏移 midpointOffset = 左侧 (contentOffset) + 列表宽度的一半 (UIScrollView.width / 2)。
float contentOffset = self.collectionView.contentOffset.x;
float scrollViewCenterX = self.collectionView.width / 2;
// 可以画个图或者体验一下这里的功能,思考下 pagingWidth 与 cellWidth 与 collectionViewWidth 之间的关系。
float pagingWidth = [self _getPagingWidth];
float cellWidth = [self _getCellWidth];

if (pagingWidth <= 0) {
_centerItemRow = 0;
} else {
float midpointOffset = contentOffset + scrollViewCenterX;
_centerItemRow = floor(midpointOffset / pagingWidth);
}

/// @Note 2. 根据滑动速度 velocity 计算用户的拖动行为目的是滑至哪一个 Cell。
/// 因为要实现的是 Paging 效果,所以只需要判断 velocity 值的正负即可。
float allContentWidth = self.collectionView.contentSize.width;
NSInteger newItemRow = _centerItemRow;
if (velocity.x == 0) {
// 速度为 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;
}
// 速度不为 0,这里就找到了目标需要显示在中间的 Cell 的 index。
_centerItemRow = newItemRow;
}

/// @Note 3. 将 CollectionView 手动位移到对应的页面。
///
/// 列表居中位置 scrollViewCenterX = UIScrollView.width / 2;
/// 相邻分页需要滑动的距离为 pagingWidth = Item_n.centerX - Item_(n-1).centerX。
///
/// 当 UIScrollView 的宽度远超单个 Cell 的宽度(例如 iPad)时,
/// 第一个 Item_0 会显示在列表头部而不是一开始就居中,
/// 此时 contentOffset = 0 但 Item_0.centerX > 0,
/// 所以从 Item_0 滑动至 Item_1 不一定需要 pagingWidth 那么多,
/// 相差的 Diff = scrollViewCenterX - Item_0.centerX(即想让 Item_0 居中的距离),
/// 也等于 Item_0.left 滑动至 scrollViewCenterX 再减自己一半宽度。
///
/// 如果 UIScrollView 还有 insetPadding 则 Diff 还要减去对应的间隔,
/// 因此最终 Diff = scrollViewCenterX - Item_0.centerX - insetPadding
float insetPadding = SECTION_SPACING_HORIZONTAL;
float firstPageOffset = scrollViewCenterX - cellWidth / 2 - insetPadding;

// 最终计算出来需要让 ScrollView 滚动结束时停留在哪个位置:
targetContentOffset->x = (newItemRow * pagingWidth) - firstPageOffset;
}

看下成果,不仅滑动丝滑、定位精准、支持慢速逐个滚动与快速多个滚动、还没有抄作业的 Bug:

优化方案

当然,这里的示例是没有根据不同滚动速度调整切页的速度,但要加上也很简单,只需要根据产品/设计的要求对不同档位的 velocity 做判断、或是算个 log 函数来调整一次切换几个 row 即可。