下拉刷新 & 上拉加載
課程目標(biāo)
- KVO的使用
- UIScrollView使用
接口準備
- 新浪微博下拉刷新與上拉加載需要有兩個重要的參數(shù)
參數(shù)名 | 說明 |
---|---|
since_id | 返回ID比since_id大的微博(即比since_id時間晚的微博) |
max_id | 返回ID小于或等于max_id的微博 |
以上可知:
- 如果傳入since_id说墨,服務(wù)器會返回ID比since_id大的微博(即比since_id時間晚的微博)庸队,也就是最新的微博,所以這個參數(shù)可以用于下拉刷新.
- 傳入max_id茬缩,服務(wù)器會返回ID小于
或等于
max_id的微博,id 越小時間越早,所以可以用作上拉加載资锰。(特別注意:會返回ID小于或等于
)
- 更改微博數(shù)據(jù)加載的方法->
HMStatusListViewModel
中loadStatuses
方法添加參數(shù)
/// 加載微博數(shù)據(jù)的方法
func loadData(isPullUp isPullUp: Bool, completion: (isSuccessed: Bool)->()) {
// 定義 url 與參數(shù)
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
]
...
}
上拉加載
實現(xiàn)效果與思路
- 當(dāng)用戶滾動到底部的時候首昔,自動去加載更多數(shù)據(jù)
- 可以在加載當(dāng)前頁面最后一個 cell 的時候去執(zhí)行加載更多數(shù)據(jù)的方法
- 給 tableView 添加一個
footerView
(上拉顯示控件)橱健,用作拉到最底部的友好顯示
代碼實現(xiàn)
- 懶加載底部上拉顯示控件
// 上拉加載控件
private lazy var pullupView: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView()
indicator.activityIndicatorViewStyle = .WhiteLarge
indicator.color = UIColor.darkGrayColor()
return indicator;
}()
- 設(shè)置成 tableView 的footerView
// 設(shè)置上拉加載控件
tableView.tableFooterView = pullupView
運行測試,看不見任何東西沙廉【械矗看不見控件的原因就是 UIActivityIndicatorView 控件默認不執(zhí)行動畫是看不見的
- 開啟執(zhí)行動畫
pullupView.startAnimating()
運行測試,已經(jīng)可以看到撬陵,但是位置沒有留出來珊皿,執(zhí)行
sizeToFit
方法
- 在將要加載最后一個 cell 的時候去加載更多數(shù)據(jù)
override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
if indexPath.row == statusListViewModel.statuses!.count - 1 && pullupView.isAnimating() == false {
// 加載更多
pullupView.startAnimating()
loadData()
}
}
注意:需要在判斷里面多加一個條件,就是底部控件沒有執(zhí)行動畫的時候才去加載更多數(shù)據(jù)巨税,防止重復(fù)加載
- 更改
loadData()
方法邏輯
// MARK: - 加載數(shù)據(jù)
private func loadData(){
statusListViewModel.loadData(isPullUp: pullupView.isAnimating()) { (isSuccessed, count) -> () in
if isSuccessed {
self.tableView.reloadData()
}
}
}
- 更改
HMStatusListViewModel
中loadData
方法 -> 上拉加載與下拉刷新數(shù)據(jù)添加的位置不一樣
if let array = res["statuses"] as? [[String: AnyObject]] {
// 如果是字典
// 判斷數(shù)組是否為 nil
if self.statuses == nil {
self.statuses = [HMStatusViewModel]()
}
// 定義一個臨時數(shù)組
var tempStatuses = [HMStatusViewModel]()
// 字典轉(zhuǎn)模型
for dic in array {
tempStatuses.append(HMStatusViewModel(status: HMStatus(dictionary: dic)))
}
if isPullUp {
// 代表是上拉加載蟋定,拼裝數(shù)據(jù)到集合后面
self.statuses! += tempArray
}else{
// 代表是下拉刷新,拼裝數(shù)據(jù)到前面
self.statuses! = tempArray + self.statuses!
}
}
...
運行測試:發(fā)現(xiàn)只加載一次數(shù)據(jù)草添,下次再拖動就不去加載了驶兜,原因是加載完畢之后 pullupView 也一直在執(zhí)行動畫,下次就進入不到加載更多的判斷邏輯里面去了远寸,所以加載完畢需要將 pullupView 結(jié)束動畫
- 結(jié)束動畫
/// 結(jié)束刷新
private func endRefresh(){
pullupView.stopAnimating()
}
/// 在數(shù)據(jù)請求成功抄淑,或者數(shù)據(jù)請求失敗之后調(diào)用此方法
statusListViewModel.loadData(isPullUp: pullupView.isAnimating()) { (isSuccessed, count) -> () in
if isSuccessed {
self.tableView.reloadData()
}
self.endRefresh()
}
運行測試
下拉刷新
實現(xiàn)效果
- 拖動 tableView,頂部顯示 下拉刷新驰后,箭頭朝下
- 拖動到一定程度的時候肆资,頂部顯示 釋放更新,箭頭朝上
- 松手:
- 到達一定程度松手灶芝,頂部顯示 加載中…郑原,隱藏箭頭,顯示菊花轉(zhuǎn)
- 未到達一定程度夜涕,直接回到最初狀態(tài)
頂部的整個 View 會隨著 tableView 的拖動而移動
示意圖
實現(xiàn)思路
- 給 tableView 添加一個自定義刷新控件(
HMRefreshControl
) - 這個刷新控件的 y 值是 負自己的高度犯犁,以讓其放在 tableView 的頂部以及可以跟隨 tableView 滑動
- 在刷新控件內(nèi)部監(jiān)聽 tableView 的滑動
- 當(dāng)滑動到某種程度去改變子控件要顯示的邏輯
- 當(dāng)用戶松開手要刷新的時候,可以調(diào)整 tableView 的
contentInset
的top
值以讓刷新控件顯示出來 - 在刷新的時候調(diào)用外部提供的方法執(zhí)行刷新的邏輯
實現(xiàn)代碼
- 自定義
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() {
// 先設(shè)置默認寬度與高度
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() {
// 先設(shè)置默認寬度與高度
self.frame.size.width = SCREENW
self.frame.size.height = HMRefreshControlH
self.frame.origin.y = -HMRefreshControlH
backgroundColor = RandomColor()
}
- 定義
scrollView
屬性
// 定義 scrollView女器,用于記錄當(dāng)前控件添加到哪一個 View 上的
var scrollView: UIScrollView?
- 在
HMRefreshView
中監(jiān)聽其添加到tableView
的滾動
/// 當(dāng)前 view 的父視圖即將改變的時候會調(diào)用酸役,可以在這個方法里面拿到父控件
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)
// 記錄當(dāng)前 scrollView,以便在 `deinit` 方法里面移除監(jiān)聽
self.scrollView = scrollView as? UIScrollView
}
}
/// 當(dāng)值改變之后回調(diào)的方法
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
printLog(change)
}
deinit{
// 移除監(jiān)聽
if let scrollView = self.scrollView {
scrollView.removeObserver(self, forKeyPath: "contentOffset")
}
}
注意:監(jiān)聽之后需要做兩件事情:a.在合適的時候移除監(jiān)聽;b.一定要實現(xiàn)值改變之后的回調(diào)方法
- 根據(jù)滾動簇捍,計算出 refreshView 完全展示出現(xiàn)的臨界點值
// 取到頂部增加的可滑動的距離
let contentInsetTop = self.scrollView!.contentInset.top
// 取到當(dāng)前 scrollView 的偏移 Y
let contentOffsetY = self.scrollView!.contentOffset.y
// printLog("contentInsetTop=\(contentInsetTop);contentOffsetY=\(contentOffsetY)")
// 通過分析可知:contentOffsetY 如果小于 (-contentInsetTop - 當(dāng)前 View 高度)只壳,就代表當(dāng)前 View 完全顯示出來
// 而 (-contentInsetTop - 當(dāng)前 View 高度) 這個值就代表臨界值
// 臨界值
let criticalValue = -contentInsetTop - self.height
// 在用戶拖動的時候去判斷臨界值
if scrollView!.dragging {
if contentOffsetY < criticalValue {
printLog("完全顯示出來啦")
}else {
printLog("沒有完全顯示出來/沒有顯示出來")
}
}
- 根據(jù)以上狀態(tài)添加 state 枚舉
enum HMRefreshControlStatus: Int {
case Normal = 0 // 默認狀態(tài)
case Pulling = 1 // 松手就可以刷新的狀態(tài)
case Refreshing = 2 // 正在刷新的狀態(tài)
}
- 定義 state 屬性
- 根據(jù)滑動的位置設(shè)置當(dāng)前 View 的狀態(tài)
// 在用戶拖動的時候去判斷臨界值
if scrollView!.dragging {
if contentOffsetY < criticalValue {
printLog("完全顯示出來啦")
self.status = .Pulling
}else {
printLog("沒有完全顯示出來/沒有顯示出來")
self.status = .Normal
}
}
- 添加子控件 (箭頭,提示文字label)
// MARK: - 懶加載控件
// 箭頭圖標(biāo)
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)
}
}
- 設(shè)置不同狀態(tài)下執(zhí)行不同的動畫
// 定義當(dāng)前控件的刷新狀態(tài)
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
}
}
}
運行測試
- 監(jiān)聽用戶松手進入刷新狀態(tài)暑塑,滿足兩個條件
- 用戶松手
- 當(dāng)前狀態(tài)是
Pulling
狀態(tài) (可以進入刷新的狀態(tài))
// 在用戶拖動的時候去判斷臨界值
if scrollView!.dragging {
if contentOffsetY < criticalValue {
printLog("完全顯示出來啦")
self.state = .Pulling
}else {
printLog("沒有完全顯示出來/沒有顯示出來")
self.state = .Normal
}
}else{
// 判斷如果用戶已經(jīng)松手吼句,并且當(dāng)前狀態(tài)是.Pulling,那么進入到 .Refreshing 狀態(tài)
if self.status == .Pulling {
print("進入刷新狀態(tài)")
self.status = .Refreshing
}
}
- 顯示刷新狀態(tài)的效果
// 1.懶加載控件
// 菊花轉(zhuǎn)
private lazy var indecator: UIActivityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: UIActivityIndicatorViewStyle.Gray)
// 2.添加控件 & 設(shè)置約束
addSubview(indecator)
indecator.snp_makeConstraints { (make) -> Void in
make.center.equalTo(arrowIcon.snp_center)
}
// 3.在 state 為 Refreshing 狀態(tài)時顯示效果
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
// 開始菊花轉(zhuǎn)
indecator.startAnimating()
// 顯示 `加載中…`
messageLabel.text = "加載中…"
- 在
默認狀態(tài)
下顯示箭頭事格,隱藏菊花轉(zhuǎn)
case .Normal: // 置為默認的狀態(tài)的效果
UIView.animateWithDuration(0.25, animations: { () -> Void in
self.arrowIcon.transform = CGAffineTransformIdentity
})
messageLabel.text = "下拉刷新"
arrowIcon.hidden = false
indecator.stopAnimating()
運行:測試發(fā)現(xiàn)當(dāng)松手刷新的時候惕艳,顯示的效果能出來,但是當(dāng)一滑動的時候狀態(tài)就發(fā)會了改變驹愚,而
Refreshing
的狀態(tài)改變是由數(shù)據(jù)刷新完成之后去重置远搪,所以更改滑動時候的判斷邏輯
- 更改滑動時的判斷邏輯,以防止
正在刷新中
的時候的狀態(tài)異常改變
// 在用戶拖動的時候去判斷臨界值
if scrollView!.dragging {
if state == .Normal && contentOffsetY < criticalValue {
printLog("完全顯示出來啦")
self.status = .Pulling
}else if status == .Pulling && contentOffsetY >= criticalValue {
printLog("沒有完全顯示出來/沒有顯示出來")
self.status = .Normal
}
}else{
// 判斷如果用戶已經(jīng)松手逢捺,并且當(dāng)前狀態(tài)是.Pulling谁鳍,那么進入到 .Refreshing 狀態(tài)
if self.status == .Pulling {
self.status = .Refreshing
}
}
- 模擬 5 秒后結(jié)束刷新
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
// 設(shè)置狀態(tài)為 默認狀態(tài)
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
})
}
})
運行測試
- 添加要執(zhí)行刷新的方法
hmRefreshControl.addTarget(self, action: "loadData", forControlEvents: UIControlEvents.ValueChanged)
- 在刷新的時候執(zhí)行方法
case .Refreshing: // 顯示刷新的效果
...
// 調(diào)用刷新的方法
sendActionsForControlEvents(.ValueChanged)
- 去掉上面模擬5秒結(jié)束刷新的邏輯,添加結(jié)束刷新方法
func endRefreshing(){
// 重置contentInsetTop
UIView.animateWithDuration(0.25, animations: { () -> Void in
var contentInset = self.scrollView!.contentInset
contentInset.top -= self.frame.height
self.scrollView?.contentInset = contentInset
})
// 設(shè)置狀態(tài)為 默認狀態(tài)
self.status = .Normal
}
- 在刷新完畢之后調(diào)用
HMRefreshControl
的endRefreshing()
方法
/// 結(jié)束刷新
private func endRefresh(){
pullupView.stopAnimating()
hmRefreshControl.endRefreshing()
}
運行測試:第一次啟動的時候劫瞳,刷新完畢倘潜,出現(xiàn)contentInset.top值遞減問題,所以要判斷如果之前狀態(tài)是刷新狀態(tài)志于,結(jié)束刷新才去更改contentInset.top
- 增加保存上一次狀態(tài)的邏輯
// 定義舊狀態(tài)屬性涮因,保存上一次狀態(tài)
var oldStatus: HMRefreshControlStatus?
// 在 `state` 的 `didSet` 方法末尾記錄狀態(tài)
// 定義當(dāng)前控件的刷新狀態(tài)
var status: HMRefreshState = .Normal {
didSet{
switch status {
case .Pulling: // 松手就可以刷新的狀態(tài)
...
case .Normal: // 置為默認的狀態(tài)的效果
...
case .Refreshing: // 顯示刷新的效果
...
}
// 記錄本次狀態(tài)
oldStatus = status
}
}
- 在結(jié)束刷新的時候判斷如果是從
刷新狀態(tài)
進入到默認狀態(tài)
就遞減contentInset.top
/// 結(jié)束刷新
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
})
}
// 設(shè)置狀態(tài)為 默認狀態(tài)
self.state = .Normal
}
- 部分代碼抽取
// 把結(jié)束刷新的邏輯,移動到 state 的 didSet 的 case .Normal 中
switch state {
case .Pulling: // 松手就可以刷新的狀態(tài)
...
case .Normal: // 置為默認的狀態(tài)的效果
...
// 如果之前狀態(tài)是刷新狀態(tài)伺绽,需要遞減 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(){
// 設(shè)置狀態(tài)為 默認狀態(tài)
self.state = .Normal
}
運行測試
下拉刷新提示
- 修改
loadStatuses
养泡,回調(diào)加載成功數(shù)據(jù)條數(shù)
/// 加載微博數(shù)據(jù)的方法
func loadData(isPullUp isPullUp: Bool, completion: (isSuccessed: Bool, count: Int)->()) {
}
- 懶加載提示控件
/// 下拉刷新提示的label
// 提示控件
private lazy var pullDownTipLabel: UILabel = {
let label = UILabel(textColor: UIColor.whiteColor(), fontSize: 12)
// 設(shè)置文字居中、背景顏色
label.textAlignment = NSTextAlignment.Center
label.backgroundColor = UIColor.orangeColor()
// 設(shè)置大小
label.frame.size = CGSizeMake(SCREENW, 35)
return label
}()
- 增加
showPullDownTips
方法奈应,測試添加位置
/// 顯示下拉刷新提示
private func showPullDownTips(count: Int){
pullDownTipLabel.y = 35
navigationController?.view.insertSubview(pullDownTipLabel, belowSubview: navigationController!.navigationBar)
}
- 在下拉刷新完成之后調(diào)用此方法
@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()
// 設(shè)置文字顏色澜掩、文字大小、居中钥组、背景顏色
label.textColor = UIColor.whiteColor()
label.font = UIFont.systemFontOfSize(12)
label.textAlignment = NSTextAlignment.Center
label.backgroundColor = UIColor.orangeColor()
// 設(shè)置大小
label.size = CGSizeMake(SCREENW, 35)
// 默認是隱藏狀態(tài)
label.hidden = true
// 添加控件
if let navigationController = self.navigationController {
navigationController.view.insertSubview(label, belowSubview: navigationController.navigationBar)
}
return label
}()
- 完成顯示邏輯
/// 顯示下拉刷新提示
private func showPullDownTips(count: Int){
// 如果當(dāng)前控件處于顯示狀態(tài)输硝,直接返回
if !pullDownTipLabel.hidden {
return
}
/// 提示文字信息
let tipStr = count==0 ? "沒有微博數(shù)據(jù)": "\(count)條新微博"
let height = pullDownTipLabel.frame.height
pullDownTipLabel.frame.origin.y = CGRectGetMaxY(self.navigationController!.navigationBar.frame) - height
// 設(shè)置文字并將其顯示
pullDownTipLabel.text = tipStr
pullDownTipLabel.hidden = false
//執(zhí)行動畫
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
//動畫執(zhí)行完畢,隱藏
self.pullDownTipLabel.hidden = true
})
}
}