本小節(jié)將帶領(lǐng)大家實現(xiàn)App最常用的兩個功能分頁數(shù)據(jù)加載(滾動加載)及下拉刷新。
重要說明: 這是一個系列教程百宇,非本人原創(chuàng)考廉,而是翻譯國外的一個教程。本人也在學(xué)習(xí)Swift携御,看到這個教程對開發(fā)一個實際的APP非常有幫助芝此,所以翻譯共享給大家。原教程非常長因痛,我會陸續(xù)翻譯并發(fā)布婚苹,歡迎交流與分享。
分頁及滾動加載
GitHub在返回數(shù)據(jù)的時候每次僅發(fā)送有限數(shù)量的數(shù)據(jù)鸵膏。假如膊升,我們向GitHub請求所有公共gist數(shù)據(jù)的時候,GitHub并不會返回全部谭企。而是返回16條左右最新的數(shù)據(jù)給我們廓译。如果,我們需要顯示更多數(shù)據(jù)债查,我們需要再次向GitHub請求非区。
如何獲取下一頁
首先讓我們了解一下GitHub是如何提供分頁數(shù)據(jù)的。在GitHub的開發(fā)文檔中有詳細的關(guān)于分頁的描述盹廷,請參閱Pagination章節(jié)征绸。簡而言之,就是在API端點添加?page=2
,然后是?page=3
管怠,然后是?page=4
...
但淆衷,獲取下一頁正確的方式是查看響應(yīng)數(shù)據(jù)中的報頭,具體來說是連接報頭(link header)渤弛。對于公共gist的請求連接報頭看起來如下:
<https://api.github.com/gists/public?page=2>; rel="next",
<https://api.github.com/gists/public?page=100>; rel="last"
因此獲取下一頁的連接為:https://api.github.com/gists/public?page=2
祝拯。如果,我們調(diào)用了該鏈接她肯,那么返回的結(jié)果中的連接報頭將變的復(fù)雜佳头,如下:
<https://api.github.com/gists/public?page=3>; rel="next",
<https://api.github.com/gists/public?page=100>; rel="last",
<https://api.github.com/gists/public?page=1>; rel="first",
<https://api.github.com/gists/public?page=1>; rel="prev"
所以加載更多數(shù)據(jù)我們在這里只需要next
的URL,那么接下來讓我們解析它:
private func getNextPageFromHeaders(response: NSHTTPURLResponse?) -> String? {
if let linkHeader = response?.allHeaderFields["Link"] as? String {
/* looks like:
<https://api.github.com/user/20267/gists?page=2>; rel="next", <https://api.github.com/\
user/20267/gists?page=6>; rel="last"
*/
// so split on "," then on ";"
let components = linkHeader.characters.split {$0 == ","}.map { String($0) }
// now we have 2 lines like
// '<https://api.github.com/user/20267/gists?page=2>; rel="next"'
// So let's get the URL out of there:
for item in components {
// see if it's "next"
let rangeOfNext = item.rangeOfString("rel=\"next\"", options: [])
if rangeOfNext != nil {
let rangeOfPaddedURL = item.rangeOfString("<(.*)>;",
options: .RegularExpressionSearch)
if let range = rangeOfPaddedURL {
let nextURL = item.substringWithRange(range)
// strip off the < and >;
let startIndex = nextURL.startIndex.advancedBy(1)
let endIndex = nextURL.endIndex.advancedBy(-2)
let urlRange = startIndex..<endIndex
return nextURL.substringWithRange(urlRange)
}
}
}
}
return nil
}
Ok晴氨,這段代碼看起來有點復(fù)雜畜晰。讓我們從頭開始逐步解釋一下。首先瑞筐,我們聲明了一個函數(shù),以NSHTTPURLResponse
為參數(shù)腊瑟,并將從報頭中解析出來的下一頁的URL字符串并返回聚假。
private func getNextPageFromHeaders(response: NSHTTPURLResponse?) -> String? {
我們先從請求的返回數(shù)據(jù)中解析出Link
報頭:
if let linkHeader = response?.allHeaderFields["Link"] as? String {
...
}
該報頭有多個格式為:<URL>;rel="type"
的連接信息通過逗號進行組合。因此闰非,我們先將它們拆分成數(shù)組膘格,然后循環(huán)進行處理:
// so split on "," then on ";"
let components = linkHeader.characters.split{$0 == ","}.map{ String($0) }
for item in components {
...
}
在我們循環(huán)進行處理的時候,通過檢測是否含有rel="next"
來獲取下一頁的URL:
for item in components {
// see if it's "next"
let rangeOfNext = item.rangeOfString("rel=\"next\"", options:[])
if rangeOfNext != nil {
// found the component with the next URL
...
}
}
接下來财松,我們需要對component
進行進一步解析瘪贱,得到具體下一頁的URL。在這里我們使用正則表達式來匹配我們希望解析出來的字符串辆毡。正則表達式是一種特殊的字符模式菜秦,用來描述如何對字符串進行搜索。舉個例子舶掖,我們的URL被幾個字符所包圍球昨,如:<(.*)>;
,這里面的.*
就是我們所要解析的URL眨攘。<(.*)>;
就是一個正則表達式主慰,描述了如何在字符串找到URL。因此鲫售,我們可以使用這個模式來解析我們的下一頁URL共螺。然后我們再把不屬于URL的<
和>;
字符移除掉:
let rangeOfNext = item.rangeOfString("rel=\"next\"", options:[])
if rangeOfNext != nil {
let rangeOfPaddedURL = item.rangeOfString("<(.*)>;",
options: .RegularExpressionSearch)
if let range = rangeOfPaddedURL {
let nextURL = item.substringWithRange(range)
// strip off the < and >;
let startIndex = nextURL.startIndex.advancedBy(1)
let endIndex = nextURL.endIndex.advancedBy(-2)
let urlRange = startIndex..<endIndex
return nextURL.substringWithRange(urlRange)
}
}
獲取并追加顯示
現(xiàn)在我們已經(jīng)知道當(dāng)用戶滾動時獲取下一頁數(shù)據(jù)的URL地址。那么應(yīng)該在什么時候進行調(diào)用呢情竹?我們還需要為每個調(diào)用該函數(shù)的都返回這個URL藐不,因此需要把它加到完成處理程序中。我們將擴展完成處理程序的增加一個返回值(即下一頁的URL地址):
func getPublicGists(completionHandler: (Result<[Gist], NSError>, String?) -> Void) {
alamofireManager.request(GistRouter.GetPublic())
.validate()
.responseArray { (response:Response<[Gist], NSError>) in
guard response.result.error == nil,
let gists = response.result.value else {
print(response.result.error)
completionHandler(response.result, nil)
return
}
// need to figure out if this is the last page
// check the link header, if present
let next = self.getNextPageFromHeaders(response.response)
completionHandler(.Success(gists), next)
}
}
現(xiàn)在我們需要增加一個能夠加載后面幾頁的gists的功能。該功能要求能夠通過一個給定的URL來加載gist時佳吞。所以拱雏,我們需要在getGists
和getPublicGists
中修改代碼來實現(xiàn):
func getGists(urlRequest: URLRequestConvertible, completionHandler:
(Result<[Gist], NSError>, String?) -> Void) {
alamofireManager.request(urlRequest)
.validate()
.responseArray { (response:Response<[Gist], NSError>) in
guard response.result.error == nil,
let gists = response.result.value else {
print(response.result.error)
completionHandler(response.result, nil) return
}
// need to figure out if this is the last page
// check the link header, if present
let next = self.getNextPageFromHeaders(response.response)
completionHandler(.Success(gists), next)
}
}
func getPublicGists(pageToLoad: String?, completionHandler:
(Result<[Gist], NSError>, String?) -> Void) {
if let urlString = pageToLoad {
getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler)
} else {
getGists(GistRouter.GetPublic(), completionHandler: completionHandler)
}
}
當(dāng)然,我們還需要修改路由器中的GetAtPath
方法使得能夠返回請求的URL:
enum GistRouter: URLRequestConvertible {
static let baseURLString:String = "https://api.github.com"
case GetPublic() // GET https://api.github.com/gists/public
case GetAtPath(String) // GET at given path
var URLRequest: NSMutableURLRequest {
var method: Alamofire.Method {
switch self {
case .GetPublic:
return .GET
case .GetAtPath:
return .GET
}
}
let result: (path: String, parameters: [String: AnyObject]?) = {
switch self {
case .GetPublic:
return ("/gists/public", nil)
case .GetAtPath(let path):
let URL = NSURL(string: path)
let relativePath = URL!.relativePath!
return (relativePath, nil)
}
}()
let URL = NSURL(string: GistRouter.baseURLString)!
let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path))
let encoding = Alamofire.ParameterEncoding.JSON
let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters)
encodedRequest.HTTPMethod = method.rawValue
return encodedRequest
}
}
因為這里我們得到的是URL全路徑底扳,所以GetAtPath
會變得有點麻煩铸抑。幸好,NSURL
可以讓我們獲取相對路徑衷模。另外鹊汛,我們也可以通過修改let URL = ...
代碼讓它返回我們傳入的全路徑。
如果你的APP也需要這項功能阱冶,那么檢查你的API看看如何獲取更多數(shù)據(jù)刁憋。然后修改你的API管理器中的相應(yīng)
getGists
和getPublicGists
方法。作為替代木蹬,你可能需要明確的傳入一個頁碼至耻,或者你已經(jīng)加載對象的數(shù)目,或者已加載的最后對象的ID镊叁。分頁這個功能在不同API之間可能實現(xiàn)的方式不同尘颓,但是你還是可以使用這里的框架。
與表格視圖整合
現(xiàn)在讓我們轉(zhuǎn)回到MasterViewController
晦譬。一旦我們得到gists疤苹,我們需要更新視圖顯示,并保存下一頁的URL路徑敛腌。我們還需要把數(shù)據(jù)的加載更改為通過指定的URL地址:
class MasterViewController: UITableViewController {
...
var nextPageURLString: String?
...
}
我們在調(diào)用loadGists
時需要把URL地址傳回給它卧土,即使地址為空:
func loadGists(urlToLoad: String?) {
GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in
self.nextPageURLString = nextPage
guard result.error == nil else {
print(result.error)
// TODO: display error
return
}
if let fetchedGists = result.value {
self.gists = fetchedGists
}
self.tableView.reloadData()
}
}
看出來有什么問題么?那這里呢像樊?
if let fetchedGists = result.value {
seft.gists = theGists
}
假如我們在獲取第二頁的時候會發(fā)生什么呢尤莺?這段代碼會把新得到的的數(shù)據(jù)替換掉原有的數(shù)據(jù),而不是添加生棍。下面讓我們修正它:
if let fetchedGists = result.value {
if self.nextPageURLString != nil {
self.gists += fetchedGists
} else {
self.gists = fetchedGists
}
}
這樣就好了缝裁。
在viewDidAppear
中我們會傳一個nil
,這樣就可以加載第一頁的數(shù)據(jù)了:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
loadGists(nil)
}
何時加載更多數(shù)據(jù)足绅?
到這里我們已經(jīng)編寫了很多代碼捷绑,但到底應(yīng)該在什么時候來調(diào)用加載更多gists的代碼呢?我們這里設(shè)定當(dāng)用戶向下滾動時氢妈,如果只剩下最后5條數(shù)據(jù)可以用來顯示時跺讯,進行加載更多的gists:
override func tableView(tableView: UITableView, cellForRowAtIndexPath
indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequenueReusableCellWithIdentifier("Cell", forIndexPath: indexPath)
let gist = gists[indexPath.row]
cell.textLabel!.text = gist.description
cell.detailTextLabel!.text = gist.ownerLogin
cell.imageView?.image = nil
// set cell.imageView to display image at gist.ownerAvatarURL
if let urlString = gist.ownerAvatarURL, url = NSURL(string: urlString) {
cell.imageView?.pin_setImageFromURL(url, placeholderImage:
UIImage(named: "placeholder.png"))
} else {
cell.imageView?.image = UIImage(named: "placeholder.png")
}
// See if we need to load more gists
let rowsToLoadFromBottom = 5
let rowsLoaded = gists.count
if let nextPage = nextPageURLString {
if (!isLoading && (indexPath.row >= (rowsLoaded - rowsToLoadFromBottom))) {
self.loadGists(nextPage)
}
}
return cell
}
就是這里進行加載更多:
let rowsToLoadFromBottom = 5
let rowsLoaded = gists.count
if let nextPage = nextPageURLString {
if (!isLoading && (indexPath.row >= (rowsLoaded - rowsToLoadFromBottom))) {
self.loadGists(nextPage)
}
}
如果我們只剩下最后5條數(shù)據(jù)可以顯示了坚嗜,并且可以加載下一頁數(shù)據(jù),那么我們進行加載(除非我們正在加載,這時候直接略過即可)喝噪。為了加載下一頁數(shù)據(jù),我們只需要調(diào)用loadGists(nextPage)
,并把下一頁的URL地址傳給它即可。
這里我們需要增加一個isLoading
變量觉啊,這樣當(dāng)我們正在加載的時候就不會重復(fù)加載了:
class MasterViewController: UITableViewController {
var detailViewController: DetailViewController? = nil
var gists = [Gist]()
var nextPageURLString: String?
var isLoading = false
...
}
如果我們開始加載的時候,把它設(shè)置為true
:
func loadGists(urlToLoad: String?) {
self.isLoading = true
GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in
self.isLoading = false
selt.nextPageURLString = nextPage
guard result.error == nil else {
print(result.error)
// TODO: display error
return
}
if let fetchedGists = result.value {
if urlToLoad != nil {
self.gists += fetchedGists
} else {
self.gists = fetchedGists
}
}
self.tableView.reloadData()
}
}
修改你的表格視圖沈贝,當(dāng)用戶滾動到底部的時候可以加載更多數(shù)據(jù)杠人。同時,你還的需要處理下一頁數(shù)據(jù)需要的參數(shù)宋下,如:URL地址嗡善、頁碼、已經(jīng)加載的對象數(shù)目或者已加載最后一個對象的ID等学歧。
分頁及滾動加載小結(jié)
在我們的API調(diào)用中增加滾動加載更多功能的確需要費點勁罩引。保存并運行,當(dāng)你向下滾動時會加載更多數(shù)據(jù)枝笨,而不是初始化時加載的那些:
這里可以下載到本章的代碼袁铐。
下拉刷新
在UITableView
中增加下拉刷新功能挺起來好像要做很多工作,但實際上不需要横浑。iOS提供的UIRefreshControl
控件可以讓我們快速輕松的實現(xiàn)這個特性剔桨。本章我們將增加該功能可以用來刷新gists列表。當(dāng)我們完成時界面看起來如下:
添加下拉刷新
在iOS中UITableView
和UIRefreshControl
是為彼此進行設(shè)計的伪嫁。事實上,UITableViewController
已經(jīng)包含了一個refreshControl
的屬性偶垮,只是默認沒有進行初始化而已张咳。因此,我們將創(chuàng)建一個刷新控件似舵,并把它賦值給MasterViewController
脚猾。同時,當(dāng)用戶激活它時將砚哗,我們讓它調(diào)用一個名稱為refresh
的函數(shù):
override func viewWillAppear(animated: Bool) {
self.clearSelectionOnViewWillAppear = self.splitViewController!.collapsed
// add refresh control for pull to refresh
if (self.refreshControl == nil) {
self.refreshControl = UIRefreshControl()
self.refreshControl?.addTarget(self, action: "refresh:",
forControlEvents: UIControlEvents.ValueChanged)
}
super.viewWillAppear(animated)
}
然后添加refresh
方法:
// MARK: -Pull to Refresh
func refresh(sender: AnyObject) {
nextPageURLString = nil // so it doesn't try to append the results
loadGists(nil)
}
如果你現(xiàn)在保存并運行龙助,你會發(fā)現(xiàn)的確會干活,但刷新圖標(biāo)卻不會消失≈虢妫現(xiàn)在讓我們來修正它:
func loadGists(urlToLoad: String?) {
self.isLoading = true
GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in
self.isLoading = false
self.nextPageURLString = nextPage
// tell refresh control it can stop showing up now
if self.refreshControl != nil && self.refreshControl!.refreshing {
self.refreshControl?.endRefreshing()
}
guard result.error == nil else {
print(result.error)
// TODO: display error
return
}
if let fetchedGists = result.value {
if urlToLoad != nil {
self.gists += fetchedGists
} else {
self.gists = fetchedGists
}
}
self.tableView.reloadData()
}
}
保存并運行提鸟。怎么樣,對你做的下拉刷新還滿意么仅淑?
顯示最后刷新時間
當(dāng)你下拉時称勋,如果能夠在下拉控件中顯示最后的刷新時間是非常友好的。因此涯竟,讓我們來完成它赡鲜。這里我們需要一個時間格式化器用來顯示最后刷新時間空厌。但創(chuàng)建一個NSDateFormatter
代價是非常昂貴的(重新設(shè)置格式化方式也是一樣),因此我們將僅創(chuàng)建一個银酬,并保存它后面復(fù)用:
class MasterViewController: UITableViewController {
var dateFormatter = NSDateFormatter()
// ...
override func viewWillAppear(animated: Bool) {
self.clearsSelectionOnViewWillAppear = self.splitViewController!.collapsed
super.viewWillAppear(animated)
// add refresh control for pull to refresh
if (self.refreshControl == nil) {
self.refreshControl = UIRefreshControl()
self.refreshControl?.attributedTitle = NSAttributedString(string: "Pull to refresh")
self.refreshControl?.addTarget(self, action: "refresh:",
forControlEvents: UIControlEvents.ValueChanged)
self.dateFormatter.dateStyle = NSDateFormatterStyle.ShortStyle
self.dateFormatter.timeStyle = NSDateFormatterStyle.LongStyle
}
}
}
然后我們只需要在每次加載完數(shù)據(jù)后把時間設(shè)置到刷新控件的標(biāo)簽上即可:
func loadGists(urlToLoad: String?) {
self.isLoading = true
self.nextPageURLString = nextPage
GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in
self.isLoading = false
// tell refresh control it can stop showing up now
if self.refreshControl != nil && self.refreshControl!.refreshing {
self.refreshControl?.endRefreshing()
}
guard result.error == nil else {
print(result.error)
self.nextPageURLString = nil
return
}
if let fetchedGists = result.value {
if urlToLoad != nil {
self.gists += fetchedGists
} else {
self.gists = fetchedGists
}
}
// update "last updated" title for refresh control
let now = NSDate()
let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now)
self.refreshControl?.attributedTitle = NSAttributedString(string: updateString)
self.tableView.reloadData()
}
}
下拉刷新小結(jié)
保存并運行嘲更。你會發(fā)現(xiàn)每次刷新完成后都會更新刷新控件中的最后更新時間:
在這里可以下載到本章的代碼。