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

最后实现的效果如下图:

源码地址为:GWTableViewIndexBar
笔者认为要设计和实现一个功能或者库,首先不必关注实现细节,先分析清楚需求,然后做总体规划和设计。
需求分析
参照QQ音乐的索引栏,一个自定义的索引栏有以下需求:
- 大小和位置、背景颜色支持自定义
- 支持配置分组数超过一个阈值才显示
- 支持配置常驻显示或滚动出现停止滚动消失两种风格
- 索引标题字体和颜色支持自定义
- 支持配置索引标题高亮颜色
- 显示和隐藏索引栏动画
- 指示器样式支持自定义
- 默认字体和颜色
- 高亮字体和颜色
- 代理方法,当用户点击索引栏时通知代理 分析清楚需求后,我们可以开始技术选型,这个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
如代码所示定义了字体、颜色、高亮色、是否圆角等供调用方灵活配置,同时提供常驻显示和滚动显示隐藏两种风格应对不同的需求,调用方可以配置索引的最小显示数量阈值,这些属性都有默认值,当调用方不需要定制时,直接使用即可,只需要配置大小位置和数据源数据即可。
实现
笔者建议,针对需求先有大概的设计实现方案,不要急于实现,设计好后再开始编码实现,需求分析和接口设计做好了,代码实现自然会水到渠成。 主要实现难点和思路如下:
- UI方面使用Label来显示索引标题,并能进行动态的刷新,索引数量改变或选中高亮等
- 交互方面通过用户的触摸点计算出当前选中的索引,进行索引标题高亮和滚动绑定的TableView到对应的section
- 监听绑定的TableView的滚动,获取到当前滚动到的section,高亮相应的索引标题
- 使用滚动显示风格时,监听TableView的滚动,开始滚动执行索引栏显示动画,停止滚动延迟执行索引栏隐藏动画
- 用户手动操作索引栏时,执行索引栏指示器显示动画,操作完毕延迟执行指示器隐藏动画
首先重写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动画,这里就不贴代码了,读者有兴趣可以直接看源码,其中只需要注意延迟执行隐藏动画,并在显示前取消隐藏操作即可。
欢迎大家提出问题沟通交流,谢谢!