由于工作上的需求,需要在 iOS 中做一个类似于嵌套滑动的案例,大致上就是,顶部有一个 Header,下面是一个 List,然后 Header 会随着 List 的滑动进行展开或收起。这种功能在 Android 中倒是挺常见,直接使用 Material 相关的一些组件就能完成,之前也多次使用过,但是要在 iOS 上做这么一个功能,之前倒没想过该怎么做。

开发的第一步,先 google 一下,看了一圈,看到了几种方案,但总体感觉都不是特别好,大概这几种:

  1. 将 Header 作为 CollectionView 的第一个 Section Header,然后将其设置为 Header 常驻;这种方式对原来的运行方式入侵较小,但同样限制也比较多,比如如果 CollectionView 本身也需要使用 Section Header 展示信息的时候,就不能单独控制哪个 Header 常驻,另外也无法做到让 Header 只隐藏一部分的状态
  2. 手势穿透,使内外层 ScrollView 都能接收手势事件,然后自己通过状态控制二者是否需要响应事件;这种方式需要重写 delegate 的 didScroll 来控制其是否需要响应事件,理论上各种能力都可以做到,但是需要同时控制两个 ScrollView 的状态,代码会比较复杂
  3. 重新定义一个手势检测器,不使用 ScrollView 自带的手势能力;这种就直接外部控制内外层是否滑动,ScrollView 自己是接收不到事件的,就无法使用到 ScrollView 内置的一些能力(如滚动条),但是同样,可定制化程度会比较高

以上方案中,第一种最简单,但满足不了我想要的效果,另外两种应该可以满足,但实现起来会比较复杂,而且对于这种比较定制化的滑动效果,可能必须要自己写一套代码,有需要才能随时改,别人的方案不好复用。

最开始我就是采用了网上的第二种方案,但需求一旦有所改动,就得自己想办法看怎么调整现有的代码,这其实相比于自己重新写一套代码更难,所以最后,我结合了解到的以上几种方案和我们的需求,通过一种比较折中的方式来实现:

  • 以 CollectionView 为基础,通过 delegate 监听滑动事件,不改变 CollectionView 的滑动效果,delegate 监听只用于控制 Header 位置
  • Header 作为 CollectionView 的 subview,嵌入到 CollectionView 中,然后通过 delegate 控制 Header 的 frame
  • CollectionView 第一个 Section 空出来,以此来营造列表在 Header 底部的假象

所以,具体的实现方案为:

  1. 新建一个 delegate 类 BEHeaderScrollViewDelegate,内部实现 didScroll 等方法,实现对 Header 位置的控制
  2. 使原先的 delegate 继承这个 delegate,然后在其中调用 super 的相关方法
  3. 对于 IndexPath 相关的方法,都跳过一个 Section,且使第一个 Section 的高度与 Header 一致

这个方案的好处就在于,在整个过程中只有一个滑动事件,而且是系统的 ScrollView 处理好的,只需要在 delegate 中根据滑动的距离,来制定 Header 当前应该所处的状态即可。

但是,需要留出一个 Section 给 Header ,这会引发一系列的反应,即所有使用到 IndexPath 的地方,都需要判断它是否会受到影响。同时还要考虑对代码的入侵性,从上述功能实现来看,这种方案需要改动 CollectionView 的 Delegate 的代码,包括 datasource 和 delegate 都需要改动,这就意味着代码入侵性比较高,需要采取一定方式尽可能地减少入侵,开发起来才会更加顺畅,最好是让原先的 Delegate 感知不到有代码变动。

所以如果要降低入侵性,要能够做到这两件事:

  1. 数据绑定相关,原先的 delegate 不需要关心这个多出来的 Section,还能够按照正常的 IndexPath 进行数据绑定操作
  2. 滑动事件相关,原先的 delegate 不需要调用 super didScroll

由此想到,可以使用 objc 的 method swizzled 方法,将 UICollectionViewDelegate 的函数,与BEHeaderScrollViewDelegate 私有方法做交换,于是便可以使,系统不会直接调用原 delegate 的方法,而是先调用 BEHeaderScrollViewDelegate 的私有方法,在它的私有方法中,先处理滑动相关的逻辑,再调用原本的 delegate 方法,这么做的一个主要目的,可以认为是调换父子类关系,原本应该是由原先的 delegate 继承 BEHeaderScrollViewDelegate 的,所以系统会先调用 delegate 的方法,而现在则变成先调用 BEHeaderScrollViewDelegate 方法,再调用 delegate 方法,这就给予了 BEHeaderScrollViewDelegate 充分的自由度且不会入侵原先的 delegate 实现,具体做法为:

  1. 数据绑定相关的方法,判断当 sectoin 为 0 时,表示这是一个预留 section,执行自己的操作;并在调用原本的实现方法时,将传过去的 IndexPath section 做 -1 处理,这样一来,原本的 delegate 实现便可以不关心预留 section 这件事,相关的方法都无需改动
  2. 滑动事件相关的方法,先做自己的操作,然后调用原本的实现方法,这样做的主要目的就是避免子类再额外调用 super 方法,也是为了减少入侵性

通过以上操作,可以实现这样的一个效果:如果想在一个正常的 CollectionView 的基础上增加一个 Header,那就只需要让当前的 delegate 类继承 BEHeaderScrollViewDelegate,然后把 Header View 以及一些必备参数传给 BEHeaderScrollViewDelegate 就行,当前的 delegate 中所有的方法实现都不需要改动。最终的实现代码见附录。

不过这种做法会导致一些问题,比如当重写 viewForSupplementaryElementOfKind 函数,在这里先将 IndexPath 的 section -1 再传给 delegate 的原实现时,但是在 delegate 实现中,是根据这个被修剪过的 IndexPath 调用 dequeueReusableSupplementaryViewOfKind 获取 HeaderView 的,那么就会导致:

  • 系统调用 section=1 的 viewForSupplementaryElementOfKind 获取 HeaderView
  • 但是实际返回的是 section=0 调用 dequeueReusableSupplementaryViewOfKind 返回的 HeaderView

这可能会导致系统误以为没有拿到合适的 HeaderView,从而反复请求,HeaderView 也就无法有效地复用,对应的 cell 也是一样,这里最终的做法是在 BEHeaderScrollViewDelegate 中不做 IndexPath 的修剪,而是在子类中绑定数据时做偏移,这一点还有待改进。

附录

@interface BEHeaderScrollViewDelegate : NSObject <UICollectionViewDelegateFlowLayout>

/// header 的最小高度
@property (nonatomic, assign) NSInteger minHeaderHeight;

/// header 最大高度
@property (nonatomic, assign) NSInteger maxHeaderHeight;

/// header view
@property (nonatomic, strong) UIView *headerView;

/// 当前 heder view 的显示高度
@property (nonatomic) CGFloat headerHeight;

/// 滑动 view
@property (nonatomic, readonly) UICollectionView *collectionView;

- (CGFloat)offsetYWithSection:(NSInteger)section;
- (NSArray<NSIndexPath *> *)realVisiableItems:(NSArray<NSIndexPath *> *)items;

+ (void)swizzleMethods;

@end
static NSString *HEADER_SCROLL_VIEW_HEADER_ID = @"header_scroll_view_header";

@interface BEHeaderScrollViewDelegate () <UICollectionViewDelegateFlowLayout, UICollectionViewDataSource> {
    BOOL _keepHeight;
    CGFloat _lastContentOffsetY;
    
    BOOL _registerHeaderView;
}

@end

@implementation BEHeaderScrollViewDelegate

@synthesize headerHeight = _headerHeight;

+ (void)swizzleMethods {static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self swizzleMethods:[self class] originalSelector:@selector(numberOfSectionsInCollectionView:) swizzledSelector:@selector(be_numberOfSectionsInCollectionView:)];
        [self swizzleMethods:[self class] originalSelector:@selector(collectionView:numberOfItemsInSection:) swizzledSelector:@selector(be_collectionView:numberOfItemsInSection:)];
        [self swizzleMethods:[self class] originalSelector:@selector(collectionView:cellForItemAtIndexPath:) swizzledSelector:@selector(be_collectionView:cellForItemAtIndexPath:)];
        [self swizzleMethods:[self class] originalSelector:@selector(collectionView:didSelectItemAtIndexPath:) swizzledSelector:@selector(be_collectionView:didSelectItemAtIndexPath:)];
        
        [self swizzleMethods:[self class] originalSelector:@selector(collectionView:viewForSupplementaryElementOfKind:atIndexPath:) swizzledSelector:@selector(be_collectionView:viewForSupplementaryElementOfKind:atIndexPath:)];
        [self swizzleMethods:[self class] originalSelector:@selector(collectionView:layout:referenceSizeForHeaderInSection:) swizzledSelector:@selector(be_collectionView:layout:referenceSizeForHeaderInSection:)];
        [self swizzleMethods:[self class] originalSelector:@selector(collectionView:layout:referenceSizeForFooterInSection:) swizzledSelector:@selector(be_collectionView:layout:referenceSizeForFooterInSection:)];
        
        [self swizzleMethods:[self class] originalSelector:@selector(scrollViewWillBeginDragging:) swizzledSelector:@selector(be_scrollViewWillBeginDragging:)];
        [self swizzleMethods:[self class] originalSelector:@selector(scrollViewDidEndDragging:willDecelerate:) swizzledSelector:@selector(be_scrollViewDidEndDragging:willDecelerate:)];
        [self swizzleMethods:[self class] originalSelector:@selector(scrollViewDidEndDecelerating:) swizzledSelector:@selector(be_scrollViewDidEndDecelerating:)];
        [self swizzleMethods:[self class] originalSelector:@selector(scrollViewDidScroll:) swizzledSelector:@selector(be_scrollViewDidScroll:)];
    });
}

- (instancetype)init
{
    self = [super init];
    if (self) {
        _keepHeight = YES;
        _registerHeaderView = NO;
    }
    return self;
}

- (void)setHeaderView:(UIView *)headerView {
    _headerView = headerView;
    
    [self.collectionView addSubview:headerView];
    headerView.layer.zPosition = CGFLOAT_MAX;
    self.headerHeight = headerView.bounds.size.height;
}

#pragma mark - swizzled methods
- (NSInteger)be_numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return [self be_numberOfSectionsInCollectionView:collectionView] + 1;
}

- (NSInteger)be_collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    if (section == 0) {
        return 0;
    }
    
    return [self be_collectionView:collectionView numberOfItemsInSection:section-1];
}

- (__kindof UICollectionViewCell *)be_collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    if (indexPath.section == 0) {
        return nil;
    }
    
    return [self be_collectionView:collectionView cellForItemAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row inSection:indexPath.section - 1]];
}

- (UICollectionReusableView *)be_collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
    if (indexPath.section == 0) {
        if (kind == UICollectionElementKindSectionHeader) {
            if (!_registerHeaderView) {
                [collectionView registerClass:[BEHeaderScrollViewHeader class] forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:HEADER_SCROLL_VIEW_HEADER_ID];
                _registerHeaderView = YES;
            }
            
            BEHeaderScrollViewHeader *header = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:HEADER_SCROLL_VIEW_HEADER_ID forIndexPath:indexPath];
            return header;
        } else {
            return nil;
        }
    }
    
    return [self be_collectionView:collectionView viewForSupplementaryElementOfKind:kind atIndexPath:[NSIndexPath indexPathForRow:indexPath.row inSection:indexPath.section]];
}

- (CGSize)be_collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section {
    if (section == 0) {
        return self.headerView.bounds.size;
    }
    
    return [self be_collectionView:collectionView layout:collectionViewLayout referenceSizeForHeaderInSection:section-1];
}

- (CGSize)be_collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section {
    if (section == 0) {
        return CGSizeZero;
    }
    
    return [self be_collectionView:collectionView layout:collectionViewLayout referenceSizeForFooterInSection:section-1];
}

- (void)be_collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    if (indexPath.section == 0) {
        return;
    }
    
    [self be_collectionView:collectionView didSelectItemAtIndexPath:[NSIndexPath indexPathForRow:indexPath.row inSection:indexPath.section-1]];
}

- (void)be_scrollViewWillBeginDragging:(UIScrollView *)scrollView {
    _keepHeight = NO;
    _lastContentOffsetY = scrollView.contentOffset.y;
    
    [self be_scrollViewWillBeginDragging:scrollView];
}

- (void)be_scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
    _keepHeight = !decelerate;
    
    [self be_scrollViewDidEndDragging:scrollView willDecelerate:decelerate];
}

- (void)be_scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    _keepHeight = YES;
    
    [self be_scrollViewDidEndDecelerating:scrollView];
}

- (void)be_scrollViewDidScroll:(UIScrollView *)scrollView {
    if (_keepHeight) {
        self.headerHeight = _headerHeight;
    } else {
        CGFloat detalY = [self detalY];
        if (detalY > 0) {
            // drag up
            if (_headerHeight > _minHeaderHeight) {
                self.headerHeight = MAX(_minHeaderHeight, _headerHeight - detalY);
            } else {
                self.headerHeight = _minHeaderHeight;
            }
        } else {
            // drag down
            CGFloat preferHeight = [self preferHeaderHeight];
            if (_headerHeight < _maxHeaderHeight && preferHeight > _minHeaderHeight) {
                self.headerHeight = preferHeight;
            } else {
                self.headerHeight = _headerHeight;
            }
        }
        
        _lastContentOffsetY = scrollView.contentOffset.y;
    }
    
    [self be_scrollViewDidScroll:scrollView];
}

#pragma mark - public
- (CGFloat)offsetYWithSection:(NSInteger)section {
    UICollectionViewLayoutAttributes *attr = [self.collectionView layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:section+1]];
    
    CGFloat offsetY = attr.frame.origin.y - self.headerHeight - 55;
    if (offsetY < self.minContentOffsetY) {
        offsetY = self.minContentOffsetY;
    }
    offsetY = MIN(offsetY, self.maxContentOffsetY);
    return offsetY;
}

- (NSArray<NSIndexPath *> *)realVisiableItems:(NSArray<NSIndexPath *> *)items {
    NSMutableArray *arr = [NSMutableArray array];
    CGFloat headerHeight = self.headerHeight;
    CGFloat contentOffset = self.collectionView.contentOffset.y;
    for (NSIndexPath *path in items) {
        UICollectionViewLayoutAttributes *attr = [self.collectionView layoutAttributesForItemAtIndexPath:path];
        CGFloat offsetY = attr.frame.origin.y - contentOffset - headerHeight + 55;
        if (offsetY > 0) {
            [arr addObject:[NSIndexPath indexPathForRow:path.row inSection:path.section-1]];
        }
    }
    return [arr copy];
}

#pragma mark - getter & setter
- (void)setHeaderHeight:(CGFloat)height {
    _headerHeight = height;
    NSInteger offsetY = self.collectionView.contentOffset.y;
    _headerView.frame = CGRectMake(0, offsetY + height - _headerView.bounds.size.height, _headerView.bounds.size.width, _headerView.bounds.size.height);
}

- (CGFloat)headerHeight {
    NSInteger offsetY = self.collectionView.contentOffset.y;
    return _headerView.frame.origin.y + _headerView.bounds.size.height - offsetY;
}

- (CGFloat)preferHeaderHeight {
    NSInteger offsetY = self.collectionView.contentOffset.y;
    return _headerView.bounds.size.height - offsetY;
}

- (CGFloat)minContentOffsetY {
    return (self.maxHeaderHeight - self.headerHeight);
}

- (CGFloat)maxContentOffsetY {
    return self.collectionView.contentSize.height - self.collectionView.bounds.size.height;
}

- (CGFloat)detalY {
    return self.collectionView.contentOffset.y - _lastContentOffsetY;
}

+ (void)swizzleMethods:(Class)class originalSelector:(SEL)origSel swizzledSelector:(SEL)swizSel {
    Method origMethod = class_getInstanceMethod(class, origSel);
    Method swizMethod = class_getInstanceMethod(class, swizSel);
     
    //class_addMethod will fail if original method already exists
    BOOL didAddMethod = class_addMethod(class, origSel, method_getImplementation(swizMethod), method_getTypeEncoding(swizMethod));
    if (didAddMethod) {
        class_replaceMethod(class, swizSel, method_getImplementation(origMethod), method_getTypeEncoding(origMethod));
    } else {
        //origMethod and swizMethod already exist
        method_exchangeImplementations(origMethod, swizMethod);
    }
}

@end