iOS一站式搭建各种滑动列表(Objective-C)

4,209 阅读19分钟

前言

国际惯例,先放链接 HDCollectionView

滑动列表在移动端开发中占据着举足轻重的位置,大多数页面都由滑动列表来构建。新学一门语言我们往往也是从滑动列表开始,iOS之于UITableView/UICollectionView,RN之于FlatList/SectionList,flutter之于ListView/GridView。学会了这些列表的搭建,可以说是在UI上简单入了门。在日常的工作中,我们基本是在创建新的滑动列表,维护已有的滑动列表,无论是新建还是修改,对滑动列表的操作可以说是非常频繁。那么,高效的搭建及更改滑动列表就对工作效率的提高有着至关重要的作用。本文要说的则是如何用原生语言来更加高效的构建滑动列表。

基类选择

在iOS原生平台可供我们选择的滑动列表有UITableView和UICollectionView。相比于UITableView,UICollectionView具有更强的表现力。UITableView主要受限于布局的相对固定,其最小单位只能是一行。直观的看,UICollectionView每一行可以分割为更小单位的cell,布局构成上更加碎片化。当一行展示多个cell时,这带来了一个好处,就是在数据源对应时我们无需再去手动分割组合数据源。然而,更为重要的是UICollectionView将布局类单独提取出来,这为其布局的扩展提供了无限可能。如此设计,不仅可以让广大开发者可以随心定制布局,而且也为后期苹果自身扩展其布局带来了诸多方便。当然UITableView的也是存在其优势的。更加轻量、简单。对于简单的页面使用UITableView可能更为方便。为了能在样式上更加通用,最终使用了UICollectionView来做滑动列表的基类。

数据驱动滑动列表

UICollectionView通过delegate及dataSource完成布局及UI信息的采集。这些API散落在多个函数的回调中,使得我们很容易在各个回调中写出各种判断逻辑。举个简单的例子,滑动列表中存在各种样式的cell,每个cell的高度不固定。此时我们写出的代码很容易是这样的:

- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    UICollectionViewCell *cell;
    id someModel = self.dataArr[indexPath.item];   
    if ([someModel isKindOfClass:[someModel class]]) {
        cell = ...;
    }else if ([someModel isKindOfClass:[someModel2 class]]){
        cell = ...;
    }else if (...)
        
    return cell;
}

甚至有可能是这样子的:

- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    UICollectionViewCell *cell;
    if (indexPath.section == 0) {
        cell = ...;
    }else if (indexPath.section == 1){
        cell = ...;
    }else if (...)
        
    return cell;
} 

size返回函数与cell返回类似,不在列出。

那么,这么写的问题在哪里呢?

假如某天突然要新增一个样式,而且这个样式出现在列表第一个位置。此时你的判断逻辑如果是方式1的话,你需要新增一个else if 来判断新模型的类型,而且这个新增是在多个函数中。包括cell返归回调、高度大小返回回调、点击事件回调中等等。如果你是用方式2写的,那么你对以上函数的更改就变得相当棘手,因为每个函数的样式对应是硬编码固定的。此时几乎要把每个回调的返回逻辑做一个大修改。然而这还并没有结束,因为这个列表随时还有可能进行下一步的修改,最终会导致这些函数中的if else多的让你无法维护。

那如何进行数据驱动列表呢?数据驱动的好处在哪里呢?

其实,数据驱动简易实现很简单。我们只需要将每个回调中需要的信息包装到一个model中,然后取出对应信息即可。比如说,我们定义一个基类model叫做BaseListViewModel(实际中可能使用协议更为合适),并给它定义一些必要属性。比如,reuseIdentifier,cellClasss,cellSize ...等。然后我们只需要让我们自定义的model继承它并放到self.dataArr中。需要注意的是,此时我们的dataArr中的模型全部是BaseListViewModel或其子类。此时我们的代码就会变成下面这样:

- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    baseListViewModel *model = self.dataArr[indexPath.item];
    //实际中要确保cell的class注册过,这里只是演示
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:model.reuseIdentifier forIndexPath:indexPath];
    return cell;
}
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
    baseListViewModel *model = self.dataArr[indexPath.item];
    return model.cellSize;
}

到此,我们已经很容易看出一些区别。改成这样以后不管需求怎么变,我们这两个回调必然是雷打不动了。此时我们只需要关心self.dataArr是如何构建的了,这个构建过程完全可以写到一个函数当中,无论你是想增加还是想删除一个样式,改动self.dataArr就可以了。以后的维护也将不在像原来那样惊心动魄。这也基本展示了数据驱动的好处,即你关心的东西从此只是数据,而且这些数据集中在一个地方,一改全改。UI的展示完全取决于你如何组合这些数据

渐进主题

说了这么多(好像也不是很多),终于来到了主题。。。

初期版本

初期进行滑动列表封装的时候,笔者仅仅是对系统的UICollectionViewFlowLayout进行了包装,将其各种回调包装到自定义的model中。这一层包装对于一些常见布局基本也可以应对了。然而,实际使用过程中,发现系统这个layout确实不够用。比如说下面这几种情况,用UICollectionViewFlowLayout实现就比较吃力,甚至无法实现。

  • 瀑布流布局
  • 某段内元素整体右对齐、居中对齐、两端对齐
  • 分段布局,第一段常规布局,第二段瀑布流

现如今,移动端开发的样式也是五花八门。淘宝的首页是一个比较典型的分段布局的例子。瀑布流的应用也是十分广泛。当我想要用UICollectionViewFlowLayout做一个右对齐的标签云列表时发现还得对它进行重载才可能实现。如果我需要更多的对齐样式都来自己实现一遍吗。。。

更多样式支持

到这里,笔者发现如果想实现更多样式支持。看来需要直接继承UICollectionViewLayout来重写布局了。对于样式的探索笔者也一直处于一个摸索的阶段。在接触了RN之后,发现了前端布局框架flexBox。这种布局方式跟原生iOS存在一些思路上的差异。iOS原生frame布局是最容易想明白,最容易实现的一种布局。AutoLayout则将不同控件间的关系用约束来进行表达。flexBox则是针对一组元素进行有序排列。仔细一想,这三个布局竟然是一个由点到面,由表及里的一个递进。frame最容易想到的原因是我们使用frame时考虑的基本就是当前控件。而AutoLayout则是不同控件间的关系。flexBox更近一层,针对的是一行,一列,所有元素的整体排列方式。纳尼?这里竟然存在着由主观到客观,由局部到全局的思想。看来编程确实是一门艺术,它可能还包含一些人生哲学。。。

苹果可能也是意识到了flexBox的灵活性,并在iOS 9推出了UIStackView,使用过这个类同学应该可以感觉到flexBox的气息。然而这并不在本文的讨论范围之内,接下来开始着重介绍flexBox与UICollectionView的结合。

flexBox And UICollectionView

表面上看它们两个似乎没什么交集。但是,前面提到了UICollectionView将布局信息完全提取出来放到了UICollectionViewLayout中。想要完全自定义布局,重载UICollectionViewLayout即可。而flexBox恰恰是一个专门进行UI控件布局的高手。那么,把flexBox的布局放到UICollectionViewLayout中不就能组成一个全能型选手了吗?但是flexBox又是一个前端布局框架,如何运用到移动端呢?幸运的是,Yoga 是faceBook开源的flexBox C++框架,只需要把二者结合起来,就可以构建一个样式足够丰富的UICollectionView了。

HDCollectionViewLayout

HDCollectionViewLayout包装了flexBox的布局实现。但是,仅仅包装felxBox一个布局是不够的,因为此时仍然无法实现分段布局(第一段常规布局,第二段瀑布流布局),flexBox组成的样式虽然够多,但是笔者并没有发现如何使用flexBox来构建瀑布流布局。因此,HDCollectionViewLayout需要是一个能够兼容多种布局的layout。即布局是跟段绑定,而非整个UICollectionView绑定的。如此,便带来了更多的可能,每段使用不同布局也是当今应用发展的一个趋势。最终,HDCollectionViewLayout实现了分段布局。目前内部实现了YogaLayout和WaterFlowLayout,这两种布局都被添加到HDCollectionViewLayout中。并且是跟段绑定的,每段都可以使用你想要的布局。 到此,就目前常规应用来说,大多数页面的布局已经足以应对。

示例图

是时候放一波示例图了。

说明 示例图 说明 示例图
中间对齐-> 左对齐->
两端对齐-> 右对齐->
space-around

header悬浮

某个view需要悬浮在顶端,目前来说也是个比较常见的需求。HDCollectionView是用专门的一个类HDHeaderStopHelper来处理header悬浮。相比于UITableView的悬浮,HDCollectionView对header悬浮功能做了强化,你可以指定任一header悬浮或者不悬浮,并在你可以指定它是永久悬浮在顶部还是随该段内容的滑出而滑出。这些功能在实际开发中还是很有用的,留给开发者定制悬浮的自由度更高。

性能优化

需要说明的是这里的优化并不是平时开发中的那些滑动列表优化,诸如异步绘制、预排版、高性能圆角之类的优化。

通用优化

这里的优化主要是针对UICollectionViewLayout自定义实现的优化。想要测试一个自定义的UICollectionViewLayout优化程度,只需将其cell个数返回一个较大的值,例如3W以上。此时一些没有优化的layout,即使只使用设置背景色且无任何子view的cell,滑动列表也将卡顿。这里的优化其实就是对

- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect;

的实现。该函数是在滑动范围变化后,系统来索要新范围内的对应属性集合的回调,也就是在不停的滑动时,该函数会多次不停的回调。如果是继承UICollectionViewFlowLayout来重写布局,那么一般我们会先调super的方法,获取父类的属性列表后再做一定的自定义操作。显然UICollectionViewFlowLayout内部对该方法的实现已经做过优化,因此一般继承UICollectionViewFlowLayout来重写布局并不会出现性能问题。但是如果继承UICollectionViewLayout来做的话,这里就要完全自己重新实现。有些开源自定义的layout这里直接返回一个保存当前所有属性列表的全局数组,看似实现非常简单,实则在数据量大的时候会出现严重性能问题。为什么会这样呢?从该函数的表面意思看就知道这里需要返回的是当前滑动范围内,需要展示的view的UICollectionViewLayoutAttributes的集合。显然返回所有的数据给系统是不合理的。而从苹果给出的文档中可以稍微看出些端倪,其中有一句话是这样描述的:


The collection view differentiates between attributes for each type and uses that information to make decisions about which views to create and how to manage them


大意是指collectionView需要在返回的这些属性集合中进行区分、筛选工作。并且决定如何创建并管理视图。试想一下,给你1万种选择让你选出10个和给你100种选择选10个哪个轻松些。对于系统去管理这些数组数据也是一样的。因此,前置的筛选工作还是要做的。那么,这个怎么筛选呢。一般我们会使用一个全局数组来保存所有属性。首先我们能想到的最简单的方法就是遍历这个数组,将其每一项属性的frame与当前rect比对是否相交,相交则添加的结果集中。但是仔细一想,这个遍历跟直接把这整个数组返给系统好像差不多,因为系统也可以在你返回的所有数据中自己遍历一遍并找出合适的集合。其实这里最大的问题在于,随着数据量的变大,计算量也在等比例上升。那有没有更合适的方法呢?合适的方法就是二分查找。因为我们的属性数组的frame的y(纵向滑动为例,要保证属性是按序添加的)整体上基本是在不断的变大。那么,我们就可以整体上认为这是一个排好序的数组。通过二分查找找到第一个后,再向其“周边”不断试探,顺藤摸瓜全牵出来。如此之后,我们的查找时间将会指数级下降。系统在相应的时间内拿到了对应数据,自然不会再卡顿了。如果你的自定义布局不需要支持悬浮功能,那么此时的优化基本足够了。但是支持悬浮之后就又有些不同了。

针对悬浮的优化

想要支持悬浮,一般的做法是将

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds

返回值固定返回YES,一旦此处返回YES。那么一滑动就会调用

- (void)prepareLayout

而此函数中我们一般会做布局计算的初始化工作,如果此处支持悬浮且此处无任何判断的话,相当于一滑动就要把所有布局都重新计算一遍,这里带来的性能损耗可能更大,因为除了遍历,这里还包括布局位置的计算。因此,此处是需要做判断是否使用缓存的。而非直接重新计算所有布局。 调用完prepareLayout又会立马调用layoutAttributesForElementsInRect。因此layoutAttributesForElementsInRect的优化是支持悬浮条件下高效滑动的基本条件。

瀑布流布局的增量计算

一般情况下我们的列表都是分页加载的,理论上我们的布局计算也是只需要计算新的一页数据的布局的。但是实际中好多自定义的布局是重新全量计算的。各种自定义瀑布流布局就比较明显。如果你使用的瀑布流布局是在

- (void)prepareLayout

中无任何判断并且直接根据数据源计算所有布局位置的话,那么该自定义layout基本就是全量计算的。简单的说,就是在你上拉加载第100页数据完毕然后调用

[collectionView reloadData]

的时候,该layout会将包含前99页的瀑布流的布局都重新再算一遍。注意这里指的是将包含在前边的布局都重新算一遍,也就是cell的frame。并不是说前边的cellForRow会再走一遍。而HDWaterFlowLayout实现了增量计算,即每次计算的都是你新增的那部分数据布局。需要注意的是目前HDYogaFlowLayout在同一段内插入cell的计算并不是增量的。如果HDYogaFlowLayout布局想要增量计算的话,建议新增一个新的段(section)。因为任何新增的段的布局计算都是增量的。因此,在数量较大且分页加载时,建议每次添加一个段。而非在一个段内一直插入数据,除非这个段是瀑布流布局。

装饰view的支持

有时候我们会有这样的需求,每段的cell整体有个背景。不使用装饰view时,我们往往是对cell进行特殊的定制来实现这样的效果。结果就是实现过程不优雅,后期更改也麻烦。HDCollectionView对装饰view的添加非常简单,跟添加一个sectionHeader、sectionFooter几乎没有区别。好了,是时候在走一波示例图了。

说明 示例图 说明 示例图
分段布局、悬浮 横向悬浮、装饰view

支持cell所有subView的frame缓存

在搭建一些较为简单的滑动列表时,我们一般会对cell使用autoLayout来进行约束。滑动过程中不断更新cell的UI,也就在不断的触发autoLayout的重新计算(比如,每个cell对应数据源文本的长度不同)。事实上这个计算只要一次就够了,计算完毕后可以缓存其所有子view的frame,下次直接使用frame赋值即可。HDCollectionView实现了这种功能。最终的效果就是使用Masonry来设置布局(也可以是其他约束框架,比如SDAutoLayout),最终展示到界面上的cell确是在使用frame布局。此时使用view hierarchy来查看布局,cell上是不存在任何约束的。实现原理也很简单。就是创建一个同类的tempCell并设置相关约束后,更新其UI。完毕后,将其所有子view frame拷贝并缓存到对应model中。当展示在界面上的cell出现时,(注意此时的cell并没有添加约束),将缓存的frame赋值回到cell的子view即可。相比直接使用约束这里带来了两个好处。一是滑动过程中不会再产生autoLayout带来的布局计算,而是仅仅在设置新的frame。二就是在设置约束时cell的数据源已经拿到,此时可以根据数据源来判断哪些view需要设置哪些约束。因为我们的需求有时候往往是当某个字段未返回值时,那么这个view不在展示。而在普通的设置中,约束往往是放在cell初始化的位置的,这时其实是不知道数据源的。此时就需要在更新UI中更新约束,不过无疑会带来更多的性能损耗。需要注意的是,在使用这个功能时不要对cell子view进行动态添加或移除。否则可能会导致拷贝的frame数据与展示的view的子view不一致而出现问题。此时,只需要cell初始化时创建最多情况下的子view,在动态进行hiden即可。

子view统一UI更新接口及统一回调

HDCollectionView对所有子view做了统一的回调封装,在cell/header/footer/decoration中回调到VC简单并且统一。使用协议统一所有cell UI更新函数,方便统一UI设定。且内部设置了几种策略来应对不同需求的回调方式,对事件处理的定制度也较高。

实际应用

单页面综合应用

这里用汽车之家的车辆信息页来展示单一页面的综合应用。这个页面涉及到了各种悬浮以及子view嵌套。

说明 示例图
汽车之家

横向滑动切换的布局

现在好多应用都存在这种页面,横向滑动可以切换各种页面。这里以QQ联系人页面为例,这个页面支持横向滑动切换栏目,纵向滑动时 栏目view 会在顶部悬浮,子view的header也会悬浮。HDCollectionView借助轮子JXCategoryView实现了QQ联系人页面,并封装到了HDMultipleScrollListView中。由于依赖了JXCategoryView,所以HDMultipleScrollListView并没有放到pod库中,因此如果使用的话需要手动拖入代码并安装JXCategoryView。

说明 示例图
QQ联系人

新闻详情页类布局

这种页面一般是指一些混合滑动的页面。类似新闻详情页,往往前段新闻详情部分用webView展示,后边跟帖评论用原生展示。这里就存在一个衔接问题。如果只用一个单一的listView来展示,那么webView对应cell的高度就需要设置为其contentSize大小。但是,当webView的高度很高时。相当于放置了一个很长的大webView到cell上。此时很可能会带来内存问题。解决方案就是底部设置一个scrollView,webView及其他子view的最大size均为scrollView的size。滑动时监听底部scrollView的contentOffset并实时更新对应view的contentOffset及frame即可。HDScrollJoinView就实现了这种衔接功能,只需要把想要放置的scrollView传递给它即可。

展望

iOS 13已经新增了UICollectionViewCompositionalLayout。该布局多了一个组的概念。它的优势在于纵向滑动夹杂横向滑动的view时,你仍然可以使用一个UICollectionView来进行构建,而并不需要嵌套实现。它确实很让人期待。但是我担心的问题如果这个类将来是iOS 13+ 才能使用的话,那么可能还需要一段时间才能接入它。

总结

其实,市面上目前已经存在好多数据驱动的滑动列表了。但大多数是基于UITableView进行构建的。即使基于UICollectionView构建的数据驱动列表一般也是对系统flowLayout进行的封装。相比HDCollectionView来说,HDCollectionView在自带样式支持上可能更加丰富,你无需在手动实现各种混合布局。而其他简易框架的优势在于更加轻量。学习成本上来说基本差不多,因为基本都不用学习。。。

最后,HDCollectionView目前实现的单页布局及框架布局,基本上可以实现目前各种常见的页面了。而且无论是在前期搭建还是后期维护中,都不会存在原生API散落的问题,维护相对会轻松许多。最后,即使你不使用HDCollectionView,你完全可以构建自己的数据驱动view,因为数据驱动滑动列表已然成为一种趋势。

首尾呼应,再放链接😁HDCollectionView

然后,给个star我也不会介意的😂

补充

不支持diff的列表组件是木得灵魂的 ----- 鲁迅(我没说过)

现已支持diff操作,刷新UI什么的之后都是动画搞起。。。