实现tableView中的下拉上拉刷新效果及相关基础概念

3,078 阅读8分钟

最近有时间自己实现了tableView中的上拉刷新和下拉加载更多的功能,同时发现了自己在基础知识上的盲点和不足,顺便补习一下,我把代码拿出来跟大家分享一下。文末有GitHub的传送门。

一、bounds、frame等重要概念

frame: 该view在父view坐标系统中的位置和大小。(参照点是,父亲的坐标系统)

bounds:该view在本地坐标系统中的位置和大小。(参照点是,本地坐标系统,就相当于View自己的坐标系统,以0,0点为起点)

contentSize:滚动视图的范围,也就是所有内容的大小

contentOffset:The point at which the origin of the content view is offset from the origin of the scroll view. (文档描述)意思就是内容区域和scrollview的frame区域的高度差,注意一定是frame,这点下面会解释。

contentInset:The distance that the content view is inset from the enclosing scroll view.(文档描述)意思是在contentView周围增加边距,当有contentInset时会让用户感觉内容距离边框有一定的距离,同时它并没有占用增加的边距内容,隐藏的视图会显现出来,一会在代码中会用到。(详见图二)

关于bounds需要好好理解一下,它相当于在frame上层浮动的一层区域,而且是负责显示内容,只不过它是边界,contentView是中间的内容。一般一个View的bounds的原点都是(0,0),因为是以自己为坐标系,但是如果你改变了它的bounds原点,那么该View的子View都会以改变后的原点来进行布局,总结为bounds的原点会影响其子视图的显示位置。

这些概念非常重要,建议大家写写demo具体观察一下。

推荐一个关于这方面介绍的好文章:👉iOS View的Frame和bounds之区别,setbounds使用(深入探究) - 郭晓东的专栏 - CSDN博客

二、tableView的重要概念

tableview的内容包括:

1.cell
2.sectionHeader / sectionFooter
3.tableHeaderView / tableFooterView

先上公式:contentOffset.y == content的顶部 和 frame.origin.y 的差值

inset是紧紧粘着内容,如果有tableHeaderView / tableFooterView,会在其上 / 下紧贴显示(详见图一)

图一

之前在第一部分中提到contentInset会让隐藏的视图显示出来,具体可参照图二

图二

三、下拉刷新逻辑分析

先看一下效果图,iPhoneX系列和iPhone8之前的都适配

下面是使用到的属性:

@property (nonatomic,assignNSInteger dataCount;
/** 下拉刷新控件*/
@property (nonatomicweakUIView *header;
/** 下拉刷新label*/
@property (nonatomicweakUILabel *headerLabel;
/** 下拉刷新的h状态*/
@property (nonatomicassigngetter=isHeaderRefreshing) BOOL headerRefreshing;

/** 上拉刷新控件*/
@property (nonatomicweakUIView *footer;
/** 上拉刷新label*/
@property (nonatomicweakUILabel *footerLabel;
/** 上拉刷新的h状态*/
@property (nonatomicassigngetter=isFooterRefreshing) BOOL footerRefreshing;

首先初始化头部和底部的刷新控件,由于考虑到一般应用的头部都有广告或者搜索框之类的东西,所以刷新状态展示的header直接采用add subview的方式,直接紧贴着内容添加,也就是在广告条的上方。

//通过headerview设置广告条等控件
UILabel *adLab = [[UILabel alloc] init];
adLab.backgroundColor = [UIColor redColor];
adLab.text = @"我是广告";
adLab.frame = CGRectMake(00030);
adLab.textAlignment = NSTextAlignmentCenter;
self.tableView.tableHeaderView = adLab;

//设置下拉刷新的控件
UIView *header = [[UIView alloc] init];
header.frame = CGRectMake(0, -50, self.tableView.bounds.size.width, 50);
header.backgroundColor = [UIColor blueColor];
self.header = header;
[self.tableView addSubview:header];
UILabel *headerLab = [[UILabel alloc] init];
headerLab.text = @"下拉可以刷新";
headerLab.textAlignment = NSTextAlignmentCenter;
headerLab.frame = header.bounds;
self.headerLabel = headerLab;
[self.header addSubview:headerLab];

footer由于一般底部没什么需要特别添加的,所以自己定义一个要展示的view直接复制给tableFooterView即可,这里就不展示了。

接下来就要监听header的位置变化来实现其中文字的变化,当然图片变化也可以,添加图片后在合适的时候直接旋转。我在scrollViewDidScroll方法中调用dealHeader和dealFooter。

#pragma mark - scrollview代理方法
- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    [self dealHeader];
    [self dealFooter];
}

重点分析header的处理。

#pragma mark - 处理下拉刷新控件变化的方法
- (void)dealHeader{
    //如果正处于下拉刷新则直接返回
    if (self.isHeaderRefreshing) {
        return;
    }
    offsetY = -(getRectNavAndStatusHight + self.headerLabel.bounds.size.height);
    
    if (self.tableView.contentOffset.y < offsetY) {
        self.headerLabel.text = @"松开立即刷新";
        self.header.backgroundColor = [UIColor grayColor];
    } else{
        self.headerLabel.text = @"下拉可以刷新";
        self.header.backgroundColor = [UIColor blueColor];
    }
}

offsetY是一个static变量,因为它是一个固定的临界值,getRectNavAndStatusHight是一个获取顶部导航最大高度的宏,在iPhoneX之前和之后的机型中都可以用。offsetY的意思是导航栏的最大高度加上刷新状态展示的header的高度,之所以加负号是由于下拉时contentOffset.y是负值,当下拉的contenOffset.y的绝对值超过offsetY,也就是代码中的判断条件self.tableView.contentOffset.y < offsetY成立时,header可以变换样式。

这里再解释一下contentOffset,它是图二中你见到的那种样子,手指下滑时也就是下拉时越来越小直到变成负值,手指上滑时值越来越大。而这里有个很有意思的注意点,初始位置contentOffset.y是-88,也就是导航栏的高度,这是因为contentView是我们可视的区域,并且bounds的原点是(0,-88),也就印证了我一开始说的bounds负责显示内容,只不过它是边界,contentView是中间的内容。我将tableView初始状态的各个属性打印了出来,大家可以分析一下。

图三

回到处理header上,之前能够通过偏移量处理header的变化,接下来就是当用户下拉松手后的处理。

/**
 用户松开手指时调用
 */
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    //在header完全出现时松开了手指才会进行刷新
    if (self.tableView.contentOffset.y < offsetY) {
        [self headerBeginRefreshing];
    }
}

#pragma mark - 开始下拉刷新的方法
- (void)headerBeginRefreshing{
    if (self.isHeaderRefreshing) {
        return;
    }
    self.headerLabel.text = @"正在刷新数据";
    self.header.backgroundColor = [UIColor greenColor];
    self.headerRefreshing = YES;
    [UIView animateWithDuration:0.25 animations:^{
        //修改tableview的内边距使得刷新时可以让header停留在用户视线内
        UIEdgeInsets inset = self.tableView.contentInset;
        inset.top += self.header.bounds.size.height;
        self.tableView.contentInset = inset;
    }];
    //请求数据
    [self loadNewData];
}

当偏移量没有超过临界值时不做操作,直接由scrollViewDidScroll中的dealHeader使其恢复原样,如果超过临界值则调用headerBeginRefreshing方法,在监听用户手势拖拽的scrollViewDidEndDragging:(UIScrollView*)scrollView willDecelerate:(BOOL)decelerate方法中调用。

在开始刷新的方法中先判断是不是正在处于刷新,如果是就直接返回,主要是防止在真正的应用中多次向服务器发出请求。在demo中没有接口请求所有我都是通过延迟来模拟的,修改contentInset的作用就是使得“正在请求数据”的header样式可以停留在用户的视野中。我加了一个动画让其看起来自然些。然后就是调用loadNewData方法请求数据,大家用的时候直接在这里面放入真正的接口请求然后reloadData就可以。最后结束刷新,结束时将contentInset恢复,注释写的比较详细,可以直接看代码。

以上就是整个下拉刷新的实现,同时是为了加深对tableView的一些属性的理解。

四、上拉刷新逻辑分析

#pragma mark - 处理上拉刷新控件变化的方法
- (void)dealFooter{
    if (self.tableView.contentSize.height == 0) {
        return;
    }
    CGFloat footerOffset = self.tableView.contentSize.height - self.tableView.frame.size.height + kTabBarHeight_X;
    if (self.tableView.contentOffset.y >= footerOffset) {
        [self footerBeginRefreshing];
    }
}

上拉刷新的过程跟这个差不多,我就不细说了,强调一下临界值的计算: self.tableView.contentSize.height - self.tableView.frame.size.height + kTabBarHeight_X; kTabBarHeight_X是底部导航栏的高度,也是一个宏,在iPhoneX之前和之后的机型中都可以用。由于是为了让footer的显示完全超过TabBar的上沿后才刷新,所以最后要加上TabBar的高度。当contentOffset.y(这个值是实时的偏移量)大于临界值时才会去处理上拉的刷新逻辑。

也可以这样理解:前面两个值相减是tableView在以当前内容(contentView)显示时的最大的contentOffset.y,但这个值是刚刚超过了tableView的底部,也就是TabBar的底部,那么可以推出加上TabBar的高度才是满足需求的。

五、最后总结

本文重点分析了tableView中frame、contentSize和contentOffset等各个属性的关联,通过逻辑计算(主要是图一和图二中展示的)可以自己实现类似MJRefresh中的头部和底部刷新的功能,当然在项目中还是会选择使用第三方库,但是对里面的逻辑还是要深入理解一下,可以学到很多平时忽略的东西。

👉GitHub传送门