在第五章中,您了解了 UITabBarController 以及它如何允許用戶訪問不同的屏幕。 標簽欄控制器非常適合彼此獨立的屏幕,但如果您有多個提供相關信息的屏幕怎么辦?
例如,Settings
應用程序具有多個相關信息屏幕:一個設置列表(如Sounds
),每個設置的詳細頁面以及每個詳細信息的選擇頁面(圖14.1)。 這種類型的界面稱為 向下鉆取界面(drill-down interface
)。
圖14.1“Settings” 中的向下鉆取界面
在本章中,您將使用 UINavigationController 向 Homepwner
添加一個 向下鉆取界面,該界面允許用戶查看和編輯 Item 的詳細信息。 這些詳細信息將由您在第十三章(圖14.2)中創建的 DetailViewController 顯示。
圖14.2 使用 UINavigationController 的 Homepwner
UINavigationController
UINavigationController 包含一個視圖控制器數組,用于在棧中呈現相關信息。 當 UIViewController 位于棧頂部時,其視圖是可見的。
你將使用 UIViewController 初始化一個 UINavigationController 的實例。 該 UIViewController 被添加到導航控制器的 viewControllers
數組,并成為導航控制器的根視圖控制器。 根視圖控制器始終位于棧底。 (請注意,盡管該視圖控制器被稱為導航控制器的 “根視圖控制器”,然而 UINavigationController 沒有 rootViewController
這個屬性。)
更多的視圖控制器可以在應用程序運行時被 push 到 UINavigationController 的棧頂。 這些視圖控制器被添加到對應于棧頂的 viewControllers
數組的末尾。
UINavigationController 的 topViewController
屬性保留對堆棧頂部的視圖控制器的引用。
當一個視圖控制器 壓棧(push) 時,它的視圖就會從右邊移到屏幕上。當 彈棧(pop) 時(例:最后一項被移除時),頂部視圖控制器從棧中移開,它的視圖滑到右邊,將下一個視圖控制器的視圖暴露在棧上并將成為頂部視圖控制器。圖14.3顯示了一個帶有兩個視圖控制器的導航控制器。topViewController
的視圖是用戶所看到的。
圖14.3 UINavigationController 的棧
UINavigationController 是 UIViewController 的子類,因此它具有自己的視圖。它的視圖總是有兩個子視圖:一個 UINavigationBar 和 topViewController
的視圖(圖14.4)。
圖14.4 UINavigationController 的視圖
在本章中,您將向 Homepwner
應用程序添加一個 UINavigationController,并使 ItemsViewController 成為 UINavigationController 的根視圖控制器。 當選擇 Item 時, DetailViewController 將被 push 到 UINavigationController 的棧上。該視圖控制器將允許用戶查看和編輯在 ItemsViewController 的表視圖中選中的 Item 的屬性。 更新的 Homepwner
應用程序的對象圖如圖14.5所示。
圖14.5 Homepwner 對象圖
這個應用程序變得相當大,你可以看到。 幸運的是,視圖控制器和 UINavigationController 知道如何處理這種復雜的對象圖。 在編寫iOS應用程序時,將每個 UIViewController 視為自己的小世界很重要。 在 Cocoa Touch 中已經實施的內容將會起到舉足輕重的作用。
首先重新打開 Homepwner
項目,給 Homepwner
添加一個導航控制器。 使用 UINavigationController 的唯一要求是您給它一個根視圖控制器,并將其視圖添加到窗口。
打開 Main.storyboard
并選擇 Items View Controller
。 然后,從 Editor
菜單中選擇
Embed In
→ Navigation Controller
。 這將將 ItemsViewController 設置為 UINavigationController 的根視圖控制器。 它還將更新故事板,將 Navigation Controller
設置為初始視圖控制器。
您的 Detail View Controller
界面現在可能會放錯位置,因為它被包含在導航控制器中。 如果是,請選擇棧視圖,然后單擊自動布局約束菜單中的 Update Frames
按鈕。
構建并運行應用程序,然后應用程序崩潰了。 發生了什么? 您之前與 AppDelegate
創建 關聯( contract
)的是 ItemsViewController 的實例,這將會是窗口的
rootViewController
:
let itemsController = window!.rootViewController as! ItemsViewController
您現在通過將 ItemsViewController 嵌入到 UINavigationController 中來破壞此關聯。 您需要更新此關聯。
打開 AppDelegate.swift
并更新 application(_:didFinishLaunchingWithOptions :) 以反映新的視圖控制器層級。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]?) -> Bool {
??// Override point for customization after application launch.
??// Create an ItemStore
??let itemStore = ItemStore()
??// Access the ItemsViewController and set its item store
??let itemsController = window!.rootViewController as! ItemsViewController
??let navController = window!.rootViewController as! UINavigationController
??let itemsController = navController.topViewController as! ItemsViewController
??itemsController.itemStore = itemStore
??return true
}
再次構建并運行應用程序。 Homepwner
再次正常運行,在屏幕頂部有一個非常好看的(雖然是空的) UINavigationBar (圖14.6)。
圖14.6具有空導航欄的Homepwner
請注意屏幕是如何調整來適應 ItemsViewController 的視圖以及新的導航欄的。UINavigationController 為您做了這樣的事情:雖然 ItemsViewController 的視圖實際上是在導航欄上進行的,但 UINavigationController 在頂部添加了 padding
,使一切都很適合。這是因為視圖控制器的頂部布局指南被調整了,以及所有視圖都被約束在頂部的布局指南中——就像棧視圖一樣。
UINavigationController 導航
在應用程序仍在運行的情況下,創建一個新 item 并從 UITableView 中選擇該行。你不僅被帶到 DetailViewController 的視圖中,而且你還可以在 UINavigationBar 中獲得一個自帶的動畫和一個 Back
按鈕。點擊這個按鈕,你將回到 ItemsViewController。
注意,你不需要改變你在第13章創建的 show segue 來得到這個行為。正如在那一章中所提到的,show segue 以一種有意義的方式呈現了目標視圖控制器。當一個 show segue 從嵌入在導航控制器中的視圖控制器觸發時,目標視圖控制器被 push 到導航控制器的視圖控制器棧中。
因為 UINavigationController 的棧是一個數組,它將擁有對添加到它的任何視圖控制器的所有權。因此,DetailViewController 只在 segue 完成之后由 UINavigationController 擁有。當棧被彈出時, DetailViewController 會被銷毀。下一次當一行被點擊時,會創建一個 DetailViewController 的新實例。
使用一個視圖控制器來 push
下一個視圖控制器是一個常見的模式。根視圖控制器通常創建下一個視圖控制器,下一個視圖控制器創建后一個視圖控制器,等等。有些應用程序可能可以根據用戶輸入來 push 不同的視圖控制器。例如,根據選擇的媒體類型,Photos
應用程序將視頻視圖控制器或圖像視圖控制器 push 到導航棧上。
請注意,item 的詳細信息視圖包含所選 Item 的信息。然而,當您可以編輯這些數據時, UITableView 在返回時不會反映這些更改。為了解決這個問題,您需要實現代碼來更新正在編輯的 Item 的屬性。在下一節中,您將看到何時執行此操作。
顯示和消失的視圖
每當一個 UINavigationController 即將交換視圖時,它會調用兩個方法: viewWillDisappear(_ :) 和 viewWillAppear(_ :)。 當 viewWillDisappear(_ :) 被調用,UIViewController 將從棧彈出。 然后,當 viewWillAppear(_ :) 被調用,UIViewController 將處在該棧頂。
為了保持數據更改,當 DetailViewController 從棧中彈出時,您將將其 item 的屬性設置為文本字段的內容。 在實現這些視圖顯示和消失的方法時,重要的是調用超類的實現——它可能要做一些事,或者需要機會去做一些事。 在 DetailViewController.swift
中,實現 viewWillDisappear(_ :) 。
override func viewWillDisappear(_ animated: Bool) {
??super.viewWillDisappear(animated)
??// "Save" changes to item
??item.name = nameField.text ?? ""
??item.serialNumber = serialNumberField.text
??if let valueText = valueField.text, let value = numberFormatter.number(from: valueText) {
????item.valueInDollars = value.intValue
??} else {
????item.valueInDollars = 0
??}
}
現在當用戶點擊 UINavigationBar 上的 back
按鈕時,Item 的值將被更新。 當 ItemsViewController 出現在屏幕上時,方法 viewWillAppear(_ :) 被調用。 借此機會重新加載 UITableView ,以便用戶立即看到更改。
在 ItemsViewController.swift
中,重寫 viewWillAppear(_ :) 來重新加載表視圖。
override func viewWillAppear(_ animated: Bool) {
??super.viewWillAppear(animated)
??tableView.reloadData()
}
再次構建并運行應用程序。 現在,您可以在創建的視圖控制器之間來回移動,并輕松更改數據。
取消鍵盤
運行應用程序,添加并選擇一個 item,然后觸摸 item 的 Name 文本字段。 當您觸摸文本字段時,屏幕上會顯示一個鍵盤(圖14.7),如您在第4章中的 WorldTrotter
應用程序中所看到的。(如果您使用的是模擬器,鍵盤沒有出現,請記住可以按 Command-K
切換設備鍵盤。)
圖14.7 觸摸文本字段時出現鍵盤
UITextField 類以及 UITextView 內置了鍵盤響應觸摸的外觀,因此您無需為鍵盤出現做任何額外的操作。但是,有時您會希望確保鍵盤的行為符合您的要求。
舉個例子,注意到鍵盤覆蓋了屏幕的三分之一以上。 現在,它并沒有遮住任何東西,但是很快你會添加更多的詳細信息,擴展到屏幕的底部,當用戶不需要鍵盤時需要一種方法來隱藏它。 在本節中,您將給用戶兩種方法來關閉鍵盤:按下鍵盤的 Return 鍵,或者點擊詳情視圖控制器視圖上的其他任何位置。 但首先,我們來看看使文本可編輯的事件組合。
事件處理基礎知識
觸擊視圖時,會創建一個事件。 此事件(稱為“觸摸事件”)與視圖控制器視圖中的特定位置相關聯。 該位置確定觸摸事件將傳遞到層級中的哪個視圖。
例如,當您在其邊界內點擊一個 UIButton 時,它會收到觸摸事件并以按鈕的形式進行響應——通過在其目標上調用動作方法。 當您的應用程序中的視圖被觸摸時,該視圖會接收到觸摸事件,并且可以選擇對該事件做出反應或忽略它。 但是,您的應用程序中的視圖也可以響應非觸摸事件。 一個很好的例子是搖一搖。 如果您在運行應用程序時晃動設備,您的其中一個視圖就可以響應。 但是是哪一個呢? 另一個有趣的情況是響應鍵盤。 DetailViewController 的視圖包含三個 UITextFields。 用戶輸入時哪個會接收到文本?
對于震動和鍵盤事件,在視圖層級中沒有事件位置來確定哪個視圖將接收該事件,因此必須使用另一個機制。這個機制是 第一響應者(first responder
) 狀態。許多視圖和控件可以是視圖層級中的第一響應者,但一次只能有一個響應者。可以把它看作可以在視圖中傳遞的標志。無論哪個視圖持有該標志,都將接收震動或鍵盤事件。
UITextField 和 UITextView 的實例對觸摸事件有一個不尋常的響應。 觸摸時,文本字段或文本視圖將成為第一響應者,反而會觸發系統將鍵盤顯示在屏幕上,并將鍵盤事件發送到文本字段或視圖。 鍵盤和文本字段或視圖沒有直接的連接,但它們通過第一響應者狀態協同工作。
這是確保將鍵盤輸入傳遞到正確文本字段的整潔方法。 第一響應者的概念只是 Cocoa Touch 編程中包含 UIResponder 類和 響應者鏈(responder chain
) 的更廣泛的事件處理主題的一部分。 當您處理第18章中的觸摸事件時,您將了解更多信息,您還可以訪問Apple的 `Event Handling Guide for iOS* 了解更多信息。
按Return鍵退出
現在讓我們回到允許用戶關閉鍵盤。 如果您觸摸應用程序中的另一個文本字段,則該文本字段將成為第一個響應者,鍵盤將保留在屏幕上。 當沒有文本字段(或文本視圖)是第一個響應者時,鍵盤將被放棄并離開。 要關閉鍵盤,那么您需要在第一個響應者的文本字段上調用 resignFirstResponder()。
要使文本字段響應于按下Return鍵,您將要實現 UITextFieldDelegate 方法 textFieldShouldReturn(_ :)。 只要按下Return鍵,就會調用此方法。
首先,在 DetailViewController.swift
中,使 DetailViewController 符合 UITextFieldDelegate 協議。
class DetailViewController: UIViewController,
UITextFieldDelegate {
接下來,在傳入的文本字段上實現 textFieldShouldReturn(_ :) 來調用 resignFirstResponder()。
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
??textField.resignFirstResponder()
??return true
}
最后,打開 Main.storyboard
并將每個文本字段的 delegate
屬性連接到 Detail View Controller
(圖14.8)。(右鍵從每個 UITextField 拖動到 Detail View Controller
并選擇 delegate
。)
圖14.8 連接文本字段的委托屬性
構建并運行應用程序。 點擊文本字段,然后按鍵盤上的 Return 鍵。 鍵盤將消失。 要使鍵盤返回,請觸摸任何文本字段。
點擊其他地方返回
如果用戶在 DetailViewController 的視圖上輕觸其他任何地方,也應該會關閉鍵盤。 為了做到這一點,當視圖被觸摸時,您將使用手勢識別器,就像在 WorldTrotter
應用程序中一樣。 在動作方法中,您將在文本字段上調用 resignFirstResponder() 。
打開 Main.storyboard
并在對象庫中找到 Tap Gesture Recognizer
。 將此對象拖動到 Detail View Controller
的背景視圖上。 您將在 scee 底部中看到此手勢識別器的引用。
在項目導航器中,右擊 DetailViewController.swift 在 assistant editor
中打開它。 右鍵從故事板中的 tap gesture recognizer 拖動到 DetailViewController 的實現類。
在出現的彈出窗口中,從 Connection
菜單中選擇 Action
。 命名動作 backgroundTapped 。 對于 Type
,選擇 UITapGestureRecognizer
(圖14.9)。
圖14.9 配置UITapGestureRecognizer操作
單擊 Connect
,動作方法的存根將顯示在 DetailViewController.swift
中。 在 DetailViewController 的視圖上更新調用 endEditing(_ :) 的方法。
@IBAction func backgroundTapped(_ sender: UITapGestureRecognizer) {
??view.endEditing(true)
}
調用 endEditing(_ :) 是一種方便的方式來解除鍵盤,你不必知道(或關心)哪個文本字段是第一個響應者。 當視圖獲得此調用時,它將檢查其層級中的任何文本字段是否是第一個響應者。 然后會在該特定的視圖上調用 resignFirstResponder()。
構建并運行您的應用程序。 點擊文本字段以顯示鍵盤。 點擊文本字段外的視圖,鍵盤也將消失。
最后一個需要關掉鍵盤的情況。 當用戶點擊后退按鈕時,在將其從彈出的堆棧之前的 DetailViewController 上調用 viewWillDisappear(_ :),并且鍵盤立即消失,沒有動畫。 要更順利地關閉鍵盤,請在 DetailViewController.swift
中更新 viewWillDisappear(_ :) 的實現,以調用 endEditing(_ :) 。
override func viewWillDisappear(_ animated: Bool) {
??super.viewWillDisappear(animated)
??// Clear first responder
??view.endEditing(true)
??// "Save" changes to item
??item.name = nameField.text ?? ""
??item.serialNumber = serialNumberField.text
??if let valueText = valueField.text, let value = numberFormatter.number(from: valueText) {
????item.valueInDollars = value.integerValue
??} else {
????item.valueInDollars = 0
??}
}
UINavigationBar
在本節中,您將聲明 UINavigationBar, 一個 UIViewController 的描述性標題,并正好位于 UINavigationController 的棧頂。
每個 UIViewController 都有一個類型為 UINavigationItem 的 navigationItem
屬性。 但是,與 UINavigationBar 不同的是,UINavigationItem 不是 UIView 的子類,所以它不能出現在屏幕上。 相反,導航項目為導航欄提供了需要繪制的內容。 當一個 UIViewController 到達 UINavigationController 的棧頂時,UINavigationBar 使用 UIViewController 的 navigationItem
進行配置,如圖14.10所示。
圖14.10 UINavigationItem
默認情況下,UINavigationItem 為空。 在最基本的層次上, UINavigationItem 有一個簡單的 title
字符串。 當 UIViewController 移動到導航棧頂,并且其 navigationItem
具有有效字符串的 title
屬性時,導航欄將顯示該字符串(圖14.11)。
圖14.11 帶有標題的UINavigationItem
ItemsViewController 的標題將始終保持不變,因此您可以在故事板本身中設置其導航項的標題。
打開 Main.storyboard
。 雙擊 Items View Controller
上方導航欄的中央以編輯其標題。 給它一個標題 “Homepwner”(圖14.12)。
圖14.12 在故事板中設置標題
構建并運行應用程序。 注意導航欄上的字符串 Homepwner
。 創建并點按一行,并注意導航欄不再具有標題。 將 DetailViewController 的導航項標題作為它正在顯示的 Item 的名稱是很好的。 因為標題將取決于正在顯示的 Item,您需要在代碼中動態設置 navigationItem
的標題。
在 DetailViewController.swift
中,將屬性觀察器添加到更新 navigationItem
標題的 item
屬性中。
var item: Item!
{
??didSet {
????navigationItem.title = item.name
??}
}
構建并運行應用程序。 創建并點按一行,您將看到導航欄的標題是您選擇的 Item 的名稱。
導航項可以保存多個標題字符串,如圖14.13所示。 每個 UINavigationItem 有三個可自定義的區域:一個 leftBarButtonItem
,一個 rightBarButtonItem
和一個 titleView
。 左和右欄的按鈕項是對 UIBarButtonItem 的實例的引用,它包含僅可以在 UINavigationBar 或 UIToolbar 上顯示的按鈕的信息。
圖14.13 UINavigationItem
回想一下,UINavigationItem 不是 UIView 的子類。 相反, UINavigationItem 封裝了 UINavigationBar 用于配置自身的信息。 類似地,UIBarButtonItem 不是視圖,而是保存有關如何顯示 UINavigationBar 上單個按鈕的信息。( UIToolbar 還使用 UIBarButtonItem 的實例配置自身。)
UINavigationItem 的第三個可定制區域是它的 titleView
。 您可以使用基本字符串作為標題,也可以將 UIView 的子類置于導航項目的中心。 你不能同時擁有這兩個。 如果它適合特定視圖控制器的上下文以具有自定義視圖(例如分段控件或文本字段),則可以將導航項的 titleView
設置為該自定義視圖。 圖14.13顯示了具有自定義視圖作為其 titleView
的 UINavigationItem 的內置 Maps
應用程序的示例。 然而,通常,標題字符串就足夠了。
將按鈕添加到導航欄
在本節中,當 ItemsViewController
位于棧頂時,您將使用兩個按鈕項替換表頭視圖中的兩個按鈕,這些按鈕項將出現在 UINavigationBar 中。 導航條按鈕項(Bar Button Item
)具有像 UIControl 的目標動作機制一樣的目標動作對:當點擊時,它將該動作消息發送到目標。
首先,我們來處理一個添加新 item 的按鈕項。 當 ItemsViewController 位于棧頂時,此按鈕將位于導航欄的右側。 點擊后,它將添加一個新的 Item。
在更新故事板之前,需要更改 addNewItem(_ :) 的方法聲明。 目前這種方法是由 UIButton 觸發的。 現在您正在將發件人更改為 UIBarButtonItem,您需要更新聲明。
在 ItemsViewController.swift
中,更新 addNewItem(_ :) 的方法聲明。
@IBAction func addNewItem(_ sender: UIButton) {
@IBAction func addNewItem(_ sender: UIBarButtonItem) {
??...
}
現在打開 Main.storyboard
然后打開對象庫。 將 導航條按鈕項 拖動到 Items View Controller
導航欄的右側。 選擇此 導航條按鈕項 并打開其屬性檢查器。 將 System Item
更改為 Add
(圖14.14)。
圖14.14系統欄按鈕項
右鍵從此 導航條按鈕項 拖動到 Items View Controller
并選擇 addNewItem
:(圖14.15)。
圖14.15連接 addNewItem:action
構建并運行應用程序。 點擊 +
按鈕,一個新行將出現在表格中。 現在我們來更換Edit
按鈕。 視圖控制器包含的導航條按鈕項會自動切換其編輯模式。 沒有辦法通過 Interface Builder
訪問它,因此您需要以編程方式添加該按鈕項。
在 ItemsViewController.swift
中,重寫 init(coder :) 方法來設置左邊導航條按鈕項。
required init?(coder aDecoder: NSCoder) {
??super.init(coder: aDecoder)
??navigationItem.leftBarButtonItem = editButtonItem
}
構建并運行應用程序,添加一些 item,然后點擊 Edit
按鈕。 UITableView 進入編輯模式!editButtonItem
屬性創建了一個標題為 Edit
的 UIBarButtonItem。 更好的是,這個按鈕帶有一個目標動作對:在點擊時調用它的 UIViewController 上的setEditing(_:animated :)方法。
打開 Main.storyboard
。 現在,Homepwner
具有全功能的導航欄,您可以擺脫標題視圖和相關代碼。 在表視圖上選擇標題視圖,然后按 Delete
。
此外,UINavigationController 將處理更新表視圖的插值。 在 ItemsViewController.swift
中,修改為以下。
override func viewDidLoad() {
??super.viewDidLoad()
??// Get the height of the status bar
??let statusBarHeight = UIApplication.shared.statusBarFrame.height
??let insets = UIEdgeInsets(top: statusBarHeight, left: 0, bottom: 0, right: 0)
??tableView.contentInset = insets
??tableView.scrollIndicatorInsets = insets
??tableView.rowHeight = UITableViewAutomaticDimension
??tableView.estimatedRowHeight = 65
}
最后,刪除 toggleEditingMode(_ :) 方法。
@IBAction func toggleEditingMode(_ sender: UIButton) {
??// If you are currently in editing mode...
??if isEditing {
????// Change text of button to inform user of state
????sender.setTitle("Edit", for: .normal)
????// Turn off editing mode
????setEditing(false, animated: true)
??} else {
????// Change text of button to inform user of state
????sender.setTitle("Done", for: .normal)
????// Enter editing mode
????setEditing(true, animated: true)
??}
}
建立并再次運行。 舊的 Edit
和 Add
按鈕已經消失,留下了一個好看的 UINavigationBar(圖14.16)。
圖14.16 帶導航欄的Homepwner
青銅挑戰:顯示數字鍵盤
顯示 Item 的 valueInDollars
的 UITextField 的鍵盤是一個QWERTY鍵盤。 如果它是一個數字鍵盤會更好。 將 UITextField 的 Keyboard Type
更改為 Number Pad
。 (提示:您可以使用屬性檢查器在 storyboard 文件中執行此操作。)
白銀挑戰:自定義 UITextField
創建 UITextField 的子類,并覆蓋 getsFirstResponder() 和 resignFirstResponder() 方法(繼承自 UIResponder),以使其邊框樣式在成為第一個響應者時更改。 您可以使用 UITextField 的 borderStyle 屬性來完成此操作。 在 DetailViewController 中使用您自定義的文本字段。
黃金挑戰:推動更多視圖控制器
目前,Item 的實例不能更改其 dateCreated
屬性。 更改 Item ,使他們可以,然后在 DetailViewController 中的 dateLabel
下面添加一個帶有 "Change Date" 標題的按鈕。當點擊此按鈕時,將另一個視圖控制器實例 push 到導航堆棧。 此視圖控制器應包含一個要修改的所選 Item 的 dateCreated
屬性的 UIDatePicker 實例。