公司的项目有一个页面需要在 Segment Control 上面加一个 header,

原本的是现实监听 segment controller 下每一个列表的 contentOffset 以调整 header 的位置,尽管能用,但毕竟它的最外层不是一个 UIScrollView 了,接手之后突然让我给整体加个加拉刷新,着实是让我很烦心。当时时间比较赶,我就直接加了个 UIPanGestureRecognizer,效果还是很糟糕的。最近比较闲,于是想着重构一下这个部分,我当然希望最外层是一个 UIScrollView,但这就涉及到了两个嵌套 scroll view 的问题了:

  1. 什么时候拖动哪一个 scroll view;
  2. 当一个 scroll view over scroll 的时候,怎么反映到另外一个 scroll view 上;

Google 了一番,看到的一些想法是监听 scroll view 的 contentOffset 并判断拖动的是哪一个 scroll view。这个方法可以解决第一个问题,但是却不能解决第二个问题。当然我们也可以实现一个 pan gesture 完全接管嵌套 scroll view 的滑动,但这样就失去了 UIScrollView 自带的 over scroll 效果了。

后来我想到了 UICollectionView。用正常的 flow layout 时也许不太明显,但当我们在做一些奇怪的布局的时候,不就是将 contentOffset 转化成了各个 cell 的 frame 吗?我们是不是可以把 contentOffset 看作是进度progress,从而计算出当前进度下各个部分的 frame 呢?

当然我们才不要用 UICollectionView,没有理由又做一个 cell 把 view controller 填到里面去。

Content Offset 看作进度

我们首先看一个例子:我们希望把两个 UITableView 在竖直方向上串联起来,它们在滚动时,看起来就像是一个 UITableView 一样。


能够想象出来的一种布局方案就是:

  1. 最外层是一个 scroll view,姑且叫做 M;
  2. scroll view 中竖直放置两个连续的 table view,A 和 B,两个 table view 的宽高皆与最外层的 M 相等;
  3. 当 A 的 content size 足够长时,我们让 M 下滚时,首先让 A 下滚;
  4. A 下滚到尽头之后,变为同时上移 A 和 B;
  5. 当 B 上移到 M 顶部之后,变为让 B 下滚。

幸运的是 A、B 的 framecontentOffset 都能映射到 M 的 contentOffset 上。也就是说我们只需要给 scroll view 增加一个功能,让它在 layoutSubviews 的过程中根据 contentOffset 设置 A 和 B 的 framecontentOffset 就行了。

在这个例子里 A 的 contentOffset 实际上就是 M 的 contentOffset;而 B 的 contentOffset 只需将 M 的 contentOffset 减去 A 的 contentSize.height 即可得到。实际实现中会需要考虑到一些别的情况,这里便不再累赘了。


我们姑且称这个新的 scroll view 为 NonScrollViewNonScrollView 会在 layoutSubviews 末尾执行上述的逻辑。我们希望 scroll view 的滚动完全由 NonScrollView 接管,因此需要先将 scroll view 设为不可滚动。

1
2
3
4
5
6
7
8
9
10
private func layoutMappedViews() {
for placer in layout.viewPlacers {
...
let frameInVisible = placer.generateViewFrame(frameOfReference)
let frame = CGRect(origin: frameInVisible.origin + contentOffset,
size: frameInVisible.size)
placer.view.frame = frame
placer.updateView?(frameOfReference)
}
}

我们需要为 NonScrollView 提供一些 ViewPlacer,它们包含了需要更新的 view 以及两个 block 用来生成对应状态下的 frame 和更新 view 的状态。

1
2
3
4
5
class ViewPlacer {
let view: UIView
let generateViewFrame: (FrameOfReference) -> CGRect
let updateView: ( (FrameOfReference) -> Void )?
}

NonScrollViewcontentSize 也需要我们提供一个逻辑来生成,在这个例子当中,它应该就是 A.contentSize + B.contentSize(竖直方向上)。为了能够在 A、B 高度发生变化时动态地更新 M 的高度,我们可以通过 KVO 进行监听,并适时更新 M.contentSize

至此我们已经可以实现图中的效果了。

Content Offset 看作手势


但当回到我们最初的需求,这个方法就不可行了。

在最初的需求当中,我们可以切换到不同的页面,但不同页面的 contentOffset 可能并不一样,这就导致了进度,M.contentOffset,不一样。如果进度不一样,按照上一个例子的做法,segment control 的位置也会发生改变。也就与我们的需求发生了冲突。我的天啊。

正当我要绝望的时候,我想到既然我们可以用一个手势完全接管滑动,为什么我们不能把 contentOffset 的变化就看作是一种手势呢?contentOffset 自身的值就是 location(in:),我们还可以记录一个 lastContentOffset,两者作差,就得到了 translation(in:),再加上一些 state 属性,俨然一个 gesture recognizer。


1
2
3
4
5
6
7
8
9
10
11
12
class NonScrollViewScrollRecognizer {
public var onChange: ((NonScrollViewScrollRecognizer)->Void)? = nil
public var scrollState: ScrollState { return ... }
public var touchState: UIGestureRecognizer.State { return ... }
public var lastContentOffset: CGPoint = .zero
public var contentOffset: CGPoint = .zero
public var translation: CGPoint { return contentOffset - lastContentOffset }

public func touchLocation(in view: UIView?) -> CGPoint? {
return panGestureRecognizer.location(in:view)
}
}

不如来试一试吧。

我们依旧创建一个 NonScrollView,但在 generateFrame 中我们返回的东西就简单多了,毕竟这次我们不再需要将 contentOffset 映射成 frame,而是根据 contentOffset 的变化不断改变 frame 以及当前显示的 scroll view 的 contentOffset

我们可以用一个属性 segmentControllerOrigin 用来记录 segment controller 在屏幕上的位置,并以此计算出 headerVC 的位置。

1
2
3
4
5
6
7
8
9
viewPlacers: [
.init(view: headerVC.view, generateFrame: { [unowned self] ref in
return .zero
+ CGSize(width: ref.size.width, height:self.segmentControllerOrigin.y)
}),
.init(view: segmentController.view, generateFrame: { [unowned self] ref in
return .init(origin: self.segmentControllerOrigin, size: ref.size)
}),
]

接下来就跟我们使用 UIPanGestureRecognizer 的时候差不多了。我们可以通过 onChange 属性监听 NonScrollViewScrollRecognizer 的变化。比方说列表在往上滚,且 segment controller 还未至顶的时候,我们就可以将竖直方向的位移加给 segmentControllerOrigin,而在至顶之后加给当前页面的 contentOffset

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
switch (self.currentScrollView, hitTop, scrollDirection) {
case (let scrollable?, true, .scrollUp):

if scrollable.contentOffset.y > 0 {
let newOffset = scrollable.contentOffset + rec.translation
scrollable.contentOffset = CGPoint(x: 0, y: max(newOffset.y, 0))
if newOffset.y < 0 { // over scroll
self.segmentControllerOrigin -= CGPoint(x: 0, y: newOffset.y)
self.calibrateContentOffset()
}
} else {
let newOrigin = self.segmentControllerOrigin - rec.translation
self.segmentControllerOrigin = .init(x: 0, y: max(newOrigin.y, 0))
}

case (let scrollable?, false, .scrollUp):

if scrollable.contentOffset.y > 0 {
if self.touchBeginsInSegmentController {
let newOffset = scrollable.contentOffset + rec.translation
scrollable.contentOffset = CGPoint(x: 0, y: max(newOffset.y, 0))
if newOffset.y < 0 { // over scroll
self.segmentControllerOrigin -= CGPoint(x: 0, y: newOffset.y)
self.calibrateContentOffset()
}
} else {
let newOrigin = self.segmentControllerOrigin - rec.translation
self.segmentControllerOrigin = .init(x: 0, y: newOrigin.y)
}

} else {
let newOrigin = self.segmentControllerOrigin - rec.translation
self.segmentControllerOrigin = .init(x: 0, y: max(newOrigin.y, 0))
}
...
}

同步外层的 contentOffset 和内层的 contentOffset 以及 segmentControllerOrigin 是比较恶心的一处。反正我就是搞不对,就写了很多 calibrateContentOffset()

Demo 在这里