一个支持页面多方向划入的框架--PZPageListContainerView

本文大约5100字,需要的阅读时间比较长,属于教程类,慎入。建议实在空闲或有空且有需求的时候跟着做(虽然一般有需求都不会有空)。直接上手请移步使用说明书

本文将会讲述一个能够支持上下左右划入(类似SnapChat)或纵向/水平滑动(类似头条)的PageList框架的搭建过程及其中遇到的一些问题。

!!!原创文章,转载请注明来源: pany.fun

本文的相关测试demo: PZPageListContainerViewDemo

测试环境:Xcode9.2、iPhone7模拟器-iOS11.2、iPhone7-11.2.5

#前言

本文将讲述一个我自己写的containerView的搭建过程以及遇到的一些问题与处理,它来源于我司的一些业务思考。它既能够支持类似SnapChat的上下左右页面划入的功能(并做的更优秀),同时也能够支持类似今日头条的横向PageList。实际上,横向、纵向、横+纵的PageList它都是没有问题的。

因为只是想搭建一个易用的框架,所以整体设计不会向太多业务偏移,不会涉及特别细节的业务需求。

这篇教程之后,我们最终将会实现的效果是这样的

效果图

#开始搭建

搭建过程我们将以SnapChat的首页为模板进行,更多PZPageListContainerView的用法将会写在文末。

我们面临的一个问题当然是容器的选型,iOS中能滚动的还是有那么几个的,比如scrollView、tableView、collectionView 还有一个UIPageController(用过一次,感觉挺别扭…)。

我们最开始是用scrollView实现的,一个足够满足明确需求且足够灵活的选型。但是它在我们写一套单独的生命周期之类的方法的时候(后面将会讲到原因),在一些边界情况需要写很多的代码来判断,再加上一些其它的动画需求,代码量会更多,所以PZPageListContainerView最终所幸我就选择了collectionview。OK,容器选型->UICollectionView

既然我们需要像SnapChat一样支持上下左右的页面都能向中间划入显示,那么必然就需要两个collectionView的,一个collectionView用来承载左和右使用横向layout,另一个用来承载上和下使用纵向layout,两个collectionView都把第二个cell留空并设置clear color。

那么类似SnapChat中间部分的相机页面放哪呢?观察发现,中间的页面是不会跟随着移动的,所以,这个中间页面一定不会放在我们的collectionView上。因为中间这个视图比较特殊,先不讲复杂,先用一个label代替啦,后面我们会有相关部分来解决。我们前面写collectionView的时候有各留一个cell的空白,就刚好能够把label露出来啦。完成后我们就会得到下面这样的视图结构,这一块代码比较简单,不详述啦,详见源码

初步视图结构

完成初步视图结构的搭建后,我们会发现一个问题:两个collectionView相互覆盖,只有一个能够被滑动,另一个没法动…

#问题一:两个collectionView相互覆盖,无法同时滑动

究其原因,其实是两个collectionView因为都是全屏的,所以在层级上,一定有一个在上面一个在下面,因而导致了遮蔽,以至于在下面的没法被滑动。

但是仔细想想,其实两个collectionView的滑动并没有真正的冲突,它们一个左右滑动,一个上下滑动,按道理应该是可以同时且同级存在的,这里需要强调一下同级,指的是在同一个view上是可以同时存在一个左右手势和一个上下手势的

接着我们找到collectionView的父视图scrollView,然后就会找到这么个属性👇👇

@property(nonatomic, readonly) UIPanGestureRecognizer *panGestureRecognizer

它是readonly的,我们无法修改,但是我们可以拿出来使用

最终我们通过这么两行代码解决了这个问题

[self addGestureRecognizer:_horizontalCollectionView.panGestureRecognizer];
[self addGestureRecognizer:_verticalCollectionView.panGestureRecognizer];
// self 指的PZPageListContainerView,此处它是collectionView的父视图

然后两个collectionView就都能够顺利滑动啦🎉

然而,又一个问题来啦,在乱滑一通后,会出现非空cell重叠的情况,就像下面这样

相机画面出现在最底层是没有问题的,有问题的是 绿色(代表上)和橙色(代表左) 的重叠,这个在SnapChat中是不可能重叠的。其实原因很简单,我们的collectionView的手势都被移到了同一层级,所以它们的滑动都是随时可以生效的,而SnapChat的横向list的滑动,只在纵向list处于第二个(即空白cell并露出相机)的时候才可以滑动,纵向list的滑动亦然。

所以解决办法也很简单,在某些情况下,我们需要限制collectionvView的滑动

#问题二:实现某些情况下的单个collectionView滑动

为了让功能更加灵活,我们首先提供了两个可供外部设置的参数,由业务来决定具体的滑动限制

// 如果只是单项列表,无需设置
@property (nonatomic) NSInteger horizontalScrollableIndex;  /**< default = 0, 当垂直list处在该index时,水平list可以滑动,NSIntegerMax表示不限制 */
@property (nonatomic) NSInteger verticalScrollableIndex;    /**< default = 0, 当水平list处在该index时,垂直list可以滑动,NSIntegerMax表示不限制 */

简单的解释一下这两个参数

horizontalScrollableIndex:用来表示horizontalList什么时候可以滑动,这个滑动时机用index表示,index指的是另一个方向即verticalList的indexPath中的item。

当 verticalList的当前显示cell的indexPath的item == horizontalScrollableIndex 时 horizontalList 允许被滑动, 如果 horizontalScrollableIndex == NSIntegerMax 则不做滑动限制。

verticalScrollableIndex 也是一样的道理。

代码实现如下:

#pragma mark - Private
- (void)resetCollectionViewScrollable {
    NSInteger horizontalIndex = floor(_horizontalCollectionView.contentOffset.x / self.bounds.size.width);
    NSInteger verticalIndex = floor(_verticalCollectionView.contentOffset.y / self.bounds.size.height);
    _horizontalCollectionView.scrollEnabled = _horizontalScrollableIndex == NSIntegerMax || verticalIndex == _horizontalScrollableIndex;
    _verticalCollectionView.scrollEnabled = _verticalScrollableIndex == NSIntegerMax || horizontalIndex == _verticalScrollableIndex;
}

因为在很多地方都会涉及到调用,所以我们这里设计成了私有方法。通过偏移量的向下取整来判断当前的index,然后通过index对比来判断是否允许滑动。

需要调用的主要地方有下面这几个,其它的具体见源码(Edit: 后期因为需要满足其它功能,源码稍微做了些修改)

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView;

搭配两个滑动限制参数,我们还设计了两个公开方法,用来通过代码把list滑动到对应的位置,其内部实现就是调用的collectionView的scrollToItemAtIndexPath,比较简单不过多阐述。两个滑动限制参数和两个公开的滑动方法需要搭配使用,否则使用不当会导致无法滑动。

在这里,我们把两个滑动限制参数都设置为1(一共有3个cell,中间的index为1),并且把横纵两个list都滑动到index为1的地方,然后一个类似SnapChat首页的基础结构就完成啦!🎉🎉

这里再插两句,其实SnapChat只实现了左右两个页面的划入,并没有去做上下的,我也看了他们的视图结构,他们仅在横向上使用的scrollView,纵向上却是使用手势来控制的。虽然不清楚他们这么选择的原因,但是从我司自己实现的效果来看,整体还是很流畅的,感觉体验上尤其上下页面比SnapChat体验更好。

#问题三:让hit能够透过collectionView

SnapChat位于中间的页面是一个相机页面,那么在我们前序步骤完成的结构上,如果我们尝试去和这个页面做一些交互,比如我们在label同级加上一个UISwitch,会发现它根本不能响应…

原因也是非常简单的,因为被collectionView挡住了嘛,点击全被拦住了。所以解决方案当然是尝试然点击能够透过去,这个重写collectionView的hitTest就能做到啦,这也是前面的结构图中我们将collectionView自定义类型的原因。

一方面因为点击能否透过涉及到一些具体逻辑需要在PZPageListContainerView(两个collectionView的父视图,也是这个框架的根视图)中判断,另一方面因为想将逻辑都统一在一起,所以我们在collectionView的hitTest中直接通过代理把事件转出去了,而整个设计中collectionView的代理正好是PZPageListContainerView。

代理:

@protocol PZContainerContentCollectionViewDelegate<UICollectionViewDelegate>
@optional
- (UIView *)pzContainerCollectionView:(PZContainerContentCollectionView *)collectionView didHitWithInfo:(NSDictionary *)hitInfo;

@end

hitTest重写:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 拿到原本的应当响应这个事件的hitView
    UIView *hitView = [super hitTest:point withEvent:event];
    // 将相关数据传递给delegate
    if (self.delegate && [self.delegate respondsToSelector:@selector(pzContainerCollectionView:didHitWithInfo:)]) {
        NSMutableDictionary *hitInfo = [NSMutableDictionary dictionary];
        [hitInfo setValue:[NSValue valueWithCGPoint:point] forKey:@"hitPoint"];
        [hitInfo setValue:hitView forKey:@"hitView"];
        [hitInfo setValue:event forKey:@"hitEvent"];
        // 由delegate来决定谁来响应这个hit
        UIView *responseView = [self.delegate performSelector:@selector(pzContainerCollectionView:didHitWithInfo:) withObject:self withObject:hitInfo];

        hitView = responseView;
    }
    return hitView;
}

接下来就是在PZPageListContainerView中进行判断,并处理这个代理啦

#pragma mark - <PZContainerContentCollectionViewDelegate>
- (UIView *)pzContainerCollectionView:(PZContainerContentCollectionView *)collectionView didHitWithInfo:(NSDictionary *)hitInfo {
    UIView *hitView = [hitInfo valueForKey:@"hitView"];
    // 正常情况下,hitView应当是cell的contentView的subview
    // 只有当页面存在空白区域的时候,才会点中cell.contentView
    // 如果点中的是contentView(superview为cell),返回nil不响应,其它情况均正常响应
    UIView *responseView = [hitView.superview isKindOfClass:[UICollectionViewCell class]] ? nil : hitView;
    return responseView;
}

当我们hit的是cell的contentView的时候,说明我们点的cell在这一块位置是空白的,如果cell在这一块位置有其它view,是一定hit不到contentView的,除非也无需求改写了其它view的hitTest,所以hit在这里是应该被允许透过去的。其它区域,hit正常响应。

理论上,这样子hit就能够透过collectionView啦,但是实际尝试后,我们会发现,switch对于点击仍然是没有反应的。这是一个需要试错然后找到解决方案的问题,它可能是一个无奈之解,所以我们选择在文中提出而不是回避。

此处将回到我们前面埋下的一个坑——类似SnapChat中间的相机视图,究竟该放在哪里。

#问题四:中间类似相机视图(不需要跟随滚动的视图)的层级问题

我们改写collectionView以后switch仍然无法响应点击,原因是因为hit被我们collectionView的父视图——即PZPageListContainerView拿走啦。

我们尝试着按老思路,改写PZPageListContainerView的hitTest来做判断,我们做了这样的判断

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitView = [super hitTest:point withEvent:event];
    return hitView == self ? nil : hitView;
}

它能够解决层级在PZPageListContainerView下的switch无法响应的问题,但也带来了另一个问题——PZPageListContainerView中的collectionView无法滑动了。这也算预期之内的问题,因为我们之前为了让collectionView能够同时滚动,把它们的panGesture都挪到了PZPageListContainerView上,而放我们在滑动的时候,hitView原本应当是PZPageListContainerView,但是却被hitTest错误返回nil不响应啦,此方案陷入了无解。

简单捋顺:collectionView的滑动的panGesture必须在整个页面层级的最后,这里的整个不仅仅包含我们的PZPageListContainerView,还包含跟PZPageListContainerView同层级或有更低层级的其它视图。

最终,我们选择了这样一个方案(后续有好的方案再提升)

在PZPageListContainerView中,以懒加载的方式提供一个readOnly的backgroundView,用于方便外部放一些东西,比如我们想要的类似相机的视图。

此时视图结构是这样的👇👇 蓝色选中的就是提供给外部的backgroundView

新的视图结构

对于这样的设计还有一个好处,哪怕这个backgroundView上有局部的panGesture(比如有个小的collectionView),也是不冲突的,因为它在我们的collectionView的panGesture之上,所以也能很好的工作。

解决这个问题以后,一切看起来就更加顺畅啦,仿佛胜利在望。

接下来我们尝试往四周的页面上加些复杂的东西,比如我们往左侧的页面(橙色)上加一个tableView,并且让tableView支持删除功能。然后问题又来啦…

#问题五:tableView的侧滑删除与pageList的侧滑冲突问题

在我们给左侧页面添加tableView且实现删除功能无误的情况下,滑动删除cell触发的(几乎)总是PZPageListContainerView内的collectionView的滑动。

这个bug主要还是上下层手势冲突引起的,同时有两个手势可以响应。这里我们用到一个用来控制手势被识别后能否继续沿响应链传递的方法

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;

这是UIGestureRecognizerDelegate中的方法,它用来控制是否允许多个手势共存,返回NO,则手势被识别后停止传递,返回YES则会继续沿响应链传递。默认返回NO

我们对这个方法的使用是这样的,当手势冲突时,在这个方法中来禁掉一个手势(尽量保证同时只有一个手势响应,不然场面会有点失控🌝)。这个方法的调用是很早的,它在手势收到触碰事件的时候就能够有回调。

具体代码如下

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    if ([otherGestureRecognizer.view isKindOfClass:[UITableView class]] &&
        gestureRecognizer.state != UIGestureRecognizerStatePossible) {
        if ([otherGestureRecognizer.view isKindOfClass:[UITableView class]] ) { //tableview可能存在滑动删除问题
            CGPoint touchPoint = [otherGestureRecognizer locationInView:otherGestureRecognizer.view];
            UITableView *tableView = (UITableView *)otherGestureRecognizer.view;
            __block BOOL touchCellCanEdit = NO;
            [tableView.visibleCells enumerateObjectsUsingBlock:^(__kindof UITableViewCell * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                if (CGRectContainsPoint(obj.frame, touchPoint)) {
                    NSIndexPath *indexPath = [tableView indexPathForCell:obj];
                    if ([tableView.dataSource respondsToSelector:@selector(tableView:canEditRowAtIndexPath:)]) {
                        touchCellCanEdit = [tableView.dataSource tableView:tableView canEditRowAtIndexPath:indexPath];
                    }
                    // 系统的tableView的滑动删除还有另一种实现方式,也需要根据情况判断一下
                    if (!touchCellCanEdit && [tableView.delegate respondsToSelector:@selector(tableView:editActionsForRowAtIndexPath:)]) {
                        NSArray *actionArr = [tableView.delegate tableView:tableView editActionsForRowAtIndexPath:indexPath];
                        touchCellCanEdit = actionArr.count > 0;
                    }
                    *stop = YES;
                }
            }];
            // 当点中cell且cell能删除的时候,禁用当前collectionView的滑动手势
            gestureRecognizer.enabled = !touchCellCanEdit || otherGestureRecognizer.state == UIGestureRecognizerStateEnded || otherGestureRecognizer.state == UIGestureRecognizerStateCancelled;
            // 当前滑动手势被禁用,则允许手势继续传递
            return !gestureRecognizer.enabled;
        }
    }
    return NO;
}

之所以要做一系列判断,是希望尽量缩小手势冲突的范围,因为cell的删除手势只有一个且在tableView上,所以我们想尽量只在cell支持删除的部分去响应删除,其它部分(如header、没有cell的空白部分)依旧响应collectionView的滑动。

处理完tableView的手势以后,考虑到其它页面可能同样存在类似的问题,所以我们又提供了代理供业务自行处理。

这个问题解决以后,cell就能够正常被删除啦,一切又回到了预期的模样。

#问题六:为什么生命周期方法执行时间点不对

完成上述步骤以后,如果我们尝试去在上下左右的controller中的系统的生命周期方法(例如:-viewWillAppear)中去做些什么,会发现这些方法都被很早就执行啦,几乎是PZPageListContainerView一加载出来就执行啦,哪怕我们还看不到这个view,然后还有一些会在使用过程中混乱调用。

其实原因也很简单,因为内部会把这些controller的view加到collectionView的cell上,所以系统的生命周期都被打乱啦。

解决办法应该也只有一个——重写一套生命周期方法。更详细的代码,封装在了PZPageListContainerViewController中,PZPageListContainerView也提供了一些能够帮助实现的代理。详见源码

当然,除了生命周期以外,如果需要做的更加完善,我们还需要重屏幕旋转相关方法、状态栏相关方法。源码中这些都在controller中做了相关判断并调用正确的vc。

#问题七:PZPageListContainerView上touch事件意外cancel

如果我们想在我们这个类似相机的页面(先叫centerVC)上面做一些更加复杂的事情,比如利用touch事件让一个小view跟着手指移动。

我们在centerVC上加了这样的代码来做这个跟随手指移动的事情

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    CGPoint location = [touch locationInView:self.view];
    _touchView.center = location;
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"touch end");
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"touch cancel");
}

首先我们会遇到collectionView的滑动同时被触发的问题,所以我们需要添加一个collectionView的禁用接口给外部根据业务需求来在需要的时候禁掉collectionView的滑动。

然后再次滑动我们加的touchView,会发现稍微动一下以后,它就不动了,看log是touchCancel执行了,这看起来非常的诡异,让我们很是头大。

我们一番搜索后找到了解答,博主自己提出了问题,后来自己研究解决啦:touchesCancelled called unexpectedly

简单点说,scrollView有这么一个参数(没错,说白了又是collectionView引起的)

@property(nonatomic) BOOL canCancelContentTouches;    
// default is YES. if NO, then once we start tracking, we don't try to drag if the touch moves. this has no effect on presses

注释写到,当返回NO的时候,scrollView就不会跟随手指移动啦。而其实正是我们当前需求所需要的。

我们在实践中发现这个参数能解决问题,但是不是特别好用,它太过武断而一刀切,所以我们继续摸索,找到了这么个更好用的方法

- (BOOL)touchesShouldCancelInContentView:(UIView *)view;
// called before scrolling begins if touches have already been delivered to a subview of the scroll view. 
// if it returns NO the touches will continue to be delivered to the subview and scrolling will not occur
// default returns YES if view isn't a UIControl
// not called if canCancelContentTouches is NO

这个回调方法的主要作用是能够改变touches的分发,默认是YES表示需要继续分发touches。当返回NO时,就会被打断。需要注意的是,它不是打断scrollView的touches事件,而是打断传进来的view的touches事件

原本这个回调是影响scrollView上的subView的,但是这里我们把collectionView的panGesture直接移到了PZPageListContainerView上,而我们的centerVC所在的backgroundView是PZPageListContainerView的subView,这就导致collectionView和centerVC的view形成了一个假的父子关系。

搞清楚了原理以后,我们在collectionView中添加以下代码,就能解决问题啦

- (BOOL)touchesShouldCancelInContentView:(UIView *)view {
    return self.scrollEnabled && self.panGestureRecognizer.enabled;
}
// 当collectionView可以滑动时,打断subView的touches,否则不打断。

#End

至此,整个框架也就基本结束啦。过程中遇到了很多很多问题都是因为scrollView的叠加导致的,其中的一些比较麻烦的问题,也基本都帮大家扫了雷,希望其中的某些东西能够为大家解决问题提供思路。本文中涉及到的一些技术点,后期有空也会从这个demo的搭建过程中分离出来单独出文。更多细节,请参考源码

创作不易,转载请注明来源!pany.fun
本文链接:http://pany.fun/post/PZPageListContainerView/