当你把 UIScrollView 的 isPagingEnabled 属性设置为 true 时,你便得到了 Apple 精心准备好的分页动画过渡效果。即当你停止拖拽时,系统会根据滚动进度来决定回到上一页还是去往下一页。不过它的局限性在于分页的大小完全取决于 UIScrollView 本身的 frame.size,无法自定义 pageSize。在某个时间开始,占据整个容器大小的轮播或者滚动视图已经无法满足设计的审美以及产品的需求了。取而代之的是除了完全显示当前页之外,还需要露出前一页和后一页的部分内容。如果用“象形文字”描述的话,大致长这样:(请全屏查看,🤪)

1
2
3
4
5
/**
|--| |-----------------| |--|
| P| | C | | N|
|--| |-----------------| |--|
*/

那么,接下来我们一起来讨论一下需求的实现方式~

论 iOS 13+ 后,如何躺着舒服

无他,在 iOS 13 及之后,Apple 贴心的为开发者准备了 UICollectionViewCompositionalLayout,拯救了那本已所剩无几的头发!它涉及到不少新的布局概念,除了接下来的代码相关使用,笔者不打算啰嗦太多。欲知更多精彩内容,请点击🔗

总的来说,它使用 NSCollectionLayoutSection 作为与 IndexPath.Section 对应的布局单元,同时每个 NSCollectionLayoutSection 包含一个 NSCollectionLayoutGroup 用于描述其下最基本的布局元素 NSCollectionLayoutItem(对应于一个 UICollectionViewCell)。(其中 NSCollectionLayoutGroup 继承自 NSCollectionLayoutItem,从而可以实现 Group 之间的嵌套。)

1
2
3
4
5
6
7
8
9
10
11
12
/**
NSCollectionLayoutSection
|
|--- NSCollectionLayoutGroup
|
|--- NSCollectionLayoutGroup
| |
| |--- NSCollectionLayoutItem
|--- NSCollectionLayoutItem
|--- NSCollectionLayoutItem
|--- NSCollectionLayoutItem
*/

它们之间的大小关系通过 NSCollectionLayoutSize 来表示,包含绝对值、相对上一级的比例关系和预估值三种情况。活学活用,在这些知识背景下,我们的问题可以这样被解决:

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
override func compositionSection(
in env: any NSCollectionLayoutEnvironment,
at section: Int
) -> NSCollectionLayoutSection? {
// item 的 size 与 group 相等
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// trailing 用于设置水平方向上的 item 间距
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 14)
// group 高度设定绝对值可用于撑起 section 的高度,group 的宽度为 section 的 0.83 倍。
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.83),
heightDimension: .absolute(134)
)
// 表示每个 group 只有一个 item,如果有多个 item 就会被自动生成多个 group。
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
// 核心代码
section.orthogonalScrollingBehavior = .groupPaging
// 假定上、左、右间距 16,因为 item 右边本身有 14 的间距,所以这里的 trailing 为 2。
section.contentInsets = .init(top: 16, leading: 16, bottom: 0, trailing: 2)
return section
}

自食其力,方得始终

什么?都 iOS 18 了,为什么还要支持 iOS 12 啊?做不了,根本做不了!开个玩笑~

本着“我很懒”的思想原则,我们自实现方案的目标是在充分利用默认行为的前提下打破 pageSize 的限制。在毫无头绪的时候,不妨从头开始梳理技术方案的硬性要求:

  1. 需要有一个自定义 pageSize 规格的 UIScrollView
  2. UIScrollView 它的实际内容 size 大于 pageSize
  3. 按照自定义 pageSize 实现分页动画效果

毫无疑问,用一个 UIScrollView 是不可能实现的。你都既要又要了,我用两个 UIScrollView 也是合情合理的。下面是方案的具体描述:

  1. 创建两个 UIScrollView 实例 A,B
  2. A 包含所有的视图内容,但是不接受手动滚动事件。
  3. B 是滚动行为视图,通过控制它的大小实现自定义 pageSize。
  4. A 通过 KVO 观察 B 的 contentOffset,并与之相等。B 观察 A 的 contentSize,宽度等于 itemsCount * B.frame.width,高度等于 A。(这里假定水平方向滚动)
  5. 在视图层级上,B 将覆盖 A。因为自定义 pageSize 通常更小,所以需要覆写响应链相关方法实现触摸事件的转移。

那么接下来,就将思路转换为代码:

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103

final class PageView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
_configureSubviews()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// 确定事件响应的条件,如某个按钮被点击
let point = convert(point, to: _button)
return _button.point(inside: point, with: event)
}

private func _configureSubviews() {}
}

final class CustomPageSizeView: UIView {
let itemWidth: CGFloat
let itemMargin: CGFloat
let padding: (left: CGFloat, right: CGFloat)
init(itemWidth: CGFloat, itemMargin: CGFloat, padding: (left: CGFloat, right: CGFloat)) {
self.itemWidth = itemWidth
self.itemMargin = itemMargin
self.padding = padding
super.init(frame: .zero)
_configureSubviews()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

var pageIdx: Int { Int(_actionScrollView.contentOffset.x / _actionScrollView.frame.width) }

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard !isHidden else { return nil }

var result: UIView?
for _pageView in _pageViews {
let point = convert(point, to: _pageView)
result = _pageView.hitTest(point, with: event)
if result != nil { break }
}
return result ?? _actionScrollView
}

func apply(models: [Any]) {}

private func _configureSubviews() {
addSubview(_actionScrollView)
addSubview(_containerScrollView)

_containerScrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(50) // ⚠️ 需要设定高度值,可在添加子视图时动态计算
}
_actionScrollView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.height.equalTo(_containerScrollView)
make.width.equalTo(itemWidth + itemMargin)
}
_contentOffSetKVOToken = _actionScrollView.observe(
\.contentOffset,
options: .new
) { [weak self] scrollView, _ in
guard let self else { return }
self._containerScrollView.contentOffset.x = scrollView.contentOffset.x
}
_contentSizeKVOToken = _containerScrollView.observe(
\.contentSize,
options: .new
) { [weak self] scrollView, _ in
guard let self else { return }
let itemsCount = CGFloat(self._pageViews.count)
let width = self._actionScrollView.frame.width * itemsCount
self._actionScrollView.contentSize = CGSize(width: width, height: scrollView.contentSize.height)
}
}

private lazy var _actionScrollView: UIScrollView = {
let result = UIScrollView()
result.isPagingEnabled = true
result.showsHorizontalScrollIndicator = false
return result
}()

private lazy var _containerScrollView: UIScrollView = {
let result = UIScrollView()
result.isScrollEnabled = false
result.showsHorizontalScrollIndicator = false
return result
}()

private var _pageViews: [PageView] { _containerScrollView.subviews.compactMap { $0 as? PageView } }

private var _contentOffSetKVOToken: NSKeyValueObservation?
private var _contentSizeKVOToken: NSKeyValueObservation?
}

秘技:抛砖引玉术

其他可行的方案……

总结

看得出来,Apple 一直都在致力于简化界面开发的复杂度,让开发者能够更加专注于业务本身。但是由于历史包袱的存在,我们几乎总是需要在好多年之后才能用上当时的新特性。而自然遗忘又是常态,当反应过来的时候已经走上了老路。所以笔者养成了一个习惯,每次当 App 兼容的系统版本实现跨越时,就会去温习一下 WWDC,看看是否有“新的玩具”可以把玩。毕竟大树底下好乘凉,能让 Apple 干的活绝对不碰一下,skr~