(Swift) iOS Apps with REST APIs(七) -- 分页加载及下拉刷新

本小节将带领大家实现App最常用的两个功能分页数据加载(滚动加载)及下拉刷新。

重要说明: 这是一个系列教程,非本人原创,而是翻译国外的一个教程。本人也在学习Swift,看到这个教程对开发一个实际的APP非常有帮助,所以翻译共享给大家。原教程非常长,我会陆续翻译并发布,欢迎交流与分享。

分页及滚动加载

GitHub在返回数据的时候每次仅发送有限数量的数据。假如,我们向GitHub请求所有公共gist数据的时候,GitHub并不会返回全部。而是返回16条左右最新的数据给我们。如果,我们需要显示更多数据,我们需要再次向GitHub请求。

如何获取下一页

首先让我们了解一下GitHub是如何提供分页数据的。在GitHub的开发文档中有详细的关于分页的描述,请参阅Pagination章节。简而言之,就是在API端点添加?page=2,然后是?page=3,然后是?page=4...

但,获取下一页正确的方式是查看响应数据中的报头,具体来说是连接报头(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。如果,我们调用了该链接,那么返回的结果中的连接报头将变的复杂,如下:

  <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"

所以加载更多数据我们在这里只需要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,这段代码看起来有点复杂。让我们从头开始逐步解释一下。首先,我们声明了一个函数,以NSHTTPURLResponse为参数,并将从报头中解析出来的下一页的URL字符串并返回。

private func getNextPageFromHeaders(response: NSHTTPURLResponse?) -> String? {

我们先从请求的返回数据中解析出Link报头:

if let linkHeader = response?.allHeaderFields["Link"] as? String {
  ...
}

该报头有多个格式为:<URL>;rel="type"的连接信息通过逗号进行组合。因此,我们先将它们拆分成数组,然后循环进行处理:

// so split on "," then on ";"    
let components = linkHeader.characters.split{$0 == ","}.map{ String($0) }
for item in components {
  ...
}

在我们循环进行处理的时候,通过检测是否含有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)
  }
}

获取并追加显示

现在我们已经知道当用户滚动时获取下一页数据的URL地址。那么应该在什么时候进行调用呢?我们还需要为每个调用该函数的都返回这个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)
    }
}

现在我们需要增加一个能够加载后面几页的gists的功能。该功能要求能够通过一个给定的URL来加载gist时。所以,我们需要在getGistsgetPublicGists中修改代码来实现:

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)
  }
}

当然,我们还需要修改路由器中的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看看如何获取更多数据。然后修改你的API管理器中的相应getGistsgetPublicGists方法。作为替代,你可能需要明确的传入一个页码,或者你已经加载对象的数目,或者已加载的最后对象的ID。分页这个功能在不同API之间可能实现的方式不同,但是你还是可以使用这里的框架。

与表格视图整合

现在让我们转回到MasterViewController。一旦我们得到gists,我们需要更新视图显示,并保存下一页的URL路径。我们还需要把数据的加载更改为通过指定的URL地址:

class MasterViewController: UITableViewController {
  ...
  var nextPageURLString: String?
    
  ...
}

我们在调用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
}

假如我们在获取第二页的时候会发生什么呢?这段代码会把新得到的的数据替换掉原有的数据,而不是添加。下面让我们修正它:

if let fetchedGists = result.value {
  if self.nextPageURLString != nil {
    self.gists += fetchedGists
  } else {
    self.gists = fetchedGists
  }
}

这样就好了。

viewDidAppear中我们会传一个nil,这样就可以加载第一页的数据了:

override func viewDidAppear(animated: Bool) {
  super.viewDidAppear(animated)
  
  loadGists(nil)
}

何时加载更多数据?

到这里我们已经编写了很多代码,但到底应该在什么时候来调用加载更多gists的代码呢?我们这里设定当用户向下滚动时,如果只剩下最后5条数据可以用来显示时,进行加载更多的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条数据可以显示了,并且可以加载下一页数据,那么我们进行加载(除非我们正在加载,这时候直接略过即可)。为了加载下一页数据,我们只需要调用loadGists(nextPage),并把下一页的URL地址传给它即可。

这里我们需要增加一个isLoading变量,这样当我们正在加载的时候就不会重复加载了:

class MasterViewController: UITableViewController {
    
  var detailViewController: DetailViewController? = nil
  var gists = [Gist]()
  var nextPageURLString: String?
  var isLoading = false
  ...
}

如果我们开始加载的时候,把它设置为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()
  }
}

修改你的表格视图,当用户滚动到底部的时候可以加载更多数据。同时,你还的需要处理下一页数据需要的参数,如:URL地址、页码、已经加载的对象数目或者已加载最后一个对象的ID等。

分页及滚动加载小结

在我们的API调用中增加滚动加载更多功能的确需要费点劲。保存并运行,当你向下滚动时会加载更多数据,而不是初始化时加载的那些:

这里可以下载到本章的代码。

下拉刷新

UITableView中增加下拉刷新功能挺起来好像要做很多工作,但实际上不需要。iOS提供的UIRefreshControl控件可以让我们快速轻松的实现这个特性。本章我们将增加该功能可以用来刷新gists列表。当我们完成时界面看起来如下:

添加下拉刷新

在iOS中UITableViewUIRefreshControl是为彼此进行设计的。事实上,UITableViewController已经包含了一个refreshControl的属性,只是默认没有进行初始化而已。因此,我们将创建一个刷新控件,并把它赋值给MasterViewController。同时,当用户激活它时将,我们让它调用一个名称为refresh的函数:

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)
}

如果你现在保存并运行,你会发现的确会干活,但刷新图标却不会消失。现在让我们来修正它:

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()
  }
}

保存并运行。怎么样,对你做的下拉刷新还满意么?

显示最后刷新时间

当你下拉时,如果能够在下拉控件中显示最后的刷新时间是非常友好的。因此,让我们来完成它。这里我们需要一个时间格式化器用来显示最后刷新时间。但创建一个NSDateFormatter代价是非常昂贵的(重新设置格式化方式也是一样),因此我们将仅创建一个,并保存它后面复用:

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
    }
  }
}

然后我们只需要在每次加载完数据后把时间设置到刷新控件的标签上即可:

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() 
  }
}

下拉刷新小结

保存并运行。你会发现每次刷新完成后都会更新刷新控件中的最后更新时间:

这里可以下载到本章的代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容