本篇主要讲解如何从列表视图转换到详细视图,及如何使用
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"
},
...
}
我们将循环每一个文件解析出filename
和raw_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
对象,这里我们会使用到NSDateFormatter
。NSDateFormatter
可以将字符串转换为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
对服务器返回的时间进行格式化时,locale
和timeZone
是非常有用的,因为我们的用户可能使用不是同一种语言,位于不同的时区,使用不同的日期格式。但当将时间显示给用户看时,应该使用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的呢?我们必须找出来,并对这些变量进行改名。这里有两种方式可以达到我们的目的:
- 在整个项目中查找"detailItem"
- 尝试编译项目,那么在使用
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
实现UITableViewDataSource
和UITableViewDeletate
。这里我们是可以这么做的,即使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自定义详细视图控制器中的视图,展示你的详细信息。
虽然我们可以看到文件的列表了,但是它还不是很有用。下面让我们来增加一些有用功能:
- 当点击文件名时显示文件的具体内容
- 可让用户收藏/取消收藏该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添加收藏和取消收藏功能。
本章的代码。