基本场景
(最终效果和链接在文末,支持Swift与OC)
UIScrollView
嵌套多个UITableView
的场景在APP里很常见,复杂点还有各种UITableView、UICollectionView
各种嵌套的场景,目前通用的解决办法基本是在UIScrollView
的代理方法
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
里比较偏移量和需要悬停的坐标位置再做相应处理,定义主要父视图scrollView
为mainScrollView
,嵌套的多个联动scrollView
为contentScrollView
,先总结下大致思路。
- 手势响应,
shouldRecognizeSimultaneouslyWithGestureRecognizer
必须同时作用于mainScrollView
和所有contentScrollView
,而contentScrollView
是需要横向滑动的,因此要允许同时垂直滑动,而不支持水平和垂直同时滑动。 -
mainScrollView 、contentScrollView
均需要实现scrollViewDidScroll
并分别处理,两者的实际滑动是互斥的,同一时刻只有一方需要响应滑动,另一方做悬停处理,相互通知也很是麻烦。 - 下拉刷新,
mainScrollView 、contentScrollView
各自有着需要下拉刷新的场景,一般contentScrollView
需要下拉刷新时也正好处于自身临界固定点的位置,这里也需要单独处理下。 -
scrollsToTop
这其实是一个很容易被忽略的点,iOS系统有个小的隐藏功能,点击系统状态栏会查找到当前显示的UIScrollView
并响应回到顶部,而在这种嵌套的场景里,主次需要响应的时机就依赖于需求了,或许需求就要求先回到contentScrollView
后回到mainScrollView
的顶部呢🤣。
要把这些都处理好,写代码的时候必须梳理清楚,即便如此,当项目不同模块都有着类似的需求的时候,又得好好捋一遍了,可能相似而不相同,一不小心就容易一团麻,令人抓狂。
之前在网上搜索这类需求的方案,大部分都是上述的大概思路,其他有些是对整个相关UI层的封装,一来学习使用成本略高,二则在已经成型的项目里使用的话,改动略大,耦合性比较高。于是打算自己重新整理一个低耦合的方案出来。
结果方案
依然是比较偏移量处理悬停,为了减少耦合,因此不走代理,采用KVO
的方式监测偏移量,初始化需要设置mainScrollView
和各contentScrollView
,考虑到不少子页面可能存在懒加载的情况,因此contentScrollView
可以不必在初始化时全给到,可延后等待时机添加。index
参数用于标记contentScrollView
在其横向父scrollView
的位置,避免受到其他兄弟视图的滑动影响。
+ (instancetype)managerWithMainScrollView:(UIScrollView *)mainScrollView contentScrollViews:(NSArray<UIScrollView *> *_Nullable)contentScrollViews;
- (void)addContentScrollView:(UIScrollView *)contentScrollView withIndex:(NSInteger)index;
初始化完了,接下来就是本方案中唯一的必设属性了:
@property (nonatomic) CGFloat contentScrollDistance;
mainScrollView
悬停相关的值,contentScrollView
可以在mainScrollView
移动的距离,一般是需要显示的内容区域在mainScrollView
的相对坐标Y值,如图所示,箭头是终点,图中上面高为300,只要设置contentScrollDistance
为300,就可以基本实现完整的嵌套联动了。当页面刷新高度变化的时候,只需要重新调整contentScrollDistance
的值即可。
必设属性之后就是扩展需求的可选属性了。
///各contentScrollView的共同横向superScrollView
///内部是寻找第一个contentScrollView的父视图里的第一个UIScrollView
///与实际不符时可 以此修正
///主要用于scrollsToTop及散装属性
@property (nonatomic, weak) UIScrollView *fixHorizontalSuperScrollView;
///滑动条显示 默认切换显示
@property (nonatomic) XShowIndicatorType showIndicatorType;
///默认main可下拉
@property (nonatomic) XMixScrollPullType mixScrollPullType;
///点击状态栏回顶部时 是否直接回到mainScrollView顶部 默认Yes
@property (nonatomic) BOOL scrollsToMainTop;
///是否开启动态模拟 默认 NO 在main范围内content范围外 上拉没有过度滑动效果 YES则添加模拟效果
@property (nonatomic) BOOL enableDynamicSimulate;
///动态模拟过度滑动效果 阻力参数 默认 2
@property (nonatomic) CGFloat dynamicResistance;
- 如注释所示,该属性的出现主要是为了
scrollsToTop
的切换以及接下来要介绍的散装属性。 -
mainScrollView
和contentScrollView
各有各的滑动条,简单暴力的话就是全隐藏,但是毕竟contentScrollView
可能上拉加载更多无限长,还是需要看情况显示的。 - 下拉刷新,可以自由设置
mainScrollView
和contentScrollView
是否支持下拉刷新。 - 当
scrollsToMainTop
为NO
时,点击状态栏会优先使当前contentScrollView
回到顶部,其次回到mainScrollView
顶部。 - 关于动态模拟,在滑动
contentScrollView
区域外的mainScrollView
时,contentScrollView
不会响应手势,自然也不会滑动,在惯性滑动过渡到contentScrollView
的时候mainScrollView
由于悬停设置会导致瞬停,没法好好平滑过渡,最终参考网上动态模拟的方案针对上滑触摸点在contentScrollView
区域外mainScrollView
区域内的单个场景增加了惯性模拟。因为需要额外的计算且不是必须的,所以默认关闭了。
以上关于contentScrollView
的设置都是针对所有内容视图的,考虑到不同contentScrollView
可能有着不同需求,比如有的子页面内容较少不需要显示滑动进度条,不需要回到子页面顶部,有的子页面内容可以无限上拉加载更多,需要进度条也需要回到子页面顶部之类的。因此增加了部分可选属性单独设置的方法。
///开启散装属性 默认NO
@property (nonatomic) BOOL enableCustomConfig;
- (void)setShowIndicatorType:(XShowIndicatorType)showIndicatorType forScrollView:(UIScrollView *)contentScrollView;
- (void)setMixScrollPullType:(XMixScrollPullType)mixScrollPullType forScrollView:(UIScrollView *)contentScrollView;
- (void)setScrollsToMainTop:(BOOL)scrollsToMainTop forScrollView:(UIScrollView *)contentScrollView;
- (void)setEnableDynamicSimulate:(BOOL)enableDynamicSimulate forScrollView:(UIScrollView *)contentScrollView;
没有单独设置属性的contentScrollView
依然以主要设置为准。
大致实现
KVO
那里判断代码比较长,大致说一下,KVO
里在
对mainScrollView 、contentScrollView
的常规嵌套联动处理的基础上,加上了回到顶部、是否显示下拉状态的处理、以及惯性模拟的判断调用,此外对内容视图横向父scrollView
的偏移量也添加了观察(如下),内容视图切换时需要校准scrollsToTop
状态以及对散装进度条的显示状况进行修正。.p
的写法只是为了少写几个associatedObject
。
//横向父scrollView滑动处理
NSInteger index = scrollView.contentOffset.x / scrollView.frame.size.width;
if (scrollView.p.index != index) {
scrollView.p.index = index;
self.currentIndex = index;
[self checkScrollsToTop];
[self checkCustomConfig];
}
联动的滑动过渡如下
- (void)changeMainScrollStatus:(BOOL)mainCanScroll
{
if (self.mainScrollView.p.canScroll == mainCanScroll) {
return;
}
self.mainScrollView.scrollsToTop = YES;
self.mainScrollView.p.canScroll = mainCanScroll;
for (UIScrollView *contentScrollView in self.contentScrollViews) {
contentScrollView.p.canScroll = !mainCanScroll;
if (mainCanScroll) {
contentScrollView.contentOffset = CGPointZero;
}
if (!self.scrollsToMainTop) {
contentScrollView.scrollsToTop = !mainCanScroll;
}
}
}
这里是到临界点过渡时的处理,canScroll = YES
代表着主动滑动,反之则是悬停,被动跟滑,当mainScrollView
可以滑动的时候重置下contentScrollView
的偏移量。mainScrollView.scrollsToTop = YES
则是因为在正好临界点时如果为NO
则无法回到顶部,mainScrollView
的实际scrollsToTop
值会在KVO contentScrollView
的偏移量大于0时重新赋值。
关于散装属性的处理比较简单,用字典存值,重写了属性的get
方法。
最后是关于UIScrollView
分类实现的这两个方法
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
if (self.p.markScroll) {
//阻止横竖联动
UIScrollView *scrollView = (UIScrollView *)otherGestureRecognizer.view;
if ([scrollView isKindOfClass:[UIScrollView class]] && scrollView.p.markScroll) {
return YES;
}
}
//阻止其他意外联动
return NO;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
if (self.p.scrollManager.enableDynamicSimulate) {
[self.property.scrollManager.dynamicSimulate stop];
if (self.p.isMain) {
XMixScrollManager *scrollManager = self.p.scrollManager;
scrollManager.isTouchMain = point.y < scrollManager.contentScrollDistance;
}
}
return [super pointInside:point withEvent:event];
}
pointInside
的处理,一是记录是否在需要模拟的坐标区间内滑动,二是停止之前的模拟。动态模拟本身就不多说了,想要了解的可以看文末的链接。
部分效果
链接
动态模拟部分参考->https://www.tuicool.com/articles/QVJnAbB
完整代码地址->XMixScrollManager
Swift版代码地址->XMixScrollManager_swift