Feb 17

【Lrdcq的iOS入门 DAY3】TableView!!!

Lrdcq , 2016/02/17 22:32 , 教程 , 閱讀(3497) , Via 本站原創
Attention:本教程,不,其实是我学习review笔记,主要目的是我自己将我近一个月以来学习ios的姿势与方式用循序渐进的方式重新整理与归纳,并总结为阶梯性的知识点。教程意义不大,多半是给自己看的,所以看到我胡扯乱弹琴或者老是忘事儿请不要太在意。
本教程所有第0章节都是废话,有基础者可以跳过的,请务必注意。


TableView与重用 - 最最最基本UITableView - 自定义cell - 不定高cell - 基本列表操作



step0.TableView与重用

无论是在安卓还是ios还是其他手机操作系统中,最为一个功能性的app,最常见的控件,肯定是列表了。仔细想想,qq的主要控件也不就是个列表么,网易云音乐除了播放界面也不就个列表么,谷歌也不就是个列表么——当然,我说的是界面。

在安卓中常用的列表控件大家都知道,是ListView和升级版RecyclerView,而在iOS中,即与之对应的UITableView和UICollectionView。比较起来的话,UITableView是RecyclerView的竖屏定制版,而UICollectionView几乎等于RecyclerView。当然由于ios深度定制的系统样式,UITableView用系统的那一套远比安卓的好用。

当然,无论是安卓还是ios,在app上的list中有一个很关键的概念,叫重用(重新使用)。它是指的每一列,当他离开屏幕后,这个对象将会在接下来新出现列里重新使用,这样的话无论多长的列表,程序只需要保留比一屏列表长度稍微长的列对象就可以了,这样不但大大节约了内存,也减少了快速滑动列表时新建对象的开销。

显然,使用了重用机制,list构成带来一定的复杂性。最主要的问题时由于每一列界面对象重用,那么就无法用界面保留数据,每一次开关选择,文本编辑,状态改变,都不能光停留在界面修改上而得实事反应在数据中,并且得在列重用时及时把旧数据清空回滚到初始状态并应用上新的数据。对于较为复杂的界面来说,这将是一个巨大的挑战。

那么UITableView到底有些什么看起来不错的功能呢?看看ios的设置面板就知道了,它的数据源可以是2维的,常见的分Section,每一个栏目下再有无数的Row作为一列。除了每个Row,每个Section可以设置header,整个列表也可以设置header和footer。每一个Row可以有RecyclerView那样的添加删除替换操作,还自带非常漂亮的动画,这一些功能,也算是大大优越于安卓了。

setp1.最最最基本UITableView

那么,非常简单的在vc的view中初始化一个全屏的tableview,简单的就这样:
UITableView *_dataTable;
[self.view addSubview:({
    _dataTable = [[UITableView alloc] init];
    [_dataTable setDelegate:self];
    [_dataTable setDataSource:self];
    [_dataTable setSeparatorStyle:UITableViewCellSeparatorStyleNone];
    _dataTable;
})];
[_dataTable mas_makeConstraints:^(MASConstraintMaker *make) {
    make.edges.with.insets(UIEdgeInsetsZero);
}];

诶,UITableView有两个delegate,一个是界面相关的代理UITableViewDelegate,一个是列表的数据来源的代理UITableViewDataSource。这两个东西放到安卓里边,当然就是类比于大家熟悉的adapter了。那么,我们来看看代理中,我们一般要实现的方法。首先是UITableViewDelegate,界面控制的方法均为可选的,其中我们最常见的只有一个,即给出Row的高度:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
  return 44;
}

不给的话每个Row就是ios的默认高度,挺小的,另外由于ios的布局缺乏动态扩展布局(安卓中的warp元素),因此此处很大一个作用是动态设置Row的高度。另外有可能用到的是:
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
    //do somethoing
}

可以响应每一个Row的点击事件。在每一个Row中并不需要复杂的界面处理,只需要简单的响应点击事件的情况的话,用这个处理就可以了。然后,是UITableViewDataSource,这个就比较重要了,假设我们已经有一串数据放在一个NSArray中,首先我们必须要实现的是列表行数:
-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return [dataArray count];
}
-(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

根据这到底是哪一个Section返回到底是哪个数组并且多长。一般来说列表只有一个Section的话,就像上面这样直接把array的count直接给返回就可以了。而且如果只有一个Section的话也不用重写numberOfSectionsInTableView了,它默认就是1了.通过这两个数据,可以确定列表到底有多少列了,最后就是处理列表的每一列。每一列的对象其实是一个UITableViewCell对象它虽然继承于UIView,却是一个对cell的封装,集成了系统list很多复杂的功能接口。在我们这个程序里,一个最简单的使用cell的范例是这样的:
//首先,都说了row也就是cell对象会重用,所以ios自带了一个重用器,UITableViewCell的class注册上去。重用器可以在cell对象不够时初始化cell,有回收掉的cell时重用cell
[_dataTable registerClass:UITableViewCell.class forCellReuseIdentifier:TableSampleIdentifier];

//然后实现方法
-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:TableSampleIdentifier];
    cell.textLabel.text = dataArray[indexPath.row];
    cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
    return cell;
}

cellForRowAtIndexPath可以说是整个UITableView中最重要的方法了,它的职责是:新建/从重用器中获得一个cell,并且向cell中准备数据,并返回出来。它直接关系到我们的cell的呈现内容。在我们这个方法中,我们从重用器中获得了一个标准的系统自带cell对象,一个UITableViewCell中已经准备好了一个坑——label,我们向其中填入我们要的数据。然后常用的属性cell.accessoryType,是每一列右侧图标,这里是设置的最常见的那个向右的小箭头>,也就是一般界面中的前进图标嘛。当然,每一列的右侧除了放置图标,比如设置菜单里很多右边就是一个开关UISwitch,那么我们就不需要设置accessoryType而是直接设置accessoryView:
    UISwitch *_switch;
    if (!cell.accessoryView){
        _switch = [[UISwitch alloc] initWithFrame:CGRectMake(0, 0, 50, 40)];
        [_switch addTarget:self action:@selector(dealSwitch:) forControlEvents:UIControlEventValueChanged];
        cell.accessoryView =_switch;
    }else{
        _switch = (UISwitch *)cell.accessoryView;
    }
    _switch.tag = indexPath.row;
    [_switch setOn:NO];//set selected from data
//同时务必编写响应方法
-(void)dealSwitch:(UISwitch *)view{
    NSInteger row = view.tag;
    //apply change to data
}

由于重用的原因,每次修改都需要把修改结果返回给数据,每次初始化row的控件状态都得从数据里面读取(上面那段代码并没有写,而是用注释把坑留下来了),否则会出现经典的界面循环bug。总之一般情况下,我们做的是:

1.判断子界面中的元素是否被添加,如果没有,添加上,如果有,取出来使用。
2.从数据源中读取数据,为需要使用子控件设置状态,添加标示。
3.处理子控件事件,通过表示判断并修改数据源的数据。

通过这样的循环,我们可以切实完成在重用机制下cell中自定义界面的添加与状态修改,确保界面与数据一一对应无误。除了这个坑,cell中可以自定义view的地方包含accessoryView,editingAccessoryView,backgroundView,selectedBackgroundView与处于最全局地位的contentView。

无论如何,通过tableview和tableviewcell的基本配置,我们现在已经有了一个功能完整可用的tableview了。

step2.自定义cell

当然,tableviewcell的默认样子就这样,坐标一排字,右侧加些东西...当然这样远远不能满足我们。同时在cellForRowAtIndexPath中对cell进行大量的界面操作似乎也是一件很烦的事儿,代码也不干净。对的,事实上大部分情况,我们要自定义cell时,会继承出一个自定义的cell,干干净净整整洁洁真好。

一般我们把cell继承出来,我们会在initWithStyle中对界面进行初始化,就像在vc的viewDidLoad中那样(从某种角度上,vc和cell都是界面容器功能的控件,确实有很多设计上的相似之处)。因此,我们在vc中撑满一个特定字体的label好了:
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        [self.contentView addSubview:({
            _label = [[UILabel alloc] init];
            _label.textColor = UIColorFromRGB(0xff0000);
            _label.font = [UIFont fontWithName:@"Arial" size:14];
            _label.lineBreakMode = NSLineBreakByWordWrapping;
            _label.numberOfLines = 0;
            _label.textAlignment = NSTextAlignmentCenter;
            _label;
        })];
        [_label mas_makeConstraints:^(MASConstraintMaker *make) {
            make.left.equalTo(self.contentView);
            make.top.equalTo(self.contentView);
            make.width.equalTo(self.contentView);
            make.height.equalTo(self.contentView);
        }];
    }
    return self;
}

这个label的约束是由外面的contentView决定的,而contentView的大小是由heightForRowAtIndexPath决定的,因此我们需要好好的设置这个高度,布局才不会出现问题。同时,我们得给外界提供编辑label文本的方法:
//定义
@property(nonatomic,strong) NSString *text;
//实现
- (void)setText:(NSString *)text{
    _label.text = text;
}

这样的话,我们在tableview的代理中,简单的把自定义的cell的class注册到重用器上,再在适当的时候cell.text=什么什么就可以了。tableview不用关心cell内部到底是怎样的,只需要提供出数据就可以了。另外还有一个值得一重写的方法,是:
- (void)prepareForReuse{
    [super prepareForReuse];
    _label.text = @"";
}

这个方法在cell被回收准备重用的时候被调用,一般用来恢复cell的初始状态(比如如果在view中添加了什么不必要的东西,用来自己清空自己)。当然,这个事儿在哪儿做都可以,不过ios既然留给我们了这个接口,当然就写在这里了。
因此,自定义cell主要要做的事情是:
1.新建时初始化界面的自定义部分
2.提供设置界面需要数据提供内容的部分并改变样式
3.向外部反馈控件的交互信息(自行设计协议)
4.在重用前清楚数据对样式的改变并恢复样式

step3.不定高cell

难道我们的列表每列都是一样高的么?当然,如果我们在heightForRowAtIndexPath就返回了一个数,它当然是一样高的,但如果我们经过对每一列计算各得到了高度抛出,这就可以达到我们的目的了。事实上,新闻内容列表,聊天记录列表,自适应图片列表,很多场景下都会遇到对cell进行高度计算。

对高度的计算,主要使用场景问题集中在变长文本的容纳,也就是上一part中的那个label那样多行的文本框,需要容纳下全部的文本。这种在安卓中只需要一个warp_content就可以做到的事儿,我们这里需要进行计算文本的高度。那么:

方法1.计算文本高度。有方法boundingRectWithSize:options:attributes:context:可以完成,这个方法通过一段给定的文本和给定的文本段落样式和文本宽度,可以计算出需要的CGRect大小。
NSString *text = @"text";
NSAttributedString *attrStr = [[NSAttributedString alloc] initWithString:text];
label.attributedText = attrStr;
NSRange range = NSMakeRange(0, attrStr.length);
NSDictionary *dic = [attrStr attributesAtIndex:0 effectiveRange:&range];
CGSize textSize = [text boundingRectWithSize:label.bounds.size options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:dic context:nil].size;

使用这个方法最大的困难是保持测量文本使用的样式和宽度等辅助信息与实际环境中的信息一致,稍有不同的话测量出的高度显然就会不一样,那就白忙活一场了。通过测量出的label的高度,再手动计算加上考虑的padding,margin,其他元素等乱七八糟的东西,加加减减起来还是挺麻烦的。

方法2.也是最常见的方法,是ios6自动布局带来的systemLayoutSizeFittingSize方法,对于特定的uiview控件,自带了测量自己最合适的大小的方法并且给吐出来,用这个大小就可以啦~
CGFloat height = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;
//

一句话的事儿。这个做法没什么不好的,也就是对于过于复杂的自动布局的约束(大量的鸡生蛋蛋生鸡的相互约束)的情况下性能稍微低了一点。

方法3.到了ios8,又有新技术出现了,是:
_tableView.rowHeight = UITableViewAutomaticDimension;
//

就可以设置row的高度是自动计算的啦。不过它内部依然是自动布局的那套计算方法,约束不对照样gg。

在计算heightForRowAtIndexPath的高度时,无论用上面哪种方法,在列表过长的时候,还是会遇到性能问题,主要有两个。一个是列表刚开始价值时卡顿时间过长,主要原因刚开始价值时需要计算出所有row的高度来显示右侧滑动栏。这个问题的解决方案是设置tableView.estimatedRowHeight = 233;来给出一个猜测值,那么它就只会计算已经显示的区域row高度而不是全部高度了。

还有一个问题是......在滑动过程中还说很卡。仔细dubug一下,就会发现每次加载row时table都会调用heightForRowAtIndexPath来获得高度,如果我们在这个方法内计算高度,而高度一般来说其实是不可变的那么显然就会遇到大量的重复计算。因此,我们需要建立一个缓存机制把已经计算完成的高度数值缓存起来复用。

参看:http://blog.sunnyxx.com/2015/05/17/cell-height-calculation/

step4.基本列表操作

我们的列表不仅用于展示,也不只是点击一个东西干嘛干嘛就ok,还有一种操作,是针对列表自己的操作——虽然使用场景相对少很多(或者说是ios的标准交互功能不足,实际使用场景下并不常见)。

首先是列表编辑功能,把tableview的editing设置为YES就进入了编辑模式,通过canEditRowAtIndexPath代理也可以控制每一个row是否允许编辑。编辑模式打开,我们看到每个item前面多了一个编辑按钮,点击之后右侧出现可以操作的功能...嗯,qq就有使用这个功能。同时还有editingStyleForRowAtIndexPath代理控制是插入操作呢还是删除操作呢,点击右侧菜单后,也是通过代理commitEditingStyle向用户进行反馈。对只是反馈回来,并不会有实际操作,我们得手动调用操作方法。

当然,对列表进行实际增减操作,我们首先得把数据源的array进行修改,然后再对列表显示的相对应位置进行同步的刷新。有一系列方法:
- (void)insertRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths withRowAnimation:(UITableViewRowAnimation)animation;
- (void)deleteRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths withRowAnimation:(UITableViewRowAnimation)animation;
- (void)reloadRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths withRowAnimation:(UITableViewRowAnimation)animation NS_AVAILABLE_IOS(3_0);
- (void)moveRowAtIndexPath:(NSIndexPath *)indexPath toIndexPath:(NSIndexPath *)newIndexPath NS_AVAILABLE_IOS(5_0);

他们通过一个范围和一个非常好用且漂亮的动画控制器来控制每一个row的增删改移动,和安卓中recycleview中类似的方法非常相似。这些方法不仅仅用于列表的修改,更多用于列表收起展开,数据加载替换等场景的动画效果。

另外,下拉刷新也是非常常见的操作,tableview对下拉刷新控件UIRefreshControl的操作兼容非常友好,我们只需要在tableview的subview中加入一个UIRefreshControl就可以了。

以上便是UITableView的常见使用方式与常见使用场景。它的高级版UICollectionView虽然吊吊的,但是万变不离其宗,还是熟悉的配方,还是熟悉的味道,上手起来就相当快了。相关参看:http://www.cocoachina.com/ios/20140922/9710.html
logo