NonScrollView,一种嵌套 UIScrollView 的方法
公司的项目有一个页面需要在 Segment Control 上面加一个 header,
原本的是现实监听 segment controller 下每一个列表的 contentOffset
以调整 header 的位置,尽管能用,但毕竟它的最外层不是一个 UIScrollView
了,接手之后突然让我给整体加个加拉刷新,着实是让我很烦心。当时时间比较赶,我就直接加了个 UIPanGestureRecognizer
,效果还是很糟糕的。最近比较闲,于是想着重构一下这个部分,我当然希望最外层是一个 UIScrollView
,但这就涉及到了两个嵌套 scroll view 的问题了:
- 什么时候拖动哪一个 scroll view;
- 当一个 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
看作是进度,从而计算出当前进度下各个部分的 frame
呢?
当然我们才不要用 UICollectionView
,没有理由又做一个 cell 把 view controller 填到里面去。
Content Offset 看作进度
我们首先看一个例子:我们希望把两个 UITableView
在竖直方向上串联起来,它们在滚动时,看起来就像是一个 UITableView
一样。
能够想象出来的一种布局方案就是:
- 最外层是一个 scroll view,姑且叫做 M;
- scroll view 中竖直放置两个连续的 table view,A 和 B,两个 table view 的宽高皆与最外层的 M 相等;
- 当 A 的 content size 足够长时,我们让 M 下滚时,首先让 A 下滚;
- A 下滚到尽头之后,变为同时上移 A 和 B;
- 当 B 上移到 M 顶部之后,变为让 B 下滚。
幸运的是 A、B 的 frame
和 contentOffset
都能映射到 M 的 contentOffset
上。也就是说我们只需要给 scroll view 增加一个功能,让它在 layoutSubviews
的过程中根据 contentOffset
设置 A 和 B 的 frame
和 contentOffset
就行了。
在这个例子里 A 的 contentOffset
实际上就是 M 的 contentOffset
;而 B 的 contentOffset
只需将 M 的 contentOffset
减去 A 的 contentSize.height
即可得到。实际实现中会需要考虑到一些别的情况,这里便不再累赘了。
我们姑且称这个新的 scroll view 为
NonScrollView
。NonScrollView
会在 layoutSubviews
末尾执行上述的逻辑。我们希望 scroll view 的滚动完全由 NonScrollView
接管,因此需要先将 scroll view 设为不可滚动。
1 | private func layoutMappedViews() { |
我们需要为 NonScrollView
提供一些 ViewPlacer
,它们包含了需要更新的 view 以及两个 block 用来生成对应状态下的 frame
和更新 view 的状态。
1 | class ViewPlacer { |
NonScrollView
的 contentSize
也需要我们提供一个逻辑来生成,在这个例子当中,它应该就是 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 | class NonScrollViewScrollRecognizer { |
不如来试一试吧。
我们依旧创建一个 NonScrollView
,但在 generateFrame
中我们返回的东西就简单多了,毕竟这次我们不再需要将 contentOffset
映射成 frame
,而是根据 contentOffset
的变化不断改变 frame
以及当前显示的 scroll view 的 contentOffset
。
我们可以用一个属性 segmentControllerOrigin
用来记录 segment controller 在屏幕上的位置,并以此计算出 headerVC
的位置。
1 | viewPlacers: [ |
接下来就跟我们使用 UIPanGestureRecognizer
的时候差不多了。我们可以通过 onChange
属性监听 NonScrollViewScrollRecognizer
的变化。比方说列表在往上滚,且 segment controller 还未至顶的时候,我们就可以将竖直方向的位移加给 segmentControllerOrigin
,而在至顶之后加给当前页面的 contentOffset
。
1 | switch (self.currentScrollView, hitTop, scrollDirection) { |
同步外层的 contentOffset
和内层的 contentOffset
以及 segmentControllerOrigin
是比较恶心的一处。反正我就是搞不对,就写了很多 calibrateContentOffset()
。