实现iOS复杂联动界面, 手势冲突界面的一些思路

3,526 阅读12分钟

原创文章首发本人博客: blog.cocosdever.com/2019/09/03/…

文档更新说明

  • 最后更新 2019年09月05日
  • 首次更新 2019年09月03日

前言

  最近有个需求, 要实现一个类似excel那样的表格展示视图, 视图又要支持上下拉刷新功能同时还要支持整屏滚动功能, 其实说白了, 就是需要用到界面联合滚动和解决手势冲突问题. 本文就是想结合最近做的UI总结出这个可以应用到其他更复杂界面上的套路出来.

关于手势冲突这里不是说UIGestureRecognizer的使用和他的代理UIGestureRecognizerDelegate提供的手势冲突解决方法, 而是说一些其他的, 下面再说.

例子

  就从我就近做的需求开始讲起, 先简单看一下界面的实际效果GIF.

再复杂的界面, 无非都是由一些简单的部件组成, 再结合手势, 位置同步等手段让它们协调工作起来, 以至于看起来就是一个整体. 这些简单的界面大概就是有UIView, UIScrollView, UITableView, UICollectionView, 组合的时候就是多个UITableView互相嵌套, 或者是UIScrollView嵌套UITableView, 又或者是一个ScrollView放一侧, 另一个TableView放一侧等方式. 本例的组件命名为FMMachineListView

思路

  上面的界面可以做如下分解:

先介绍一下这几个基本视图对应的功能:

蓝色绿色两个视图可以用约束布局或者代码布局, 确保他们按照一定比例分配即可. 如果把整个表格功能封装成一个组件, 绿色视图其实就是这个组件的根视图.当然如果需要Interface Builder直接摆放视图的话, 可以先放一个普通的View, View里再放组件根视图即可, 灵活变通.

红色视图本身不一定要设置为ScrollView, 但是为了能兼容MJRefresh上下拉刷新组件, 我使用了更为复杂的ScrollView来做, 这样就可以方便地往它身上加入上下拉视图了. 红色视图的contentSize应该和绿色视图一样, 这样可以固定住白色视图.

灰色视图是一个ScrolView, 主要目的是为了让表格除第一列之外其他列可以左右滚动, 这样才能添加更多的列进来. 灰色视图里面的小矩形视图, 我这里为了复杂起见, 每一列都是一个TableView, 不过如果改成只有一个TableView, 然后在每一个Cell里去控制每一行的数据也是可以的. 对了别忘了, 灰色视图的contentSize高度应该等于红色视图, 因为他不需要上下滚动, 不过contentSize的宽度就要看具体有多少列, 这样才能实现左右滚动.

实现视图联动

  为了让整个组件看起来就是一个整体, 比如每一列本身都是一个TableView, 那用户滚动的时候肯定只是滚动了其中一列, 这样就需要做联动, 把滚动的信息传递给其他TableView, 这样看起来才不会像下面GIF这样.   

所以说, 组合的界面, 联动这个思路是比较常见的. 具体联动怎么实现, 放到下面说.

解决手势冲突

  本例中, 主要的手势冲突有以下几个:    列表视图和红色视图有冲突. 列表视图需要支持上下滚动展示更多行, 而红色视图需要支持上下滚动来实现数据刷新功能, 我们知道iOS中多个相同类型的手势, 比如pan手势, 默认只会响应其中一个, 所以列表视图的pan手势响应了之后红色视图的pan手势不会触发了. 注意这里pan手势都是scrollView自带的, 不像开发者自己添加的手势可以通过UIGestureRecognizerDelegate解决多手势响应问题.

列表视图和蓝色视图的冲突, 蓝色视图也需要向上滚动到屏外, 所以也需要一个比较好的解决方案.

上面两个问题的解决思路, 我觉得可以这样来做:

  1. 首先让多个嵌套的scrollView(红色, 灰白色: 灰色和白色的统称)只有一个支持上下滚动, 这里就有两种选择, 一种是让灰白色都不支持上下滚动, 让红色支持上下滚动, 这样滚动时响应的是红色视图的pan手势.

  2. 不需要红色支持上下滚动, 但是让灰色白色支持上下滚动, 然后在灰白色滚动的同时, 根据一定算法判断是否停止滚动灰白色, 用算法模拟滚动红色(实际上用的还是灰白色的pan手势), 下面会有代码具体演示一下.

  3. 蓝色视图这部分的冲突, 本例的需求其实可以像上面设计图那样让蓝色视图直接放到控制器的view上, 接着观察列表组件的滚动情况, 列表上拉到顶部了则蓝色视图用动画滚到屏外, 列表组件从顶部下拉的时候, 蓝色视图滚回原位. 如果有需要, 还可以让蓝色部分也支持响应滚动手势的话, 可以直接把白色视图的pan手势添加到控制器的view上, 这样整个控制器都能响应pan手势, 而且白色视图又能正确滚动, 又能让蓝色视图观察到滚动情况从而也就可以在蓝色视图上响应滚动事件了. (如果是方案1那就是操作红色视图的pan手势), 他的效果就像下面GIF演示的:   

各种方案的优缺点

  上面提到的解决手势冲突的方案, 第三种严格说也算不上, 这里主要就说1和2的优缺点.

方案1

优点是实现比较简单, ScrollView里面嵌套TableView(或者其他ScrollView子类视图), 让TableView把全部内容都显示出来, 只需要简单计算一下TableView有多少row,多少section以及具体的高度汇总就是整个TableView的内容高度了,这样TableView的frame.size等于TableView的contentSize, 本质上就退化成一个普通的UIView, 滚动的是外部的ScrollView, 所以可以解决手势冲突.

缺点也比较明显, TableView失去原有的复用机制, 每一次都把全部的Cell都加载出来放到内存里, 把全部Cell的视图都渲染出来, 占用内存变大, 因此此方案只适合行数较少的场景, 我测试了一下大概1000行之后就会有很明显的卡顿了.

方案2 优点是性能正常, ScrollView里面嵌套TableView, 内部的TableView本身还是支持滚动的, 这也就支持了视图的复用机制, 占用内存少, 支持任意数据量.

缺点是编码比方案1复杂, 因为需要在内部的TableView滚动到合适的位置的时(比如顶部或底部), 通过算法让TableView的contentOffset固定住, 然后根据滚动的偏移量直接去设置外层ScrollView的contentOffset, 这样就可以实现看上去内部的TableView停止滚动了外部的ScrollView开始滚动的样子, 这样也可以解决手势冲突. 这部分下文会有代码演示, 看不明白的可以继续往下看.

具体实现

界面联动

  界面联动的实现方式有多种, 本文主要是讲思路, 所以这里讲最简单的实现, 能看懂就好, 后面可能会再发一篇文章专门论述如何优雅实现界面联合滚动.    首先是白色,灰色视图这样的列需要同步滚动, 那么就在每一个列对应的TableView子类里定义一个协议, 这里就叫FMSyncDelegate, 协议内容如下:

// FMDataTableView.h

@protocol FMSyncDelegate <NSObject>

- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet;

- (void)dataTableView:(UITableView *)tableView didSelected:(NSIndexPath *)indexPath;

- (void)dataTableView:(UITableView *)tableView didDeSelected:(NSIndexPath *)indexPath;

- (void)dataTableViewDidEndDragging:(UITableView *)tableView;

- (void)dataTableViewBeganDragging:(UITableView *)tableView;

@end

具体功能就不用介绍了看名字已经很清晰了, 目的就是把tableView内部具体发生事情回调给实现了FMSyncDelegate协议的对象. 同步滚动的时候, 只需要把组件设置为列表视图的代理, 然后实现即可. 本例中整个协议的方法都要实现, 因为要支持点击某一行跳进详情页, 也要知道手指什么时候触摸列表什么时候离开列表好实现上下拉功能. 协议还需要支持其他什么功能这个具体看情况而定即可.

下面再说一下同步滚动这个功能的简单实现. 在组件对应的类里(绿色视图)实现列表的同步代理, 监听到某一个列滚动时, 把信号也发给其他列, 让其他列跟着滚动即可.

// FMMachineListView.h

- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet {
    static BOOL stopSync = NO;
    if (stopSync == YES) {
        return;
    }
    stopSync = YES;
    
    FMDataTableView *headTV = (FMDataTableView *)_headView.subviews[0];    
    [headTV setTableViewContentOffSet:contentOffSet];
    
    for (UIView *subView in _scroll.subviews) {
        if ([subView isKindOfClass:[FMDataTableView class]]) {
            [(FMDataTableView *)subView setContentOffSet:contentOffSet];
        }
        if (subView == _scroll.subviews.lastObject) {
            // 本轮数据同步最后一个对象结束之后, 才允许下一轮同步, 这样可以避免重复的同步操作
            stopSync = NO;
        }
    }
}

其中stopSync是为了防止重复同步, 毕竟这里对每一个tableView都调用了setContentOffSet:, 这样就又会导致- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet方法被触发一次, 所以每一轮同步只有第一个事件能起作用, 其他同一轮的事件都直接忽略, 直到最后一个列表被同步之后才允许新一轮同步.   

解决滚动冲突

  这里只讲方案2, 红色视图不需要响应上下滚动手势, 灰白色需要. 下面给出滚动灰白色视图, 固定灰白色视图的位置滚动红色视图, 以及如何上下滑动蓝色视图的代码.

// FMMachineListView.h

- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet {
    static BOOL stopSync = NO;
    if (stopSync == YES) {
        return;
    }
    stopSync = YES;
    
    FMDataTableView *headTV = (FMDataTableView *)_headView.subviews[0];
    
    // 注意如果contentSize还没有tableview本身大的话, 说明数据太少了tableView都不需要滚动, 也就不需要加载下一页了, 也不需要通知上下滚的事件.
    BOOL isLongPage = NO;
    if (headTV.contentSize.height > headTV.frame.size.height) {
        isLongPage = YES;
    }
    
     // 注意如果contentSize还没有tableview本身大的话, 说明数据太少了tableView都不需要滚动, 也就不需要加载下一页了, 也不需要通知上下滚的事件.
    BOOL isLongPage = NO;
    if (headTV.contentSize.height > headTV.frame.size.height) {
        isLongPage = YES;
    }
        
    // 可使用tableView.isDragging控制是否只在拉动状态触发
    if (isLongPage) {
        if (contentOffSet.y <= 0) {
            NSLog(@"向下滚动了");
            // 列表向下滚动(展示上面内容), 通知代理
            if ([self.delegate respondsToSelector:@selector(machineListScrollDown)]) {
                [self.delegate machineListScrollDown];
            }
        } else if (contentOffSet.y > 0) {
            NSLog(@"向上滚动了");
            // 列表向上滚动(展示下面内容)
            if ([self.delegate respondsToSelector:@selector(machineListScrollUp)]) {
                [self.delegate machineListScrollUp];
            }
        }
    }
    
    if (contentOffSet.y <= 0 && ((FMDataTableRefreshNormalHeader *)self.panelScrollView.mj_header).dragging == YES) {
        // 当列表滚动到顶部时, 让contentOffset.y保持在0位置, 同时调整panelScrollView的contentOffset, 让下拉刷新控件露出来
        // 同时要处理拖动列表之后松手的事件, 让contentOffset复原!
        self.panelScrollView.contentOffset = CGPointMake(0, self.panelScrollView.contentOffset.y + (contentOffSet.y / 2));
        
        contentOffSet.y = 0;
    }
    
    
    // 处理滑动到底部直接加载下一页数据
    // 滚动到底部时, y的座标刚好是contentSize高度减去视图本身高度
    // 不过要注意如果contentSize还没有tableview本身大的话, 说明数据太少了tableView都不需要滚动, 也就不需要加载下一页了
    if (isLongPage) {
        CGFloat happendY = headTV.contentSize.height - headTV.frame.size.height;
        if (contentOffSet.y >= happendY) {
            self.panelScrollView.contentOffset = CGPointMake(0, self.panelScrollView.contentOffset.y + ((contentOffSet.y - happendY) / 2));
            contentOffSet.y = happendY;
        }
    }
    
    [headTV setTableViewContentOffSet:contentOffSet];
    for (UIView *subView in _scroll.subviews){
        if ([subView isKindOfClass:[FMDataTableView class]]) {
            [(FMDataTableView *)subView setTableViewContentOffSet:contentOffSet];
        }
        if (subView == _scroll.subviews.lastObject) {
            // 本轮数据同步最后一个对象结束之后, 才允许下一轮同步, 这样可以避免重复的同步操作
            stopSync = NO;
        }
    }
}

- (void)dataTableViewDidEndDragging:(UITableView *)tableView {
    ((FMDataTableRefreshNormalHeader *)self.panelScrollView.mj_header).dragging = NO;
    if (self.panelScrollView.contentOffset.y != 0) {
        [self.panelScrollView setContentOffset:CGPointMake(0, 0) animated:YES];
    }
}

- (void)dataTableViewBeganDragging:(UITableView *)tableView {
    ((FMDataTableRefreshNormalHeader *)self.panelScrollView.mj_header).dragging = YES;
}


具体代码作用看注释, 其中panelScrollView就是本例的红色视图.

最后要说的就是如何让蓝色视图也能滚动, 实现的原理就是让控制器实现组件的协议, 协议内容如下:

// FMMachineListView.h

@protocol FMMachineListViewDelegate <NSObject>
@optional
- (void)machineListViewDidSelected:(NSIndexPath *)indexPath;

- (void)machineListViewDidLongPress:(NSIndexPath *)indexPath;

- (void)machineListScrollUp;

- (void)machineListScrollDown;
@end

其中machineListScrollUpmachineListScrollDown两个方法会在组件从顶部上拉或者顶部下拉时被调用. 组件内部具体实现的代码就是下面这段:

// FMMachineListView.h

    // 注意如果contentSize还没有tableview本身大的话, 说明数据太少了tableView都不需要滚动, 也就不需要加载下一页了, 也不需要通知上下滚的事件.
    BOOL isLongPage = NO;
    if (headTV.contentSize.height > headTV.frame.size.height) {
        isLongPage = YES;
    }
        
    // 可使用tableView.isDragging控制是否只在拉动状态触发
    if (isLongPage) {
        if (contentOffSet.y <= 0) {
            NSLog(@"向下滚动了");
            // 列表向下滚动(展示上面内容), 通知代理
            if ([self.delegate respondsToSelector:@selector(machineListScrollDown)]) {
                [self.delegate machineListScrollDown];
            }
        } else if (contentOffSet.y > 0) {
            NSLog(@"向上滚动了");
            // 列表向上滚动(展示下面内容)
            if ([self.delegate respondsToSelector:@selector(machineListScrollUp)]) {
                [self.delegate machineListScrollUp];
            }
        }
    }

控制器只要实现了machineListScrollUpmachineListScrollDown这两个方法, 就可以控制蓝色视图滚动的时机了, 最终效果就是本文开头GIF演示那样子.

总结

  至此就把本文开头的例子的实现讲完了. 本文实现一个无法直接用系统自带视图实现的界面, 通过组合多个列表视图和滚动视图, 讲述了这类需求的主要问题, 也就是联合滚动, 手势冲突这些, 并给出了解决方案, 目的就是希望能把复杂界面的实现思路理清楚, 不管遇到什么样的界面, 百变不离其宗, 只要稍加灵活变通即可实现, 性能也会太差 : )