自己动手实现一个简单的 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 Developer / 🎓 Grad Student / ®️ ENTJ

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.