本篇主要講解如何從列表視圖轉換到詳細視圖,及如何使用
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添加收藏和取消收藏功能。
本章的代碼。