(Swift)iOS Apps with REST APIs(十二) -- 视图控制器切换及数组、日期格式的JSON解析

本篇主要讲解如何从列表视图转换到详细视图,及如何使用SFSafariViewController来显示一个Web视图。此外,还讲解了如何对JSON中的数组及日期进行解析。

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

到目前为止我们的Swift应用具有的功能有:

  • 使用Alamofire框架通过GitHub Gists API获取数据
  • 使用自定义响应序列化将JSON数据解析为Gist对象数组
  • 将结果显示在表格视图中,并通过URL加载图片
  • 可以让用户选择3种不同的列表
  • 滚动加载更多
  • 下拉刷新

在本章中我们将添加更多“实际”可用的功能,这些功能包含:

  • 从JSON中解析对象数组(这里需要解析的是文件列表)以及日期解析(使用NSDateFormatter)
  • 表格视图与详情视图之间的数据传递,当用户在列表中点击一行时将在详情视图中打开该Gist,并显示更详细的数据
  • 使用代码创建一个新的视图控制器,用来显示Gist中的文件内容。当用户点击文件名称时将在一个web视图中显示该文件的内容

后面两个功能使用了不同的方式进行视图控制器的切换。到详细视图的转换使用了segue,到文件内容视图的转换则使用了导航控制器将一个新的视图控制器压入栈中。

JSON解析:数组和日期

当我们的APP启动时,就会通过API调用获取一个JSON格式的Gist列表,然后通过我们自定义的响应序列化将JSON数据转换成Gist对象。到目前为止,我们仅仅解析了JSON数据中的字符串,接下来我们将解析JSON数据中的文件数组及Gist的创建日期和最后更新日期。

首先在Gist类中增加几个属性:

class Gist: ResponseJSONObjectSerializable { 
  var id: String?
  var description: String?
  var ownerLogin: String?
  var ownerAvatarURL: String? 
  var url: String?
  var files:[File]?
  var createdAt:NSDate?
  var updatedAt:NSDate?
  ...
}

我们还需要一个来负责Gist中的文件信息(一个Gist可能包含多个文件)的类。对于Gist中的文件我们只需要文件名称以及文件具体内容的URL,然后我们可以在web视图中显示具体的文件内容。下面让我们创建一个File.swift文件,并实现File类:

import SwiftyJSON

class File: ResponseJSONObjectSerializable { 
  var filename: String?
  var raw_url: String?
}

Gist一样,File类需要实现一个从JSON数据创建对象的方法,因此需要实现ResponseJSONObjectSerializable协议:

class File: ResponseJSONObjectSerializable { 
  var filename: String?
  var raw_url: String?

  required init?(json: JSON) { 
    self.filename = json["filename"].string 
    self.raw_url = json["raw_url"].string
  }
}

然后扩展Gists的初始化方法对文件进行解析(我们后面再对日期进行处理):

required init?(json: JSON) {
  self.description = json["description"].string
  self.id = json["id"].string
  self.ownerLogin = json["owner"]["login"].string 
  self.ownerAvatarURL = json["owner"]["avatar_url"].string 
  self.url = json["url"].string
  
  // files
  self.files = [File]()
  if let filesJSON = json["files"].dictionary {
    for (_, fileJSON) in filesJSON {
      if let newFile = File(json: fileJSON) {
        self.files?.append(newFile) 
      }
    }
  }
  
  // TODO: dates
}

对于Gist中的文件,我们创建一个数组:self.files = [File]()

在JSON数据中文件列表格式为:

"files": {
  "ring.erl": {
    "size": 932,
    "raw_url": "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0\ff512853564e/ring.erl",
    "type": "text/plain", 
    "language": "Erlang", 
    "truncated": false, 
    "content": "content of gist"
  },
  ...
}

我们将循环每一个文件解析出filenameraw_url(译者注:这里给出的JSON格式中并没有filename属性,GitHub的API文档中也没有,但实际返回的数据中是有filename属性的):

if let filesJSON = json["files"].dictionary { 
  for (_, fileJSON) in filesJSON {
    if let newFile = File(json: fileJSON) { 
      self.files?.append(newFile)
    }
  }
}

因为filesJSON是一个字典类型(dictionary),所以我们可以使用for(_, fileJSON) in filesJSON循环每一个文件。这里_是一个字典key的占位符,表示我们不会使用该值。然后,我们将尝试使用JSON数据创建一个File对象,如果创建成功,将它添加到文件数组中:

if let newFile = File(json: fileJSON) { 
  self.files?.append(newFile)
}

以上就是我们从JSON中解析文件数组的代码。

日期解析

对于日期改如何进行解析呢?因为我们从服务器中获得的日期是字符串格式的,我们希望能够把它转换为NSDate对象,这里我们会使用到NSDateFormatterNSDateFormatter可以将字符串转换为NSDate,也可以将NSDate转换为字符串。

服务器返回的日期格式为:2014-12-10T16:44:31.486000Z。为了可以解析该字符串我们需要设置一些参数:

class func dateFormatter() -> NSDateFormatter {
  let aDateFormatter = NSDateFormatter()
  aDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
  aDateFormatter.timeZone = NSTimeZone(abbreviation: "UTC")
  aDateFormatter.locale = NSLocale(localeIdentifier: "en_US_POSIX")
  return aDateFormatter
}

当使用NSDateFormatters对服务器返回的时间进行格式化时,localetimeZone是非常有用的,因为我们的用户可能使用不是同一种语言,位于不同的时区,使用不同的日期格式。但当将时间显示给用户看时,应该使用NSDateFormatterShortStyle这样用户将看到更符合他们习惯日期格式。

现在我们就可以使用日期格式化对创建时间和更新时间进行解析:

class Gist { 
  ...

  required init?(json: JSON) { 
    ...

    // Dates
    let dateFormatter = Gist.dateFormatter()
    if let dateString = json["created_at"].string {
      self.createdAt = dateFormatter.dateFromString(dateString) 
    }
    if let dateString = json["updated_at"].string { 
      self.updatedAt = dateFormatter.dateFromString(dateString)
    }
  }
  
  ...
}

创建一个NSDateFormatter及改变它的属性代价都是非常高昂的。这段代码中每次进行Gist解析时都会创建一个新的NSDateFormatter,因此这里我将对它进行优化,让所有的解析都使用一个NSDateFormatter实例。代码如下:

static let sharedDateFormatter = Gist.dateFormatter() 
required init?(json: JSON) {
  ...
  
  let dateFormatter = Gist.sharedDateFormatter 
  if let dateString = json["created_at"].string {
    self.createdAt = dateFormatter.dateFromString(dateString) 
  }
  if let dateString = json["updated_at"].string { 
    self.updatedAt = dateFormatter.dateFromString(dateString)
  }
}

现在我们运行,它看起来也没有什么不同。不用担心,获取并显示数据并没有多少困难。下面让我们来完善一开始创建项目时就创建的详细视图控制器。处理的流程大致如下,当我们在列表视图中点击了一个Gist时,将转换到详细视图并显示该Gist的详细信息。

回头检查一下业务对象的JSON数据。并从中间选取几个属性用来在详细视图中显示,这些属性可能是日期或者一些数组。将它们添加到实体类中并解析。

配置详细视图控制器

打开DetailViewController文件,它看起来如下:

import UIKit
class DetailViewController: UIViewController {
  
  @IBOutlet weak var detailDescriptionLabel: UILabel!
  
  var detailItem: AnyObject? { 
    didSet {
      // Update the view.
      self.configureView() 
    }
  }
  
  func configureView() {
    // Update the user interface for the detail item. 
    if let detail = self.detailItem {
      if let label = self.detailDescriptionLabel { 
        label.text = detail.description
      }
    }
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view, typically from a nib. 
    self.configureView()
  }
  
  override func didReceiveMemoryWarning() { 
    super.didReceiveMemoryWarning()
    // Dispose of any resources that can be recreated.
  }
}

在故事板中详细页面视图也是非常简单的,它紧紧显示了一个标签,该视图我们将用来显示详细信息。

我们接下来将完善该UI,但是首先我们先将详细对象的类型由通用的对象AnyObject更改为我们所要显示的Gist。同时我们还需要将修改变量的名称,修改后的代码如下:

var gist: Gist? { 
  didSet {
    // Update the view.
    self.configureView()
  }
}
func configureView() {
  // Update the user interface for the detail item. 
  if let currentGist = self.gist {
    if let label = self.detailDescriptionLabel { 
      label.text = currentGist.description
    }
  }
}

那,该视图是从哪里获取Gist的呢?我们必须找出来,并对这些变量进行改名。这里有两种方式可以达到我们的目的:

  1. 在整个项目中查找"detailItem"
  2. 尝试编译项目,那么在使用detailItem的地方编译器就会报错

我们不应该逐个去找detailItem。在Objective-C中我们可以通过重构工具直接对变量进行重命名。但swift中还没有提供该方法。希望该工具可以早点实现。

通过上面的方法,我们在详细视图控制器之外找到了2处使用的地方。下面我们需要使用gist替换这两个地方。

AppDelegate中:

func splitViewController(splitViewController: UISplitViewController, 
  collapseSecondaryViewController secondaryViewController:UIViewController,  
  ontoPrimaryViewController primaryViewController:UIViewController) -> Bool {
    guard let secondaryAsNavController = secondaryViewController as? 
      UINavigationController else { return false }
    guard let topAsDetailController = secondaryAsNavController.topViewController 
      as? DetailViewController else { return false }
    if topAsDetailController.gist == nil {
      // Return true to indicate that we have handled the collapse by doing nothing 
      // the secondary controller will be discarded.
      return true
    }
    return false
}

另外一个是在MasterViewController中,表格视图将选中的gist传递给DetailViewController进行显示。这里我们还要将类型由NSData修改为Gist就像我们修改名称一样。我们还要检查导航控制器中的顶层视图控制器是否是DetailViewController,而不是假定它是,以防万一我们曾经改变了导航:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { 
  if segue.identifier == "showDetail" {
    if let indexPath = self.tableView.indexPathForSelectedRow {
      let gist = gists[indexPath.row] as Gist
      if let detailViewController = (segue.destinationViewController as!
        UINavigationController).topViewController as? 
        DetailViewController {
        detailViewController.gist = gist 
        detailViewController.navigationItem.leftBarButtonItem =
          self.splitViewController?.displayModeButtonItem() 
        detailViewController.navigationItem.leftItemsSupplementBackButton = true
      }
    }
  }
}

更改你的DetailViewController中的实体对象模型的类,并修改其它地方对它的引用。

现在我们可以编译并运行了。当我们在列表视图中点击了一个Gist后,详细视图中将会显示它的描述:

在Segue中传递数据

但这是怎么工作的呢?在故事板中我们可以看到在MasterViewController的表格视图的单元格中有一个到DetailViewController的连接(实际上是导航控制器持有DetailViewController,但那只是让我们又一个更好的标题栏而已):

可以使用Editor-Canvas->Zoom进行放大。Segue就在那里。

因此,当在表格视图中点击时Segue就会被激活。当从表格视图转换(也就是"segue")到详细视图时就会调用prepareForSeque方法。

prepareForSegue中:

  • 获取我们将要转换到的视图控制器: let controller = (segue.destinationViewController as! UINavigationController).topViewController as! DetailViewController
  • 通过indexPathForSelectedRow()获取所点击的行。当我们表格视图中有多个区段时,indexPath可以获取到选中的行及区段信息
  • 获取选中的Gist: let object = gists[indexPath.row]
  • 将它传递给目标视图控制器: controller.gist = object

这就是Xcode为我们构建的Master-Detail项目。我使用我们所创建的Gist来更新:

// MARK: - Segues
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { 
  if segue.identifier == "showDetail" {
    if let indexPath = self.tableView.indexPathForSelectedRow {
      let gist = gists[indexPath.row] as Gist
      if let detailViewController = (segue.destinationViewController as!
        UINavigationController).topViewController as? DetailViewController {
        detailViewController.detailItem = gist 
        detailViewController.navigationItem.leftBarButtonItem =
          self.splitViewController?.displayModeButtonItem() 
        detailViewController.navigationItem.leftItemsSupplementBackButton = true
      }
    }
  }
}

添加一个表视图

接下来让我们通过使用表视图显示数据来完善视图显示。首先,我们让DetailViewController实现UITableViewDataSourceUITableViewDeletate。这里我们是可以这么做的,即使DetailViewController不是一个UITableViewController。当表视图只是视图的一部分时这种方式就非常有用,比如你希望在表视图上面有一个头,它并不随着表视图的滚动而滚动。另外,我们还需要增加一个对表视图的引用,并删除原来那个标签控件:

class DetailViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { 
  @IBOutlet weak var tableView: UITableView!
  ...

下面让我们转到主故事板,这样我们就可以添加表视图了。打开Main.Storyboard并选择DetailViewController

在详细视图控制器中,先删除标签,然后将表视图拖拽到控制器中,并让它充满整个视图:

然后向表视图中添加一个原型单元格。然后把identifier设置为"Cell"。

在右上方的面版中选择connection organizer(最后一个页签,圆形中间有一个小箭头)。将详细视图控制器连接成为表视图的数据源(data source)和代理(delegate)。然后将详细视图中的tableView IBOutlet链接到表视图:

现在我们就可以转会到详细视图控制器,为表视图填充数据并显示。这里我们有2个区段:一个用来显示gist的基础数据,如描述等;另外一个用来显示gist中的文件列表。然后当点击文件时,显示它们的详细内容。

因为我们有两个区段,我们还需要为它们设置相应的标题。第一个区段有两个项目需要显示(描述信息和拥有者信息),第二个区段需要显示所包含文件的列表:

func numberOfSectionsInTableView(tableView: UITableView) -> Int { 
  return 2
}

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 
  if section == 0 {
    return 2 
  } else {
    return gist?.files?.count ?? 0 
  }
}
func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 
  if section == 0 {
    return "About" 
  } else {
    return "Files" 
  }
}

??是空和运算符。它表示,如果不为空就使用该值,否则就使用默认值。因此return gist?.files?.count ?? 0将返回文件的个数,如果gist中没有文件就返回0.

这样我们就可以使用这些数据来填充表视图的单元格了:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath)
  
  if indexPath.section == 0 { 
    if indexPath.row == 0 {
      cell.textLabel?.text = gist?.description 
    } else if indexPath.row == 1 {
      cell.textLabel?.text = gist?.ownerLogin
    }
  } else {
    if let file = gist?.files?[indexPath.row] {
      cell.textLabel?.text = file.filename
    }
  }
  return cell 
}

当gist赋值后我们还得告诉表视图重新进行加载:

func configureView() {
  // Update the user interface for the detail item. 
  if let detailsView = self.tableView {
    detailsView.reloadData()
  }
}

现在就可以编译运行了。

构建你的表视图或者使用IBOutlet自定义详细视图控制器中的视图,展示你的详细信息。

虽然我们可以看到文件的列表了,但是它还不是很有用。下面让我们来增加一些有用功能:

  1. 当点击文件名时显示文件的具体内容
  2. 可让用户收藏/取消收藏该gist

显示文件内容

我们在解析JSON数据的时候,并没有获取到文件的具体内容,而是获取到这些文件的URL路径。我们可以使用web视图来显示这个URL。当然还有更好的方式,但现在使用web视图就足够了。

在处理OAuth的时候我们已经引入了Safari Services framework。如果你还没有做,那么你需要把它添加到工程中。

DetailViewController中引入SafariServices框架:

import UIKit
import SafariServices
class DetailViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { 
...
}

当用户点击一个文件名时:

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) { 
  if indexPath.section == 1 {
    if let file = gist?.files?[indexPath.row], 
      urlString = file.raw_url,
      url = NSURL(string: urlString) {
        let safariViewController = SFSafariViewController(URL: url) 
        safariViewController.title = file.filename 
        self.navigationController?.pushViewController(safariViewController, animated: true)
    }
  }
}

当用户点击文件名时,我们可以获取到相应的URL:

if let file = gist?.files?[indexPath.row], 
  urlString = file.raw_url,
  url = NSURL(string: urlString) {

然后我们就创建一个Safari视图控制器,并把URL传递给它显示,在导航栏中显示文件名称:

let safariViewController = SFSafariViewController(URL: url) safariViewController.title = file.filename

最后使用导航控制器切换到该视图:

self.navigationController?.pushViewController(safariViewController, animated: true)

使用导航控制器就意味着在新的视图中显示一个退回按钮,并且允许使用滑动手势返回。这样我们也就不用增加新的按钮来处理这些功能了。

看看你的工程中是否有那些可以使用web视图进行显示的。如果有,就按照上面的步骤实现它。

小结

接下来我们将为gist添加收藏和取消收藏功能。

本章的代码

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

推荐阅读更多精彩内容

  • 本文将继续前面的教程,继续讲解如何通过REST API获取数据列表并解析为Swift对象,然后显示在表格视图中。 ...
    CD826阅读 1,117评论 0 6
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,594评论 18 139
  • WebSocket-Swift Starscream的使用 WebSocket 是 HTML5 一种新的协议。它实...
    香橙柚子阅读 23,690评论 8 183
  • 安徽安庆大观十里中心学校 汪琼 题记: 当你用祈祷使一天终结, 亲爱的,你那一双小手, 我想,也该用来为那些 丢失...
    犟龟汪琼阅读 161评论 0 1
  • 爱情是一朵带刺的玫瑰—是我见过对爱情最简短最精炼的解读。 我们总是被玫瑰娇艳红火的外表和迷人的香气给吸引,一度沉沦...
    两颗星blingbling阅读 199评论 1 0