本文旨在对于SegementSlide库实现原理的讲解,有兴趣的同学,欢迎前往Github地址浏览。
背景
如今的app中,越来越多地采用如下图所示的设计,一般用在诸如『用户主页』、『话题详情页』、『专题详情页』等这些场景。通常,这些场景会带有头部视图(头部视图可能要求支持滚动渐变),下面紧接着的是分页控件,最下面是滚动列表。
如下图所示:
各种方案以及优缺点
为了方便下面的说明,在开始之前,先约定几个说法,下面的各种方案,大都离不开在最底层放上一个UIScrollView
(竖直方向滚动),我们称之为rootScrollView
。无论分页控件下方有多少个子界面,总有一个当前界面,我们称当前界面下的UIScrollView
(竖直方向滚动)为childScrollView
。
I 控制isScrollEnabled
属性
这是我们第一时间能想到的方案,通过给rootScrollView
和childScrollView
实现UIScrollViewDelegate
,并在func scrollViewDidScroll(_ scrollView: UIScrollView)
方法中实时将scrollView.contentOffset.y
与临界值进行对比从而修改两者scrollView
的isScrollEnabled
属性值来达到目的。
大致代码如下
1 | func scrollViewDidScroll(_ scrollView: UIScrollView) { |
方法简单,但是有个不太能接受的交互问题,但凡将isScrollEnabled
设置为false
,这次的滑动手势就会被打断,从表现上来看,就是滑动到临界值时滑动会被中断。
II 自定义滑动手势
在这篇文章这篇文章中,作者提供了一种利用自定义手势的方式来实现。
但是,只是添加普通的滑动手势是不够的,UIScrollView
是自带阻尼效果的,因此引入了UIDynamicAnimator
来实现阻尼效果。
这是一种不错的思路。不过完全自定义手势来实现UIScrollView
的效果,需要考虑的细节过多,挺难处理得跟系统的效果一致(写这篇文章的时候,下载了作者提供的源码,commitID
为ff7b76f8468bc87fea8ea6975d8b9fe1173ab031
,在真机iPhone X
上运行,感觉还是有交互上的问题)。此外,因为是自定义手势,手势不是直接作用在UIScrollView
上的,UIScrollView
的ScrollIndicator
是无法显示的,通过改变UIScrollView
的contentOffset
,其ScrollIndicator
也是无法显示的,必须要手势作用在UIScrollView
上才行。使用UIScrollView
的flashScrollIndicators()
来强迫ScrollIndicator
显示出来?…可能还真行,不过我没试过,感觉太粗暴了。
III 手势穿透
这应该是目前相对主流的一种实现方式,比如在这篇文章中,便是介绍了这种方式。据我观察Twitter和微博的用户主页可能是使用这种方式实现的(写这篇文章的时候,Twitter版本为:7.41.2,微博版本为:9.2.0,推测错了的话还望见谅)
该方案的核心为有两点:
- 让滑动手势穿透使得
rootScrollView
和childScrollView
都能接收到滑动手势(因为手势是作用到UIScrollview
上的,自然是能显示ScrollIndicator
的)。做法是让rootScrollView
实现UIGestureRecognizerDelegate
的代理方法func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
,并在适当的时机返回true
。
这部分的代码大致如下:
1 | class SegementSlideScrollView: UIScrollView, UIGestureRecognizerDelegate { |
当然只是如此的话,是不够的,这样的结果是滑动的时候,导致rootScrollView
和childScrollView
一起滚动。
- 增加两个标志位来控制何时允许
rootScrollView
滚动,以及何时允许childScrollView
。
这部分代码大致如下:
1 | func scrollViewDidScroll(_ scrollView: UIScrollView) { |
如上代码所示,控制rootScrollView
或者是childScrollView
不可滚动的方式是将两者的contentOffset.y
设置为一个固定值(见注释point A
和point B
),并不是简单地将isScrollEnabled
设置false
而已。
没问题了?不,也是有不足之处的:
**在第一个界面使用手指向上滑动,让头部视图完全被隐藏后再向上滑动一些,让childScrollView
的contentOffset.y
处于大于0
的状态,随后,左右切换到第二个界面,使用手指向下滑动,完全拉出头部视图,然后再切换回第一个界面,这个时候,使用手指在屏幕上稍微滑动一下,rootScrollView
或是childScrollView
的contentOffset.y
会突变,从表现上看,就是发生『位置突变现象』**
问题产生的原因是什么?canParentViewScroll
和childScrollView
始终为一对相反的值,浏览上诉代码,会发现在point A
和point B
处,将rootScrollView
或者是childScrollView
的contentOffset.y
设置为了一个固定值。这样的处理,当始终在同一个界面滑动的时候,不会有问题,但是,在切换界面后,由于rootScrollView
是共用的,在新界面改动了rootScrollView
的contentOffset.y
,切换回原界面后,稍做滑动,定会执行point A
或是point B
其中的一处代码,从而导致『位置突变现象』。
在微博和Twitter中对此问题做了简单的处理。微博上,在切换至新界面之前,将原界面的childScrollView
的contentOffset.y
值重置为了0
。Twitter上,则是在合适的时机做了重置。这也是推测两者可能是使用了该方案的原因。
如下图所示:
SegementSlide的需求
SegementSlide是使用 方案III 来实现的。
此外我希望它还能支持一些别的特性:
- 简单易用的接口
- 一般使用 方案III 实现的例子,大都只是支持在
rootScrollView
上实现阻尼效果,我希望也能在childScrollView
上实现,可以选择任意一个阻尼来使用。(有阻尼,就可以配套下拉刷新工具来使用了) - 一般使用 方案III 实现的例子,大都是需要手指在子视图部分滑动才能实现联动,希望也能在头部滑动实现联动
- 既可以支持使用头部视图,也可以不需要头部视图
- 头部视图可以使用简单的接口实现滚动渐变效果(
navigation
上随着滚动改变背景色、标题、leftItem颜色、rightItem颜色,或是背景色透明之类的),也可以自定义渐变效果 - 子控件既可结合一起使用,也可以单独使用
- 分页标题旁可以显示红点
…
对此,大都已经实现:
- 看下如下示例代码,是否还算简单易用:
1 | import SegementSlide |
1 | import SegementSlide |
- 已经能否支持“父阻尼”和“子阻尼”效果了
重写SegementSlideViewController
的属性bouncesType
,它是一个枚举类型:
1 | enum BouncesType { |
默认值为.parent
,如下重写,即可实现『子阻尼』效果:
1 | class HomeViewController: SegementSlideViewController { |
如何使得在头部滑动也能实现滚动联动效果?
我在SegementSlideHeaderView
中重写了方法func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
,在合适的情况下返回了childScrollView
。目前这不是一个最优的方法,因为我没能够在这个方法中判断出这个事件是滑动还是点击事件,这里还可以优化。既可以支持使用头部视图,也可以不需要头部视图
SegementSlideViewController
是实现这套方案的基类,其中有一个headerView
属性,该属性为可选值,返回nil
则表示不需要头部视图。我在项目配套的Example
工程中,其中的首页便是没有头部视图的示例,不过增加了下拉显示navigation
、上滑隐藏navigation
的效果。一般使用 方案III 的例子,在rootScrollView
上使用了UITableView
,为了使用UITableView
的tableHeaderView
属性,以及吸顶效果。SegementSlide
在v1
版本的时候,使用了UICollectionView
,也是处于同样的目的,现v2
已经改成了UIScrollView
,吸顶效果的话,可以通过增加一条到view.safeAreaLayoutGuide.topAnchor
的约束来实现。快速应用头部渐变效果?
TransparentSlideViewController
是继承于SegementSlideViewController
的子类,其中的headerView
属性已被改成非可选值。其中另外定义了一些属性,用于头部视图处于『显示状态』或是『嵌入状态』时,titleView
和navigationBar
对应属性的改动。
如下所示:
1 | typealias DisplayEmbed<T> = (display: T, embed: T) |
其中DisplayEmbed
为一个typealias
表示『显示状态』或是『嵌入状态』时的值。
需要注意的是:
TransparentSlideViewController
中的titleView
是使用自定义的方式并赋值给navigationItem.titleView
来实现的,最先考虑的是修改navigationBar
的titleTextAttributes
属性,实践下来,发现会出现titleTextAttributes
已经修改完毕,但是效果没有改变的情况。TransparentSlideViewController
会在viewWillAppear
时保存navigation
上对应样式的状态,并在viewWillDisappear
时进行还原,来保证从一个TransparentSlideViewController
(A)进入到另一个TransparentSlideViewController
(B)时,navigation
上样式的状态不会有错误,所以也不该在viewDidLoad
时修改navigation
上的样式,因为B
的viewDidLoad
先于A
的viewWillDisappear
执行。
如果需要自定义渐变效果,可以模仿TransparentSlideViewController
继承SegementSlideViewController
来实现需要的效果。Example
中使用的是原生的UINavigationController
,和TransparentSlideViewController
配合起来,可以做到还算满意的效果。但是,实际情况下每个项目中可能会去改动默认的navigation
,如果TransparentSlideViewController
不适用,则需要使用自定义的方式来支持已有项目。
子控件既可结合一起使用,也可以单独使用
目前SegementSlideSwitcherView
和SegementSlideContentView
既可以作为SegementSlideViewController
的子控件来使用,也可以单独拿出来使用,Example
工程中的NoticeViewController
便是单独使用的例子,实现了将switcher
放在navigation
上的效果。红点显示?
SegementSlideSwitcherView
支持了红点显示
1 | enum BadgeType { |
红点类型为枚举值,从上述代码可以看出红点是支持『普通红点显示』还有『带数字红点显示』。
还需要优化的点
上面在第3点已经提到,『头部滑动也能实现滚动联动效果』目前对此的解决方法不是最优。
方案III 所提到的『位置突变现象』,我在
SegementSlideViewController
中提供了canCacheScrollState
属性,值为true
时,在切换界面的时候会缓存当前的canParentViewScroll
、canChildViewScroll
以及rootScrollView
的contentOffset.y
值,并在切换回该界面的时候恢复;值为false
时,即为类似微博的处理,在切换到新界面前将当前界面的childScrollView
的contentOffset.y
值置为0
。设置为true
时会有一个效果,担心这个效果难以被接受,故将该值的默认值设置为了false
。
效果如下:
但这仍不是一个很好的处理方式。
- 联动滚动切换的时候,还没有达到完美的流畅效果。由于
point A
和point B
处将contentOffset.y
强制设值来阻止滚动,同时也导致了滚动切换时『动能』不足的结果,也就是还不够流畅。
接下去要做的事
自然是要解决上面提到的三点不足的地方,要想让联动完美般流畅,还是需要使用一个滚动,而不是两个。我在本地开了个v3
分支做了个尝试,在视图顶层覆盖一层透明的UIScrollView
,借用它的手势、它的contentOffset
来控制rootScrollView
和childScrollView
的contentOffset
,可以解决上述提到的三个需要优化的点,但是同时也带来了其他好多问题,这里就不细说了,哪天问题都解决了,更新了v3
版本,再来补充说明吧。
参考
结束语
编写本文时,SegementSlide的版本号为2.0-beta-13
。另外,本站还未开通评论功能,如对本文中的内容存在疑问,或者发现文中的不正确之处,欢迎在本文的掘金地址评论区中友善提出。如对本项目有任何疑问,欢迎前往issues提出,同时也欢迎来Pull requests,为本项目做贡献。
欢迎关注我的个人微信订阅号,我将不定期分享开发方面的干货。