自己动手实现一个简单的 UITableView

iOS Sep 18, 2016

UITableView 是 iOS 开发中常用的一个控件,可惜他默认不支持横向滚动(其实是支持的,给 tableView 和 cell 的 contentView 分别 setTransform 二分之 M_PI即可)

但是为了更方便的定制里面的 cell,还是来手动实现一个横向滚动的 UITableView 吧,这次我们要实现一个跑马灯效果的横向滚动的 tableView,所以暂时取名叫 BayCarouselView

先分享结论,实现的过程中发现一些比较好玩的问题,比如

  1. 当回收某个 view 的时候,用 removeFromSuperView,会造成页面卡糊糊的,重用某个 view 的时候,addSubview 方法也是卡糊糊的
  2. 所以我们用 hidden 属性来代替这两个方法,CPU 占用和内存占用确实有大幅下降
  3. scrollViewDidScroll: 方法随着滑动的速度加快,调用频次是越来越低,所以,如果想在这个回调方法里面,根据 contentOffset 来做一些判断,就不太容易了。即使在滑动结束再做一次运算抢救一下,好像还是有点问题,也有可能我代码写的有问题
  4. 相似的,虽然我没实现 delete 和 insert 操作,即使要 delete 某个 cell,最好用 hidden,不要用 removeFromSuperView
  5. 具体的实现就是在 alloc init 的时候 addSubview 从此之后只用 hidden 属性来控制 cell 的显示与否
  6. 这样可能会造成一些问题,比如如果后面的 cell 的 size 很大,实际上 reusableViewPool 里面有很多 object 是再也没有出头之日了,这个问题的话,UITableView 现在也是这么实现的,如果想测试可以在 cell 里面写上 dealloc 方法看看什么时候调用
  7. UIScrollView 里面默认有两个 UIImageView 这是滚动条的 image,想要去除也很简单,加上 _scrollView.showsVerticalScrollIndicator = NO_scrollView.showsHorizontalScrollIndicator = NO

实现的过程不是那么重要,本身并不难,更主要的还是通过学习苹果自己的 Cocoa 框架,模仿他们规范的写法,比如 Delegate Protocol 方法名的规范,如果这个方法不需要参数,一般这样写:

- (NSInteger)numberOfRowInCarouselView:(BayCarouselView *)carouselView;

如果需要参数,一般会这样写:

- (void)carouselView:(BayCarouselView *)carouselView didEndDisplayView:(BayCarouselItemView *)view forRowAtIndex:(NSInteger)index;

- (void)carouselView:(BayCarouselView *)carouselView didSelectRowAtIndex:(NSInteger)index;

其次,我们还发现了一个小细节,在使用 tableView 的时候,相信大家对 dequeueReusableCellWithIdentifier 这个方法不陌生,这个方法的返回值类型,也是有一点细节在里面

- (__kindof BayCarouselItemView *)dequeueReusableView;

相信细心的同学已经发现,前面有一个 __kindof 修饰符,__kindof 是在 WWDC 2015 上提出来的,加上 __kindof 修饰符,就不再需要强制转换类型了,最典型的例子就是 UITableViewCell,而且加上这个修饰符,可以在某些场景下消除一些不必要的警告

delegate 和 dataSource 使用 weak __nullable 两个修饰符相信大家已经不陌生了

如果有人看过苹果自己的头文件,不难发现文件头尾分别有一个

NS_ASSUME_NONNULL_BEGIN

...

NS_ASSUME_NONNULL_END

关于 NS_ASSUME_NONNULL_BEGINNS_ASSUME_NONNULL_END,这一对儿宏也是在 WWDC 2015 提出的,就不再需要开发者写一大堆 nonnull

To ease adoption of the new annotations, you can mark certain regions of your Objective-C header files as audited for nullability. Within these regions, any simple pointer type will be assumed to be nonnull.  -- See Nullability and Objective-C - https://developer.apple.com/swift/blog/?id=25

大家在日常里面也没少用到 registerClass dequeueReusableView 这些方法,我实现的方法比较简单粗暴,下面是代码

@property (nonatomic, strong) NSMutableSet *reusableViewPool;
@property (nonatomic, strong) Class registerClass;

- (void)registerClass:(Class)viewClass {
    self.registerClass = viewClass;
}

- (BayCarouselItemView *)dequeueReusableView {
    if (self.reusableViewPool.count > 0) {
        BayCarouselItemView *view = [self.reusableViewPool anyObject];
        view.transform = CGAffineTransformIdentity;
        [self.reusableViewPool removeObject:view];
        return view;
    } else {
        BayCarouselItemView *view = [[self.registerClass alloc] init];
        [self.scrollView addSubview:view];
        return view;
    }
}

- (void)queueReusableView:(BayCarouselItemView *)view {
    [self.reusableViewPool addObject:view];
}

最后是如何判断哪一个 cell 应该隐藏,哪一个应该显示,具体实现在 scrollViewDidScroll: 方法

我用的方法比较简单,如果有更好的算法,欢迎推荐给我,感谢

判断哪个 view 越界,并且将它 hidden 掉

    for (BayCarouselItemView *view in scrollView.subviews) {
        if ([view isKindOfClass:self.registerClass]) {
            if (CGRectGetMaxX(view.frame) < minX - padding * 2 || CGRectGetMinX(view.frame) > maxX + padding * 2) {
                
                if (!view.hidden) {
                    [self queueReusableView:view];
#ifdef DEBUG
                    //NSLog(@"remove %ld", view.itemIndex);
#endif
                    view.hidden = YES;
                    if ([self.delegate respondsToSelector:@selector(carouselView:didEndDisplayView:forRowAtIndex:)]) {
                        [self.delegate carouselView:self didEndDisplayView:view forRowAtIndex:view.itemIndex];
                    }
                }
            }
        }
    }

判断哪个地方有空缺,并且给它加上去

    NSArray *subviewsArray = [self.scrollView.subviews sortedArrayUsingComparator:^NSComparisonResult(BayCarouselItemView *obj1, BayCarouselItemView *obj2) {
        
        return [[NSNumber numberWithFloat:obj1.frame.origin.x] compare:[NSNumber numberWithFloat:obj2.frame.origin.x]];
    }];
    
    BayCarouselItemView *firstView;
    for (NSInteger i = 0; i < subviewsArray.count; i++) {
        if ([subviewsArray[i] isKindOfClass:self.registerClass]) {
            if (!((BayCarouselItemView *)(subviewsArray[i])).hidden) {
                firstView = subviewsArray[i];
                break;
            }
        }
    }
    
    BayCarouselItemView *lastView;
    for (NSInteger j = subviewsArray.count - 1; j >= 0; j--) {
        if ([subviewsArray[j] isKindOfClass:self.registerClass]) {
            if (!((BayCarouselItemView *)(subviewsArray[j])).hidden) {
                lastView = subviewsArray[j];
                break;
            }
        }
    }

    // 如果符合条件就生成右边的 view
    if (lastView && CGRectGetMaxX(lastView.frame) < maxX + padding - self.cardInset) {
        
        self.currentIndex = lastView.itemIndex;
        if (lastView.itemIndex + 1 < self.numberOfRows) {
            [self generateViewWithIndex:lastView.itemIndex + 1];
        }
    }
    
    // 如果符合条件就生成左边的 view
    if (firstView && CGRectGetMinX(firstView.frame) > minX - padding + self.cardInset) {
        
        self.currentIndex = firstView.itemIndex;
        if (firstView.itemIndex > 0) {
            [self generateViewWithIndex:firstView.itemIndex - 1];
        }
    }
    
    // 如果一个 subview 都没有,执行类似 reloadData 的操作
    if (!firstView && !lastView) {
        self.currentIndex = scrollView.contentOffset.x / self.rowWidth;
        [self renderCurrentView];
    }

接下来是大家最喜闻乐见的源代码了:点我可见

Tags

Jie Li

🚘 On-road / 📉 US Stock / 💻 Full Stack Engineer / ®️ ENTJ