iOS-关于UIScrollView的嵌套联动

基本场景

(最终效果和链接在文末,支持Swift与OC)
UIScrollView嵌套多个UITableView的场景在APP里很常见,复杂点还有各种UITableView、UICollectionView各种嵌套的场景,目前通用的解决办法基本是在UIScrollView的代理方法
- (void)scrollViewDidScroll:(UIScrollView *)scrollView里比较偏移量和需要悬停的坐标位置再做相应处理,定义主要父视图scrollViewmainScrollView,嵌套的多个联动scrollViewcontentScrollView,先总结下大致思路。

  1. 手势响应,shouldRecognizeSimultaneouslyWithGestureRecognizer必须同时作用于mainScrollView和所有contentScrollView,而contentScrollView是需要横向滑动的,因此要允许同时垂直滑动,而不支持水平和垂直同时滑动。
  2. mainScrollView 、contentScrollView均需要实现scrollViewDidScroll并分别处理,两者的实际滑动是互斥的,同一时刻只有一方需要响应滑动,另一方做悬停处理,相互通知也很是麻烦。
  3. 下拉刷新,mainScrollView 、contentScrollView各自有着需要下拉刷新的场景,一般contentScrollView需要下拉刷新时也正好处于自身临界固定点的位置,这里也需要单独处理下。
  4. 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;
  1. 如注释所示,该属性的出现主要是为了scrollsToTop的切换以及接下来要介绍的散装属性。
  2. mainScrollViewcontentScrollView各有各的滑动条,简单暴力的话就是全隐藏,但是毕竟contentScrollView可能上拉加载更多无限长,还是需要看情况显示的。
  3. 下拉刷新,可以自由设置mainScrollViewcontentScrollView是否支持下拉刷新。
  4. scrollsToMainTopNO时,点击状态栏会优先使当前contentScrollView回到顶部,其次回到mainScrollView顶部。
  5. 关于动态模拟,在滑动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的处理,一是记录是否在需要模拟的坐标区间内滑动,二是停止之前的模拟。动态模拟本身就不多说了,想要了解的可以看文末的链接。

部分效果

简单UIScrollView嵌套
UITableView嵌套

链接

动态模拟部分参考->https://www.tuicool.com/articles/QVJnAbB
完整代码地址->XMixScrollManager
Swift版代码地址->XMixScrollManager_swift

推荐阅读更多精彩内容

  • 在iOS中,滚动视图UIScrollView用于查看大于屏幕的内容。Scroll View有两个主要目的: 让用户...
    pro648阅读 25,508评论 4 27
  • 前言 在上一篇文章中,我们学习了三方刷新库MJRefresh(巧用MJRefresh),同时我们也说了MJRefr...
    langkee阅读 10,436评论 4 19
  • 一、简介 <<继承关系:UIScrollView --> UIView-->UIResponder-->NSObj...
    无邪8阅读 1,229评论 0 0
  • 1.UIScrollView是什么? 移动设备的屏幕⼤小是极其有限的,因此直接展⽰在用户眼前的内容也相当有限,当展...
    happycolt阅读 7,124评论 1 10
  • 大众对八字风水的态度 信与不信的 有兴趣的没兴趣的 想学习的不想学只想算算自己的 想借此探索自己命运走势的 想学会...
    carrie灵修学院阅读 100评论 0 0