原创文章首发本人博客: blog.cocosdever.com/2019/09/03/…算法
最近有个需求, 要实现一个相似excel那样的表格展现视图, 视图又要支持上下拉刷新功能同时还要支持整屏滚动功能, 其实说白了, 就是须要用到界面联合滚动和解决手势冲突问题. 本文就是想结合最近作的UI总结出这个能够应用到其余更复杂界面上的套路出来.app
关于手势冲突这里不是说UIGestureRecognizer
的使用和他的代理UIGestureRecognizerDelegate
提供的手势冲突解决方法, 而是说一些其余的, 下面再说.ide
就从我就近作的需求开始讲起, 先简单看一下界面的实际效果GIF.布局
再复杂的界面, 无非都是由一些简单的部件组成, 再结合手势, 位置同步等手段让它们协调工做起来, 以致于看起来就是一个总体. 这些简单的界面大概就是有UIView, UIScrollView, UITableView, UICollectionView, 组合的时候就是多个UITableView互相嵌套, 或者是UIScrollView嵌套UITableView, 又或者是一个ScrollView放一侧, 另外一个TableView放一侧等方式. 本例的组件命名为FMMachineListView
性能
上面的界面能够作以下分解:测试
先介绍一下这几个基本视图对应的功能:动画
蓝色和绿色两个视图能够用约束布局或者代码布局, 确保他们按照必定比例分配便可. 若是把整个表格功能封装成一个组件, 绿色视图其实就是这个组件的根视图.固然若是须要Interface Builder
直接摆放视图的话, 能够先放一个普通的View, View里再放组件根视图便可, 灵活变通.ui
红色视图自己不必定要设置为ScrollView, 可是为了能兼容MJRefresh
上下拉刷新组件, 我使用了更为复杂的ScrollView来作, 这样就能够方便地往它身上加入上下拉视图了. 红色视图的contentSize
应该和绿色视图同样, 这样能够固定住白色视图.编码
灰色视图是一个ScrolView, 主要目的是为了让表格除第一列以外其余列能够左右滚动, 这样才能添加更多的列进来. 灰色视图里面的小矩形视图, 我这里为了复杂起见, 每一列都是一个TableView, 不过若是改为只有一个TableView, 而后在每个Cell里去控制每一行的数据也是能够的. 对了别忘了, 灰色视图的contentSize
高度应该等于红色视图, 由于他不须要上下滚动, 不过contentSize
的宽度就要看具体有多少列, 这样才能实现左右滚动.idea
为了让整个组件看起来就是一个总体, 好比每一列自己都是一个TableView, 那用户滚动的时候确定只是滚动了其中一列, 这样就须要作联动, 把滚动的信息传递给其余TableView, 这样看起来才不会像下面GIF这样.
因此说, 组合的界面, 联动这个思路是比较常见的. 具体联动怎么实现, 放到下面说.
本例中, 主要的手势冲突有如下几个: 列表视图和红色视图有冲突. 列表视图须要支持上下滚动展现更多行, 而红色视图须要支持上下滚动来实现数据刷新功能, 咱们知道iOS中多个相同类型的手势, 好比pan手势, 默认只会响应其中一个, 因此列表视图的pan手势响应了以后红色视图的pan手势不会触发了. 注意这里pan手势都是scrollView自带的, 不像开发者本身添加的手势能够经过UIGestureRecognizerDelegate
解决多手势响应问题.
列表视图和蓝色视图的冲突, 蓝色视图也须要向上滚动到屏外, 因此也须要一个比较好的解决方案.
上面两个问题的解决思路, 我以为能够这样来作:
首先让多个嵌套的scrollView(红色, 灰白色: 灰色和白色的统称)只有一个支持上下滚动, 这里就有两种选择, 一种是让灰白色都不支持上下滚动, 让红色支持上下滚动, 这样滚动时响应的是红色视图的pan手势.
不须要红色支持上下滚动, 可是让灰色白色支持上下滚动, 而后在灰白色滚动的同时, 根据必定算法判断是否中止滚动灰白色, 用算法模拟滚动红色(实际上用的仍是灰白色的pan手势), 下面会有代码具体演示一下.
蓝色视图这部分的冲突, 本例的需求其实能够像上面设计图那样让蓝色视图直接放到控制器的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
复制代码
其中machineListScrollUp
和machineListScrollDown
两个方法会在组件从顶部上拉或者顶部下拉时被调用. 组件内部具体实现的代码就是下面这段:
// 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];
}
}
}
复制代码
控制器只要实现了machineListScrollUp
和machineListScrollDown
这两个方法, 就能够控制蓝色视图滚动的时机了, 最终效果就是本文开头GIF演示那样子.
至此就把本文开头的例子的实现讲完了. 本文实现一个没法直接用系统自带视图实现的界面, 经过组合多个列表视图和滚动视图, 讲述了这类需求的主要问题, 也就是联合滚动, 手势冲突这些, 并给出了解决方案, 目的就是但愿能把复杂界面的实现思路理清楚, 无论遇到什么样的界面, 百变不离其宗, 只要稍加灵活变通便可实现, 性能也会太差 : )