由于工作上的需求,需要在 iOS 中做一个类似于嵌套滑动的案例,大致上就是,顶部有一个 Header,下面是一个 List,然后 Header 会随着 List 的滑动进行展开或收起。这种功能在 Android 中倒是挺常见,直接使用 Material 相关的一些组件就能完成,之前也多次使用过,但是要在 iOS 上做这么一个功能,之前倒没想过该怎么做。
开发的第一步,先 google 一下,看了一圈,看到了几种方案,但总体感觉都不是特别好,大概这几种:
- 将 Header 作为 CollectionView 的第一个 Section Header,然后将其设置为 Header 常驻;这种方式对原来的运行方式入侵较小,但同样限制也比较多,比如如果 CollectionView 本身也需要使用 Section Header 展示信息的时候,就不能单独控制哪个 Header 常驻,另外也无法做到让 Header 只隐藏一部分的状态
- 手势穿透,使内外层 ScrollView 都能接收手势事件,然后自己通过状态控制二者是否需要响应事件;这种方式需要重写 delegate 的 didScroll 来控制其是否需要响应事件,理论上各种能力都可以做到,但是需要同时控制两个 ScrollView 的状态,代码会比较复杂
- 重新定义一个手势检测器,不使用 ScrollView 自带的手势能力;这种就直接外部控制内外层是否滑动,ScrollView 自己是接收不到事件的,就无法使用到 ScrollView 内置的一些能力(如滚动条),但是同样,可定制化程度会比较高
以上方案中,第一种最简单,但满足不了我想要的效果,另外两种应该可以满足,但实现起来会比较复杂,而且对于这种比较定制化的滑动效果,可能必须要自己写一套代码,有需要才能随时改,别人的方案不好复用。
最开始我就是采用了网上的第二种方案,但需求一旦有所改动,就得自己想办法看怎么调整现有的代码,这其实相比于自己重新写一套代码更难,所以最后,我结合了解到的以上几种方案和我们的需求,通过一种比较折中的方式来实现:
- 以 CollectionView 为基础,通过 delegate 监听滑动事件,不改变 CollectionView 的滑动效果,delegate 监听只用于控制 Header 位置
- Header 作为 CollectionView 的 subview,嵌入到 CollectionView 中,然后通过 delegate 控制 Header 的 frame
- CollectionView 第一个 Section 空出来,以此来营造列表在 Header 底部的假象
所以,具体的实现方案为:
- 新建一个 delegate 类 BEHeaderScrollViewDelegate,内部实现 didScroll 等方法,实现对 Header 位置的控制
- 使原先的 delegate 继承这个 delegate,然后在其中调用 super 的相关方法
- 对于 IndexPath 相关的方法,都跳过一个 Section,且使第一个 Section 的高度与 Header 一致
这个方案的好处就在于,在整个过程中只有一个滑动事件,而且是系统的 ScrollView 处理好的,只需要在 delegate 中根据滑动的距离,来制定 Header 当前应该所处的状态即可。
但是,需要留出一个 Section 给 Header ,这会引发一系列的反应,即所有使用到 IndexPath 的地方,都需要判断它是否会受到影响。同时还要考虑对代码的入侵性,从上述功能实现来看,这种方案需要改动 CollectionView 的 Delegate 的代码,包括 datasource 和 delegate 都需要改动,这就意味着代码入侵性比较高,需要采取一定方式尽可能地减少入侵,开发起来才会更加顺畅,最好是让原先的 Delegate 感知不到有代码变动。
所以如果要降低入侵性,要能够做到这两件事:
- 数据绑定相关,原先的 delegate 不需要关心这个多出来的 Section,还能够按照正常的 IndexPath 进行数据绑定操作
- 滑动事件相关,原先的 delegate 不需要调用 super didScroll
由此想到,可以使用 objc 的 method swizzled 方法,将 UICollectionViewDelegate 的函数,与BEHeaderScrollViewDelegate 私有方法做交换,于是便可以使,系统不会直接调用原 delegate 的方法,而是先调用 BEHeaderScrollViewDelegate 的私有方法,在它的私有方法中,先处理滑动相关的逻辑,再调用原本的 delegate 方法,这么做的一个主要目的,可以认为是调换父子类关系,原本应该是由原先的 delegate 继承 BEHeaderScrollViewDelegate 的,所以系统会先调用 delegate 的方法,而现在则变成先调用 BEHeaderScrollViewDelegate 方法,再调用 delegate 方法,这就给予了 BEHeaderScrollViewDelegate 充分的自由度且不会入侵原先的 delegate 实现,具体做法为:
- 数据绑定相关的方法,判断当 sectoin 为 0 时,表示这是一个预留 section,执行自己的操作;并在调用原本的实现方法时,将传过去的 IndexPath section 做 -1 处理,这样一来,原本的 delegate 实现便可以不关心预留 section 这件事,相关的方法都无需改动
- 滑动事件相关的方法,先做自己的操作,然后调用原本的实现方法,这样做的主要目的就是避免子类再额外调用 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