下拉刷新 & 上拉加载
课程目标
- KVO的使用
- UIScrollView使用
接口准备
- 新浪微博下拉刷新与上拉加载需要有两个重要的参数
参数名 | 说明 |
---|---|
since_id | 返回ID比since_id大的微博(即比since_id时间晚的微博) |
max_id | 返回ID小于或等于max_id的微博 |
以上可知:
- 如果传入since_id,服务器会返回ID比since_id大的微博(即比since_id时间晚的微博),也就是最新的微博,所以这个参数可以用于下拉刷新.
- 传入max_id,服务器会返回ID小于
或等于
max_id的微博,id 越小时间越早,所以可以用作上拉加载。(特别注意:会返回ID小于或等于
)
- 更改微博数据加载的方法->
HMStatusListViewModel
中loadStatuses
方法添加参数
/// 加载微博数据的方法
func loadData(isPullUp isPullUp: Bool, completion: (isSuccessed: Bool)->()) {
// 定义 url 与参数
let urlString = "https://api.weibo.com/2/statuses/friends_timeline.json"
let since_id = isPullUp ? 0 : (statuses?.first?.status?.id ?? 0)
let max_id = isPullUp ? (statuses?.last?.status?.id ?? 0) : 0
let params = [
"access_token": HMUserAccountViewModel.sharedUserAccount.accessToken!,
"since_id": since_id,
"max_id": max_id
]
...
}
上拉加载
实现效果与思路
- 当用户滚动到底部的时候,自动去加载更多数据
- 可以在加载当前页面最后一个 cell 的时候去执行加载更多数据的方法
- 给 tableView 添加一个
footerView
(上拉显示控件),用作拉到最底部的友好显示
代码实现
- 懒加载底部上拉显示控件
// 上拉加载控件
private lazy var pullupView: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView()
indicator.activityIndicatorViewStyle = .WhiteLarge
indicator.color = UIColor.darkGrayColor()
return indicator;
}()
- 设置成 tableView 的footerView
// 设置上拉加载控件
tableView.tableFooterView = pullupView
运行测试,看不见任何东西。看不见控件的原因就是 UIActivityIndicatorView 控件默认不执行动画是看不见的
- 开启执行动画
pullupView.startAnimating()
运行测试,已经可以看到,但是位置没有留出来,执行
sizeToFit
方法
- 在将要加载最后一个 cell 的时候去加载更多数据
override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
if indexPath.row == statusListViewModel.statuses!.count - 1 && pullupView.isAnimating() == false {
// 加载更多
pullupView.startAnimating()
loadData()
}
}
注意:需要在判断里面多加一个条件,就是底部控件没有执行动画的时候才去加载更多数据,防止重复加载
- 更改
loadData()
方法逻辑
// MARK: - 加载数据
private func loadData(){
statusListViewModel.loadData(isPullUp: pullupView.isAnimating()) { (isSuccessed, count) -> () in
if isSuccessed {
self.tableView.reloadData()
}
}
}
- 更改
HMStatusListViewModel
中loadData
方法 -> 上拉加载与下拉刷新数据添加的位置不一样
if let array = res["statuses"] as? [[String: AnyObject]] {
// 如果是字典
// 判断数组是否为 nil
if self.statuses == nil {
self.statuses = [HMStatusViewModel]()
}
// 定义一个临时数组
var tempStatuses = [HMStatusViewModel]()
// 字典转模型
for dic in array {
tempStatuses.append(HMStatusViewModel(status: HMStatus(dictionary: dic)))
}
if isPullUp {
// 代表是上拉加载,拼装数据到集合后面
self.statuses! += tempArray
}else{
// 代表是下拉刷新,拼装数据到前面
self.statuses! = tempArray + self.statuses!
}
}
...
运行测试:发现只加载一次数据,下次再拖动就不去加载了,原因是加载完毕之后 pullupView 也一直在执行动画,下次就进入不到加载更多的判断逻辑里面去了,所以加载完毕需要将 pullupView 结束动画
- 结束动画
/// 结束刷新
private func endRefresh(){
pullupView.stopAnimating()
}
/// 在数据请求成功,或者数据请求失败之后调用此方法
statusListViewModel.loadData(isPullUp: pullupView.isAnimating()) { (isSuccessed, count) -> () in
if isSuccessed {
self.tableView.reloadData()
}
self.endRefresh()
}
运行测试
下拉刷新
实现效果
- 拖动 tableView,顶部显示 下拉刷新,箭头朝下
- 拖动到一定程度的时候,顶部显示 释放更新,箭头朝上
- 松手:
- 到达一定程度松手,顶部显示 加载中…,隐藏箭头,显示菊花转
- 未到达一定程度,直接回到最初状态
顶部的整个 View 会随着 tableView 的拖动而移动
示意图
实现思路
- 给 tableView 添加一个自定义刷新控件(
HMRefreshControl
) - 这个刷新控件的 y 值是 负自己的高度,以让其放在 tableView 的顶部以及可以跟随 tableView 滑动
- 在刷新控件内部监听 tableView 的滑动
- 当滑动到某种程度去改变子控件要显示的逻辑
- 当用户松开手要刷新的时候,可以调整 tableView 的
contentInset
的top
值以让刷新控件显示出来 - 在刷新的时候调用外部提供的方法执行刷新的逻辑
实现代码
- 自定义
HMRefreshControl
class HMRefreshControl: UIControl {
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
// 先设置默认宽度与高度
self.frame.size.width = SCREENW
self.frame.size.height = 44
backgroundColor = RandomColor()
}
}
- 定义懒加载控件 & 添加到首页的 tableView 中去
// 下拉刷新控件
private lazy var hmRefreshControl: HMRefreshControl = HMRefreshControl()
...
// 添加头部视图
tableView.addSubview(hmRefreshControl)
运行测试
- 抽取控件高度常量
private let HMRefreshControlH: CGFloat = 44
- 更改 Y 值
private func setupUI() {
// 先设置默认宽度与高度
self.frame.size.width = SCREENW
self.frame.size.height = HMRefreshControlH
self.frame.origin.y = -HMRefreshControlH
backgroundColor = RandomColor()
}
- 定义
scrollView
属性
// 定义 scrollView,用于记录当前控件添加到哪一个 View 上的
var scrollView: UIScrollView?
- 在
HMRefreshView
中监听其添加到tableView
的滚动
/// 当前 view 的父视图即将改变的时候会调用,可以在这个方法里面拿到父控件
override func willMoveToSuperview(newSuperview: UIView?) {
super.willMoveToSuperview(newSuperview)
// 如果父控件不为空,并且父控件是UIScrollView
if let scrollView = newSuperview where scrollView.isKindOfClass(NSClassFromString("UIScrollView")!) {
scrollView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.New, context: nil)
// 记录当前 scrollView,以便在 `deinit` 方法里面移除监听
self.scrollView = scrollView as? UIScrollView
}
}
/// 当值改变之后回调的方法
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
printLog(change)
}
deinit{
// 移除监听
if let scrollView = self.scrollView {
scrollView.removeObserver(self, forKeyPath: "contentOffset")
}
}
注意:监听之后需要做两件事情:a.在合适的时候移除监听;b.一定要实现值改变之后的回调方法
- 根据滚动,计算出 refreshView 完全展示出现的临界点值
// 取到顶部增加的可滑动的距离
let contentInsetTop = self.scrollView!.contentInset.top
// 取到当前 scrollView 的偏移 Y
let contentOffsetY = self.scrollView!.contentOffset.y
// printLog("contentInsetTop=\(contentInsetTop);contentOffsetY=\(contentOffsetY)")
// 通过分析可知:contentOffsetY 如果小于 (-contentInsetTop - 当前 View 高度),就代表当前 View 完全显示出来
// 而 (-contentInsetTop - 当前 View 高度) 这个值就代表临界值
// 临界值
let criticalValue = -contentInsetTop - self.height
// 在用户拖动的时候去判断临界值
if scrollView!.dragging {
if contentOffsetY < criticalValue {
printLog("完全显示出来啦")
}else {
printLog("没有完全显示出来/没有显示出来")
}
}
- 根据以上状态添加 state 枚举
enum HMRefreshControlStatus: Int {
case Normal = 0 // 默认状态
case Pulling = 1 // 松手就可以刷新的状态
case Refreshing = 2 // 正在刷新的状态
}
- 定义 state 属性
- 根据滑动的位置设置当前 View 的状态
// 在用户拖动的时候去判断临界值
if scrollView!.dragging {
if contentOffsetY < criticalValue {
printLog("完全显示出来啦")
self.status = .Pulling
}else {
printLog("没有完全显示出来/没有显示出来")
self.status = .Normal
}
}
- 添加子控件 (箭头,提示文字label)
// MARK: - 懒加载控件
// 箭头图标
private lazy var arrowIcon: UIImageView = UIImageView(image: UIImage(named: "tableview_pull_refresh"))
// 显示文字的label
private lazy var messageLabel: UILabel = {
let label = UILabel()
label.text = "下拉刷新"
label.textColor = UIColor.grayColor()
label.font = UIFont.systemFontOfSize(12)
return label
}()
...
// 添加子控件
private func setupUI(){
...
// 添加控件
addSubview(arrowIcon)
addSubview(messageLabel)
// 添加约束
arrowIcon.snp_makeConstraints { (make) -> Void in
make.centerX.equalTo(self.snp_centerX).offset(-30)
make.centerY.equalTo(self.snp_centerY)
}
messageLabel.snp_makeConstraints { (make) -> Void in
make.leading.equalTo(arrowIcon.snp_trailing)
make.centerY.equalTo(arrowIcon.snp_centerY)
}
}
- 设置不同状态下执行不同的动画
// 定义当前控件的刷新状态
var status: HMRefreshControlStatus = .Normal {
didSet{
switch status {
case .Pulling:
UIView.animateWithDuration(0.25, animations: { () -> Void in
self.arrowIcon.transform = CGAffineTransformMakeRotation(CGFloat(M_PI))
})
messageLabel.text = "释放更新"
case .Normal:
UIView.animateWithDuration(0.25, animations: { () -> Void in
self.arrowIcon.transform = CGAffineTransformIdentity
})
messageLabel.text = "下拉刷新"
default:
break
}
}
}
运行测试
- 监听用户松手进入刷新状态,满足两个条件
- 用户松手
- 当前状态是
Pulling
状态 (可以进入刷新的状态)
// 在用户拖动的时候去判断临界值
if scrollView!.dragging {
if contentOffsetY < criticalValue {
printLog("完全显示出来啦")
self.state = .Pulling
}else {
printLog("没有完全显示出来/没有显示出来")
self.state = .Normal
}
}else{
// 判断如果用户已经松手,并且当前状态是.Pulling,那么进入到 .Refreshing 状态
if self.status == .Pulling {
print("进入刷新状态")
self.status = .Refreshing
}
}
- 显示刷新状态的效果
// 1.懒加载控件
// 菊花转
private lazy var indecator: UIActivityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: UIActivityIndicatorViewStyle.Gray)
// 2.添加控件 & 设置约束
addSubview(indecator)
indecator.snp_makeConstraints { (make) -> Void in
make.center.equalTo(arrowIcon.snp_center)
}
// 3.在 state 为 Refreshing 状态时显示效果
case .Refreshing: // 显示刷新的效果
// 添加顶部可以多滑动的距离
UIView.animateWithDuration(0.25, animations: { () -> Void in
var contentInset = self.scrollView!.contentInset
contentInset.top += self.frame.height
self.scrollView?.contentInset = contentInset
})
// 隐藏箭头
arrowIcon.hidden = true
// 开始菊花转
indecator.startAnimating()
// 显示 `加载中…`
messageLabel.text = "加载中…"
- 在
默认状态
下显示箭头,隐藏菊花转
case .Normal: // 置为默认的状态的效果
UIView.animateWithDuration(0.25, animations: { () -> Void in
self.arrowIcon.transform = CGAffineTransformIdentity
})
messageLabel.text = "下拉刷新"
arrowIcon.hidden = false
indecator.stopAnimating()
运行:测试发现当松手刷新的时候,显示的效果能出来,但是当一滑动的时候状态就发会了改变,而
Refreshing
的状态改变是由数据刷新完成之后去重置,所以更改滑动时候的判断逻辑
- 更改滑动时的判断逻辑,以防止
正在刷新中
的时候的状态异常改变
// 在用户拖动的时候去判断临界值
if scrollView!.dragging {
if state == .Normal && contentOffsetY < criticalValue {
printLog("完全显示出来啦")
self.status = .Pulling
}else if status == .Pulling && contentOffsetY >= criticalValue {
printLog("没有完全显示出来/没有显示出来")
self.status = .Normal
}
}else{
// 判断如果用户已经松手,并且当前状态是.Pulling,那么进入到 .Refreshing 状态
if self.status == .Pulling {
self.status = .Refreshing
}
}
- 模拟 5 秒后结束刷新
UIView.animateWithDuration(0.25, animations: { () -> Void in
var contentInset = self.scrollView!.contentInset
contentInset.top += self.height
self.scrollView?.contentInset = contentInset
}, completion: { (finish) -> Void in
// 模似 5 秒之后约束刷新
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(5 * Double(NSEC_PER_SEC))), dispatch_get_main_queue()) { () -> Void in
// 设置状态为 默认状态
self.status = .Normal
// 重置contentInsetTop
UIView.animateWithDuration(0.25, animations: { () -> Void in
var contentInset = self.scrollView!.contentInset
contentInset.top -= self.frame.height
self.scrollView?.contentInset = contentInset
})
}
})
运行测试
- 添加要执行刷新的方法
hmRefreshControl.addTarget(self, action: "loadData", forControlEvents: UIControlEvents.ValueChanged)
- 在刷新的时候执行方法
case .Refreshing: // 显示刷新的效果
...
// 调用刷新的方法
sendActionsForControlEvents(.ValueChanged)
- 去掉上面模拟5秒结束刷新的逻辑,添加结束刷新方法
func endRefreshing(){
// 重置contentInsetTop
UIView.animateWithDuration(0.25, animations: { () -> Void in
var contentInset = self.scrollView!.contentInset
contentInset.top -= self.frame.height
self.scrollView?.contentInset = contentInset
})
// 设置状态为 默认状态
self.status = .Normal
}
- 在刷新完毕之后调用
HMRefreshControl
的endRefreshing()
方法
/// 结束刷新
private func endRefresh(){
pullupView.stopAnimating()
hmRefreshControl.endRefreshing()
}
运行测试:第一次启动的时候,刷新完毕,出现contentInset.top值递减问题,所以要判断如果之前状态是刷新状态,结束刷新才去更改contentInset.top
- 增加保存上一次状态的逻辑
// 定义旧状态属性,保存上一次状态
var oldStatus: HMRefreshControlStatus?
// 在 `state` 的 `didSet` 方法末尾记录状态
// 定义当前控件的刷新状态
var status: HMRefreshState = .Normal {
didSet{
switch status {
case .Pulling: // 松手就可以刷新的状态
...
case .Normal: // 置为默认的状态的效果
...
case .Refreshing: // 显示刷新的效果
...
}
// 记录本次状态
oldStatus = status
}
}
- 在结束刷新的时候判断如果是从
刷新状态
进入到默认状态
就递减contentInset.top
/// 结束刷新
func endRefreshing(){
if oldStatus == .Refreshing {
// 重置contentInsetTop
UIView.animateWithDuration(0.25, animations: { () -> Void in
var contentInset = self.scrollView!.contentInset
contentInset.top -= self.frame.height
self.scrollView?.contentInset = contentInset
})
}
// 设置状态为 默认状态
self.state = .Normal
}
- 部分代码抽取
// 把结束刷新的逻辑,移动到 state 的 didSet 的 case .Normal 中
switch state {
case .Pulling: // 松手就可以刷新的状态
...
case .Normal: // 置为默认的状态的效果
...
// 如果之前状态是刷新状态,需要递减 contentInset.top
if oldStatus == .Refreshing {
// 重置contentInsetTop
UIView.animateWithDuration(0.25, animations: { () -> Void in
var contentInset = self.scrollView!.contentInset
contentInset.top -= self.frame.height
self.scrollView?.contentInset = contentInset
})
}
case .Refreshing: // 显示刷新的效果
...
}
...
// 抽取之后的方法
func endRefreshing(){
// 设置状态为 默认状态
self.state = .Normal
}
运行测试
下拉刷新提示
- 修改
loadStatuses
,回调加载成功数据条数
/// 加载微博数据的方法
func loadData(isPullUp isPullUp: Bool, completion: (isSuccessed: Bool, count: Int)->()) {
}
- 懒加载提示控件
/// 下拉刷新提示的label
// 提示控件
private lazy var pullDownTipLabel: UILabel = {
let label = UILabel(textColor: UIColor.whiteColor(), fontSize: 12)
// 设置文字居中、背景颜色
label.textAlignment = NSTextAlignment.Center
label.backgroundColor = UIColor.orangeColor()
// 设置大小
label.frame.size = CGSizeMake(SCREENW, 35)
return label
}()
- 增加
showPullDownTips
方法,测试添加位置
/// 显示下拉刷新提示
private func showPullDownTips(count: Int){
pullDownTipLabel.y = 35
navigationController?.view.insertSubview(pullDownTipLabel, belowSubview: navigationController!.navigationBar)
}
- 在下拉刷新完成之后调用此方法
@objc private func loadData(){
statusListViewModel.loadData(isPullUp: pullupView.isAnimating()) { (isSuccessed, count) -> () in
if isSuccessed {
self.tableView.reloadData()
}
if self.pullupView.isAnimating() == false {
self.showPullDownTips(count)
}
self.endRefresh()
}
}
- 更改懒加载代码
/// 下拉刷新提示的label
private lazy var pullDownTipLabel: UILabel = {
let label = UILabel()
// 设置文字颜色、文字大小、居中、背景颜色
label.textColor = UIColor.whiteColor()
label.font = UIFont.systemFontOfSize(12)
label.textAlignment = NSTextAlignment.Center
label.backgroundColor = UIColor.orangeColor()
// 设置大小
label.size = CGSizeMake(SCREENW, 35)
// 默认是隐藏状态
label.hidden = true
// 添加控件
if let navigationController = self.navigationController {
navigationController.view.insertSubview(label, belowSubview: navigationController.navigationBar)
}
return label
}()
- 完成显示逻辑
/// 显示下拉刷新提示
private func showPullDownTips(count: Int){
// 如果当前控件处于显示状态,直接返回
if !pullDownTipLabel.hidden {
return
}
/// 提示文字信息
let tipStr = count==0 ? "没有微博数据": "\(count)条新微博"
let height = pullDownTipLabel.frame.height
pullDownTipLabel.frame.origin.y = CGRectGetMaxY(self.navigationController!.navigationBar.frame) - height
// 设置文字并将其显示
pullDownTipLabel.text = tipStr
pullDownTipLabel.hidden = false
//执行动画
UIView.animateWithDuration(1, animations: { () -> Void in
self.pullDownTipLabel.transform = CGAffineTransformMakeTranslation(0, height)
}) { (finish) -> Void in
UIView.animateWithDuration(1, delay: 1, options: [], animations: { () -> Void in
self.pullDownTipLabel.transform = CGAffineTransformIdentity
}, completion: { (finish) -> Void in
//动画执行完毕,隐藏
self.pullDownTipLabel.hidden = true
})
}
}