自定义TableView索引栏

3,193 阅读10分钟

iOS中UITableView是经常使用的控件,当数据量大时,为了方便用户快速查看和检索数据,通常会使用分组和索引栏,而系统的索引栏样式不支持自定义,因此参考QQ音乐的索引栏,结合项目中的使用情况,自定义了一个TableView的索引栏库

QQ音乐的TableView的索引栏的样式如下图:

最后实现的效果如下图:

源码地址为:GWTableViewIndexBar

笔者认为要设计和实现一个功能或者库,首先不必关注实现细节,先分析清楚需求,然后做总体规划和设计。

需求分析

参照QQ音乐的索引栏,一个自定义的索引栏有以下需求:

  1. 大小和位置、背景颜色支持自定义
  2. 支持配置分组数超过一个阈值才显示
  3. 支持配置常驻显示或滚动出现停止滚动消失两种风格
  4. 索引标题字体和颜色支持自定义
  5. 支持配置索引标题高亮颜色
  6. 显示和隐藏索引栏动画
  7. 指示器样式支持自定义
  8. 默认字体和颜色
  9. 高亮字体和颜色
  10. 代理方法,当用户点击索引栏时通知代理 分析清楚需求后,我们可以开始技术选型,这个UI框架组成比较单一,主要考虑显示和交互两方面。首先很容易想到用TableView来实现这个UI显示,但是仔细一想发现交互上不能满足,TableView主要有点选和滑动两种交互,但是显然索引栏即能点选有能通过手指滑动触发交互,这个跟TableView的滑动滚动内容有冲突,而且这里事件处理直接控件自己处理,因此直接选用UIView。

接口设计

根据需求分析和技术选型,在封装过程中应保证调用方能灵活配置的同时使用简单,设计成和正常的UIView操作一样,提供配置方法,和TableView绑定后,不需要关心索引切换和TableView滑动。接口文件设计如下:

/**
 索引栏显示方式

 - kGWTableViewIndexBarPermanentShowStyle: 常驻显示
 - kGWTableViewIndexBarScrollShowStyle: 开始滚动显示,滚动停止消失
 */
typedef NS_ENUM(NSInteger, kGWTableViewIndexBarShowStyle){
    kGWTableViewIndexBarPermanentShowStyle,
    kGWTableViewIndexBarScrollShowStyle
};
@interface GWTableViewIndexBar : UIView
/*! @brief 代理 */
@property(nonatomic, weak) id<GWTableViewIndexBarDelegate> delegate;
/*! @brief tableView */
@property(nonatomic, weak) UITableView *tableView;
/*! @brief 显示的最小section值的数量,默认10 */
@property(nonatomic, assign) NSInteger minimumShowCount;
/*! @brief 显示风格, 默认常驻显示 */
@property(nonatomic, assign) kGWTableViewIndexBarShowStyle showStyle;
/*! @brief 索引文字正常颜色,默认黑色 */
@property(nonatomic, strong) UIColor *normalTitleColor;
/*! @brief 索引文字正常字体,默认系统16号字体 */
@property(nonatomic, strong) UIFont *titleFont;
/*! @brief 索引文字高亮颜色 */
@property(nonatomic, strong) UIColor *highlighTitletColor;
/*! @brief 内间距,主要用来设置上下的间距,做圆角 */
@property(nonatomic, assign) UIEdgeInsets contentInset;
/*! @brief 单个索引的高度 */
@property(nonatomic, assign) CGFloat titleHeight;
/*! @brief 是否圆角,默认NO */
@property(nonatomic, assign) BOOL isCorner;
/*! @brief 索引文字高亮字体,默认同normalColor*/
//@property(nonatomic, strong) UIFont *highlightFont;//暂时未实现高亮字体大小和常规字体不同,会引起高度变化影响到布局
/*! @brief 索引文字数组 */
@property(nonatomic, strong) NSArray<NSString *> *indexTitlesArray;
@end

如代码所示定义了字体、颜色、高亮色、是否圆角等供调用方灵活配置,同时提供常驻显示和滚动显示隐藏两种风格应对不同的需求,调用方可以配置索引的最小显示数量阈值,这些属性都有默认值,当调用方不需要定制时,直接使用即可,只需要配置大小位置和数据源数据即可。

实现

笔者建议,针对需求先有大概的设计实现方案,不要急于实现,设计好后再开始编码实现,需求分析和接口设计做好了,代码实现自然会水到渠成。 主要实现难点和思路如下:

  1. UI方面使用Label来显示索引标题,并能进行动态的刷新,索引数量改变或选中高亮等
  2. 交互方面通过用户的触摸点计算出当前选中的索引,进行索引标题高亮和滚动绑定的TableView到对应的section
  3. 监听绑定的TableView的滚动,获取到当前滚动到的section,高亮相应的索引标题
  4. 使用滚动显示风格时,监听TableView的滚动,开始滚动执行索引栏显示动画,停止滚动延迟执行索引栏隐藏动画
  5. 用户手动操作索引栏时,执行索引栏指示器显示动画,操作完毕延迟执行指示器隐藏动画

首先重写UIView的init和initWithFrame方法,进行属性的基本初始化和指示器子View的初始化工作,指示器用一张背景图和Label实现。

#pragma mark -- init
- (instancetype)init{
    if (self = [super init]) {
        //初始化属性默认值
        [self _initProperties];
        //初始化子视图
        [self _initViews];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame{
    if (self = [super initWithFrame:frame]) {
        //初始化属性默认值
        [self _initProperties];
        //初始化子视图
        [self _initViews];
    }
    return self;
}
/**
 初始化默认属性
 */
- (void)_initProperties{
    _minimumShowCount = 10;//默认最小显示个数为10
    _showStyle = kGWTableViewIndexBarPermanentShowStyle;//默认是常驻显示风格
    _normalTitleColor = [UIColor blackColor];//默认是索引标题黑色
    _highlighTitletColor = _normalTitleColor;//默认是索引高亮色和正常色一样
    _titleFont = [UIFont systemFontOfSize:kDefaultTitleFontSize];//默认是索引正常字体13.0
    _titleLabelArray = [NSMutableArray arrayWithCapacity:_minimumShowCount];//默认是数组容器大小为最小显示阈值
    _titleHeight = kDefaultTitleHeight;//默认是索引标题高度20
    _contentInset = UIEdgeInsetsZero;//默认上下左右内间距为0
}

/**
 初始化子视图
 */
- (void)_initViews{
    //初始化索引指引背景视图
    UIImage *image = [UIImage imageNamed:@"bg_retrieving_letter"];
    _indexTitleBgView = [[UIImageView alloc] initWithImage:image];
    _indexTitleBgView.backgroundColor = [UIColor clearColor];
    //初始化索引指引标题视图
    _indexTitleLabel = [[UILabel alloc] init];
    _indexTitleLabel.textAlignment = NSTextAlignmentCenter;
    _indexTitleLabel.textColor = [UIColor whiteColor];
    _indexTitleLabel.font = [UIFont systemFontOfSize:kDefaultIndexFontSize];
    _indexTitleLabel.backgroundColor = [UIColor clearColor];
    [_indexTitleBgView addSubview:_indexTitleLabel];
}

然后封装数据刷新方法,当索引栏数组变化时刷新,这里笔者利用数组来做索引标题的缓存,避免重复销毁和创建,同时也方便通过index操作对应的Label,当然其中也做了最小数据量显示阈值的操作。

#pragma mark -- 刷新视图
- (void)reloadData{
    NSInteger count = _indexTitlesArray.count;
    //先将所有label从视图移出
    for (UILabel *label in _titleLabelArray) {
        [label removeFromSuperview];
    }
    //如果当前tableview可见cell的indexpath数据不为空,初始化当前选中的index为第一个可见indexpath的section
    if (self.tableView.indexPathsForVisibleRows.count > 0) {
        self.currentIndex = self.tableView.indexPathsForVisibleRows[0].section;
    }
    //如果索引数组大于最小显示个数则创建Label并显示
    if (count >= _minimumShowCount) {
        for (NSInteger i = 0; i < count; i++) {
            NSString *title = _indexTitlesArray[i];
            UILabel *label;
            //如果i小于保存的label数组,则直接从数据中取label即可,不需要重新创建,节省性能,否则才重新创建,并保存到数组中
            if (i < _titleLabelArray.count) {
                label = _titleLabelArray[i];
            }else{
                label = [[UILabel alloc] init];
                [_titleLabelArray addObject:label];
            }
            if (i == self.currentIndex) {
                label.textColor = _highlighTitletColor;
            }else{
                label.textColor = _normalTitleColor;
            }
            label.font = _titleFont;
            label.textAlignment = NSTextAlignmentCenter;
            label.lineBreakMode = NSLineBreakByClipping;
            label.text = title;
            [self addSubview:label];
        }
        //如果保存的label比需要的多,删除多余的lable
        if (_titleLabelArray.count > count) {
            [_titleLabelArray removeObjectsInRange:NSMakeRange(count, _titleLabelArray.count - count)];
        }
        if (kGWTableViewIndexBarPermanentShowStyle == _showStyle) {
            self.hidden = NO;
        }else{
            self.hidden = YES;
        }
    }else{
        self.hidden = YES;
    }
}
#pragma mark -- setter &  getter
- (void)setIndexTitlesArray:(NSArray<NSString *> *)indexTitlesArray{
    _indexTitlesArray = indexTitlesArray;
    [self reloadData];
}

由于self的frame在layoutSubView方法时才唯一确定,因此索引标题的布局放在该方法中实现,如果在init方法或者reload方法可能出现不准确的情况,这也是笔者觉得自定义View时可以注意的地方。根据数据源数组长度计算每一个Label的frame,并且更新索引栏的高度,保持居中显示,还会根据是否切圆角属性进行切圆角。

#pragma mark -- 布局
- (void)layoutSubviews{
    [super layoutSubviews];
    //根据索引数量修改self的高度,并保持self居中显示
    CGPoint center = self.center;
    CGRect rect = self.frame;
    rect.size.height = self.titleHeight * self.titleLabelArray.count + self.contentInset.top + self.contentInset.bottom;
    self.frame = rect;
    self.center = center;
    //是否切圆角
    if (self.isCorner) {
        //设置圆角
        UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:self.bounds byRoundingCorners:UIRectCornerAllCorners cornerRadii:self.bounds.size];
        CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
        //设置大小
        maskLayer.frame = self.bounds;
        //设置图形样子
        maskLayer.path = maskPath.CGPath;
        self.layer.mask = maskLayer;
    }
    //初始化索引Label的frame相关参数
    CGFloat width = CGRectGetWidth(self.frame);
    for (NSInteger i = 0; i < self.titleLabelArray.count; i++) {
        UILabel *label = self.titleLabelArray[i];
        CGRect frame = CGRectMake(0, i * self.titleHeight + self.contentInset.top, width, self.titleHeight);
        label.frame  = frame;
    }
    //初始化索引指引背景视图的frame相关参数
    CGRect indexFrame = self.indexTitleBgView.frame;
    CGFloat indexWidth = CGRectGetWidth(indexFrame);
    CGFloat indexHeight = CGRectGetHeight(indexFrame);
    indexFrame.origin.x = CGRectGetMinX(self.frame) - indexWidth;
    self.indexTitleBgView.frame = indexFrame;
    //初始化指引label的frame相关参数
    self.indexTitleLabel.frame = CGRectMake(0, 0, indexWidth * 0.5, indexHeight * 0.5);
    self.indexTitleLabel.center  = CGPointMake(indexWidth * 0.5, indexHeight * 0.5);
}

到这里,其实UI层已经基本实现了,该控件UI其实比较简单,接下来是交互部分。交互主要是通过重写touchesBegan和touchesMove方法,获取到用户触摸到的点,然后计算出当前用户选中的索引栏标题。

#pragma mark -- events
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesBegan:touches withEvent:event];
    [self handleTouches:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [super touchesMoved:touches withEvent:event];
    [self handleTouches:touches withEvent:event];
}

/**
 处理触摸事件

 @param touches touch数组
 @param event   event
 */
- (void)handleTouches:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    //获取触摸点位置
    UITouch *touch = [touches anyObject];
    CGPoint touchPoint = [touch locationInView:self];
    if (touchPoint.x < 0) return;
    //通过触摸的位置计算出选中的索引
    NSInteger index = (touchPoint.y - self.contentInset.top) / (long)self.titleHeight;
    if (index < 0) {
        index = 0;
    }else if (index >= self.indexTitlesArray.count){
        index = self.indexTitlesArray.count - 1;
    }
    [self didSelectRowIndex:index byTouch:YES];
}

关键方法是处理用户选中操作,这里封装了一个方法didSelectRowIndex,首先直接通过index获取到索引标题Label,设置高亮颜色,然后index转换成indexPath,执行绑定TableView的滚动,通过Label的中心Y值修改指示器视图位置并通知代理即可。为了避免用户触摸同一个索引标题范围反复执行的问题,保存了当前选中的currentIndex,比较不同才执行。

/**
 选中索引操作
 @param index   索引的位置
 @param isTouch 是否是触摸触发
 */
- (void)didSelectRowIndex:(NSInteger)index byTouch:(BOOL)isTouch{
    //如果选中的索引超出标题数组,或者和当前选中的相同则直接return
    if (index >= self.titleLabelArray.count || index < 0 || index == self.currentIndex) {
        return;
    }
    if (self.currentIndex != index) {
        if (kGWTableViewIndexBarScrollShowStyle == self.showStyle) {
            [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(dismissIndexBar) object:nil];
            [self performSelector:@selector(dismissIndexBar) withObject:nil afterDelay:1.0];
        }
        //保存当前选中的index
        self.currentIndex = index;
        //如果正常状态颜色和高亮颜色则循环遍历设置选中的index为高亮颜色
        if (![self.normalTitleColor isEqual:self.highlighTitletColor]) {
            for (NSInteger i = 0; i < self.titleLabelArray.count; i++) {
                UILabel *label = self.titleLabelArray[i];
                if (index == i) {
                    label.textColor = self.highlighTitletColor;
                }else{
                    label.textColor = self.normalTitleColor;
                }
            }
        }
        //如果是用户触摸indexBar触发选中索引则滚动tableView并通知代理
        if (isTouch) {
            //显示指示器
            UILabel *label = self.titleLabelArray[index];
            NSString *title = self.indexTitlesArray[index];
            [self showIndecatorWithTitle:title andcenterY:(CGRectGetMidY(label.frame) + CGRectGetMinY(self.frame))];
            //如果当前选中的index在tablview的section范围内,则滚动tableview到相应位置
            if (index < self.tableView.numberOfSections) {
                NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:index];
                [self.tableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionTop animated:YES];
            }
            //通知代理
            if([self.delegate respondsToSelector:@selector(tableViewIndexBar:didSelectRowAtIndex:)]){
                [self.delegate tableViewIndexBar:self didSelectRowAtIndex:index];
            }
        }
    }
}

另一个交互是监听绑定的TableView的滚动执行相应的选中索引操作和显示隐藏索引栏操作,如何监听滚动和停止滚动笔者只找到通过UIScrollView的代理方法,笔者开始想索引栏内部监听,但是想了通过KVO监听contentOffset,这时候无法监听到TableView的停止滚动。在UIScrollView的头文件中找到dragging属性能标识滚动和停止,但是由于该属性是readonly的无法通过KVO监听。笔者暂时未找到不通过UIScrollView的代理方法监听滚动和停止滚动的方案,而由于代理的唯一性,UITableViewDelegate继承自UIScrollViewDelegate,使用索引栏替换代理再传递的方法也不太合适。因此最后只能采用调用方实现UIScrollViewDelegate通知索引栏的方式监听滚动和停止滚动这种不优雅的方式,如果读者有好的方式请不吝指导。 因此索引栏暴露三个方法需要调用方调用,其中通过获取绑定的TableView获取到当前可见的cell的indexpath的第一个获取到section,即可获取到当前需要高亮的index。

#pragma mark -- scroll
- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    [self tableViewDidScroll];
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView{
    [self tableViewDidEndScroll];
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    if (decelerate == NO) {
        [self scrollViewDidEndDecelerating:scrollView];
    }
}

/**
 tableView滚动
 */
- (void)tableViewDidScroll{
    //开始滚动,首先判断索引栏是常驻显示还是
    //计算出当前滚动到的section
    NSInteger index = self.tableView.indexPathsForVisibleRows[0].section;
    [self didSelectRowIndex:index byTouch:NO];
    //如果是滚动显示并且当前是隐藏状态
    if (kGWTableViewIndexBarScrollShowStyle == self.showStyle && self.hidden && self.indexTitlesArray.count >= self.minimumShowCount) {
        [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(dismissIndexBar) object:nil];
        [self showIndexBar];
    }
}
/**
 tableView停止滚动
 */
- (void)tableViewDidEndScroll{
    if (kGWTableViewIndexBarScrollShowStyle == self.showStyle && !self.hidden) {
        [self performSelector:@selector(dismissIndexBar) withObject:nil afterDelay:1.0];
    }
}


实现完上面的交互后,该索引栏基本已经实现完了。显示隐藏索引栏和显示隐藏指示器视图只是最简单的透明度UIView动画,这里就不贴代码了,读者有兴趣可以直接看源码,其中只需要注意延迟执行隐藏动画,并在显示前取消隐藏操作即可。

欢迎大家提出问题沟通交流,谢谢!