①②⑦ iOS 頁面間的資料傳遞-往回傳

Data Passing Between Controllers — Back: Unwind Segue, Delegate, Closure & Notification

Min
彼得潘的 Swift iOS / Flutter App 開發教室
27 min readFeb 16, 2024

--

繼續上一篇的討論:

傳遞資料方法的三種類型裡:

  • 往前傳 (Passing data forward):傳到下一個頁面。
  • 往回傳 (Passing data back):往前回傳。
  • 雙向傳:不限制傳遞的方向,不過有些有比較推薦的方向,或可以但不建議使用的方法。

往前傳因為是很直觀的動作,按了按鈕就帶著資料移動到下一頁。但往回傳除了直接回到來時的頁面,也包含了回到首頁、其他頁等等,往前走了幾頁,就有幾頁可以回首。在準備離開當頁時,使用 shouldPerformSegue 可檢查是否準備好要回去的資料,不然不准離開。

除了 unwind Segue 比較特定只能回到前一頁之外,其他方法諸如 delegate、closure 或 notification,都可以跑到指定的任何一頁,當然,也包含未來的頁面,因此可以將它們分類到雙向傳之中。只是通常用來往回傳。

另外也可在 App 中定義一個共享的變數/狀態,在所有地方都能讀取與更新,這也包含了資料儲存。

本篇將討論往回傳的 unwind Segue,與用來往回傳的 delegate、closure 或 notification。delegate 除了目前最通用的寫法之外,還有 iOS 13 以上可以用的 diffable data source 來管理資料。不過這次的內容只是單純回傳資料,還不需要用到 diffable data source 以處理表格的大量資料變動。共享狀態則到另一篇文章討論。

跟 AI 一起整理出的回傳資料表格
unwind Segue
shouldPerformSegue
delegate
closure
notification

只能往回傳的 unwind Segue

Unwind Segue 只能搭配 prepare,不能使用 SegueAction。

這次的範例,在 TableViewController 中點擊 + 號的 Bar Button Item 後,進到 AddingViewController。選擇日期與咖啡杯數之後,按 Save 會將兩個資料回傳到 TableViewController 並新增一個 row。如果按 Cancel 則是回到 TableViewController 但什麼都沒發生。

資料的部分,先設定 Coffee 型別,包含日期與杯數:

struct Coffee {
let date: Date
let count: Int
}

Cancel- 單純回前頁

先在要回到的頁面 TableView 中寫好 unwind 的 IBAction 方法,參數為 UIStoryboardSegue,中括號內不用寫內容:

    @IBAction func unwind(for unwindSegue: UIStoryboardSegue) {

}

到 Storyboard,將 cancel button 連到 Exit,有兩個方法。

方法一

方法二

點擊 Cancel 之後,離開當前畫面:

在前方任何頁面寫上 unwind 的 IBAction 方法,都可連接 Exit 回到該處。

Save-傳資料回前頁

AddingViewController 目前內容:

import UIKit

class AddingViewController: UIViewController {

var dayOfCoffee: Coffee?

@IBOutlet weak var datePicker: UIDatePicker!

@IBOutlet weak var cupCountingLabel: UILabel!

var cups: Int = 0

override func viewDidLoad() {
super.viewDidLoad()

}

override func viewWillAppear(_ animated: Bool) {
cupCountingLabel.text = "\(cups)"
}

@IBAction func addCup(_ sender: UIButton) {
cups += 1
cupCountingLabel.text = "\(cups)"
}

@IBAction func minus(_ sender: UIButton) {
cups -= 1
cupCountingLabel.text = "\(cups)"
}
}

TableViewController 目前內容:

import UIKit

class TableViewController: UITableViewController {

var daysOfCoffee = [Coffee]()

override func viewDidLoad() {
super.viewDidLoad()

}

// MARK: - Table view data source

override func numberOfSections(in tableView: UITableView) -> Int {
1
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
daysOfCoffee.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "tableViewCell", for: indexPath)
let dateOfCoffee = daysOfCoffee[indexPath.row].date
let countOfCoffee = daysOfCoffee[indexPath.row].count
var content = cell.defaultContentConfiguration()
content.text = "\(dateOfCoffee.formatted(date: .numeric, time: .shortened))"
content.secondaryText = "喝了 \(countOfCoffee) 杯咖啡"
cell.contentConfiguration = content
return cell
}

@IBAction func unwind(for unwindSegue: UIStoryboardSegue) {

}
}

因為要從 AddingViewController 回到 TableViewController,先在 AddingViewController 用 prepare 設定更新後的咖啡杯數與日期:

    // AddingViewController
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
let date = datePicker.date
let cupCount = cups
dayOfCoffee = Coffee(date: date, count: cupCount)
}

然後回到 TableViewController,新增在 AddingViewController 按下 Save 按鍵回來的另一個 unwind 方法,為了跟 Cancel 回來的 unwind 做區分,加上回來的地點變成 unwindToTableView。

    // TableViewController
@IBAction func unwindToTableView(for unwindSegue: UIStoryboardSegue) {
if let source = unwindSegue.source as? AddingViewController,
let coffee = source.dayOfCoffee {
daysOfCoffee.insert(coffee, at: 0)
tableView.reloadData()
}
}

在 unwindToTableView 方法中,使用 optional binding 檢查兩件事:

  • unwindSegue.source 有沒有成功轉型成 AddingViewController?成功的話可以當作 AddingViewController 的引用
  • 有沒有成功取得 AddingViewController 中的 dayOfCoffee

如果兩件事情都成功了,那將 Save 傳回來的新咖啡資料插入 daysOfCoffee 陣列成為第 0 位。最後使用 tableView.reloadData() 重新載入 tableView。

成功回傳!

shouldPerformSegue

因為 AddingViewController 杯數沒有設定不能減到變負數,因此我們用 shouldPerformSegue 來檢查,如果 cups < 0 就出現警告,並且不能離開 AddingViewController:

// AddingViewController    
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
if cups >= 0 {
return true
} else {
let alertController = UIAlertController(title: "杯數錯誤", message: "沒有負幾杯的", preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK", style: .default))
present(alertController, animated: true)
return false
}
}

不過光這樣設定,連按下 Cancel 也會被攔下,但 Cancel 不會回去添加資料,應該不用設限。

因此到 Main.storyboard 中按下 Save 的 unwind segue 設定這條 Segue 的 identifier:

然後在 shouldPerformSegue 一開始先檢查是不是”unwindToTableView” identifier 的 segue,如果是的話檢查杯數,不是的話直接回傳 true,可以回到上一頁:

    override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
if identifier == "unwindToTableView"{
if cups >= 0 {
return true
} else {
let alertController = UIAlertController(title: "杯數錯誤", message: "沒有負幾杯的", preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK", style: .default))
present(alertController, animated: true)
return false
}
}
return true
}

這樣就只有 Save 時會擋負數杯:

章節開頭 | 回 Menu

delegate

delegate 適合以下三種情況:

  • 要回傳的資料是暫時的,不需要存成 App 的 shared state
  • 使用者只是單純回到上一頁,沒有任何 segue 被啟動
  • 不只希望在頁面過度時發生,而是隨時都想要 controllers 能溝通

今天的 App 需求可能不是非常適合用 delegate,不過也是可以使用。參照潘大的七步驟來設定 delegate:

在 AddingViewController 中

步驟一 & 二:宣告 protocol 與 protocol 的 function

protocol AddingViewControllerDelegate: AnyObject {
func addingViewController(_ controller: AddingViewController, didAddingCoffee dayOfCoffee: Coffee)
}

宣告 AddingViewControllerDelegate 的方法 addingViewController 之後,任何遵從 AddingViewControllerDelegate 的型別都可以使用這個方法。addingViewController 的第一個參數是呼叫此方法的 controller 本人,第二個參數是要傳遞的資料,這邊是選擇好的喝咖啡日期與杯數。

步驟三:宣告 property 的 delegate

在 AddingViewController 中,宣告 property delegate:

class AddingViewController: UIViewController {
weak var delegate: AddingViewControllerDelegate?

為了防止 memory leak,使用 weak var,此前的 protocol 也必須先設定只有類別 (AnyObject) 能夠遵循此 protocol。

步驟四:呼叫 delegate 的 function

將 Unwind Segue 的 prepare 方法去掉:

// AddingViewController
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
let date = datePicker.date
let cupCount = cups
dayOfCoffee = Coffee(date: date, count: cupCount)
}
// 以上刪除

並把 Main.Storyboard 裡的 Unwind Segue “UnwindToTableView”刪掉。

改成連接 Save button 的 IBAction,名為 save:

    @IBAction func save(_ sender: Any) {
let date = datePicker.date
let cupCount = cups
let newDayOfCoffee = Coffee(date: date, count: cupCount)
delegate?.addingViewController(self, didAddingCoffee: newDayOfCoffee)
dismiss(animated: true, completion: nil)
}

宣告要傳回前面的資料,放到步驟二宣告的 delegate 方法內,回傳資料。最後使用 dismiss 關閉 AddingViewController,回到 TableViewController。

在 TableViewController 中

步驟五:遵從 protocol

extension TableViewController: AddingViewControllerDelegate {
}

TableViewController 要遵從 protocol AddingViewControllerDelegate,才能擔任 AddingViewController 的 delegate。

步驟六:定義 protocol 的 function

extension TableViewController: AddingViewControllerDelegate {
func addingViewController(_ controller: AddingViewController, didAddingCoffee dayOfCoffee: Coffee) {

daysOfCoffee.insert(dayOfCoffee, at: 0)

tableView.reloadData()
}
}

定義 addingViewController 從參數取得 dayOfCoffee 的資料後,要做的兩件事:

  • 將資料插入到 daysOfCoffee 陣列的首位
  • 重新整理 tableView

步驟七:設定 delegate

有許多方法能夠設定 delegate,這邊使用在 TableViewController 要透過 segue 切換到 addingViewController 之前,就在 prepare 中設定自己是 delegate,以讓 addingViewController 對自己傳遞資訊:

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let addingViewController = segue.destination as? AddingViewController {
addingViewController.delegate = self
}
}

還有多種方法,潘大上方的文章有註明。

這樣就能使用 delegate 來達到與 Unwind Segue 一樣的功效了:

章節開頭 | 回 Menu

closure

closure 回傳資料的方式與 delegate 相似,更為彈性,不過 “Passing Data Between View Controllers in iOS: The Definitive Guide” 的作者 Matteo Manferdini 因此不是很推薦使用這個方式。

  • 使用 delegate 時,透過 protocol 來指定目標 View Controller 的介面。使用 closure 時,則透過包含 closure 的 stored property 來定義該介面。
  • Source View Controller 執行這些 closures 與前一個 View Controller 互動。這和使用 delegate 的方法相同。
  • 當回到前一頁時,目標 View Controller 會建立一個連結。但在這種情況下,它不是將對自身的引用傳遞給 Source View Controller,而是傳遞一個 closure。

回到喝咖啡 App,改造 delegate 版本為 closure 版本。

在 AddingViewController 中,將原本的 protocol 刪掉。

把原先的 delegate 變數也刪掉:

weak var delegate: AddingViewControllerDelegate?

改將 closure 指定給 dayOfCoffee ,讓他作為 stored property:

private var dayOfCoffee: (Coffee) -> Void?

為了讓程式看起來比較清楚,使用 typealias 將接受 Coffee 並回傳 Void 的 closure 命名別名為 DayOfCoffee:

// AddingViewController    
typealias DayOfCoffee = (Coffee) -> Void

private var dayOfCoffee: DayOfCoffee?

在點選 Save 的 IBAction 中,將 Coffee 物件作為 closure dayOfCoffee 的參數傳遞:

// AddingViewController
@IBAction func save(_ sender: Any) {
let date = datePicker.date
let cupCount = cups
dayOfCoffee?(Coffee(date: date, count: cupCount))

dismiss(animated: true, completion: nil)
}

接著到 TableViewController,在 prepare 方法中,跟 delegate 一樣,一旦確定目標 ViewController 是 AddingViewController,就將 closure 設定給 AddingViewController 的 dayOfCoffee。一旦我們在 AddingViewController 中儲存新的 Coffee 資料,就會調用這個 closure,並將資料回傳到 TableViewController,插入並更新表格:

// TableViewController
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let addingViewController = segue.destination as? AddingViewController {
addingViewController.dayOfCoffee = {
newDayOfCoffee in
self.daysOfCoffee.insert(newDayOfCoffee, at: 0)
self.tableView.reloadData()
}
}
}

可使用 closure 記錄咖啡囉!

回到 Matteo Manferdini 對於 closure 的看法:

closure 與 delegate 一樣,使用 closure 也在兩個 View Controller 間建立了一個連結。這種方法比 delegate 更簡潔一些。但使用 closure 也會帶來一些功能/缺點, delegate 則不會:

  • 幫存儲 closure 的 stored properties 命名不像給方法取合適的名字那麼容易。
  • 如果需要不止一個 closure 與前一個 View Controller 溝通,我們需要為每個 closure 都設置一個 stored properties。而使用 delegate,整個介面被封裝在一個 protocol 中,我們只需要一個 delegate property。
  • delegate 強制了單一的通訊管道。多個 closure 屬性更靈活,可以指向不同的 View Controller。我們無法確定它們通過的路徑,變成用難懂的程式碼去交換靈活性。

Manferdini 認為,closure 更適合作為 asynchronous 任務的回調,例如network request 或動畫。delegate 對於 View Controller 之間的通訊是更好的方案。

章節開頭 | 回 Menu

notification

透過通知中心在事件發生時發出通知,物件收到通知之後執行對應的 function。

notification 可以在任何時候傳資料,不限定回上一頁的時候。並且可以依次傳資料給多個地方,不限定離近的頁面。

先在 AddingViewController 設定一個 static 常數,用來存放通知的名稱,到時在別的 ViewController 也能呼叫:

// AddingViewController
static let coffeeUpdateNotification = Notification.Name("coffeeUpdateNotification")

發送 notification:

 // AddingViewController
@IBAction func save(_ sender: Any) {
let date = datePicker.date
let cupCount = cups
dayOfCoffee = Coffee(date: date, count: cupCount)
NotificationCenter.default.post(name: AddingViewController.coffeeUpdateNotification, object: nil, userInfo: ["daysOfCoffee" : dayOfCoffee!])
dismiss(animated: true, completion: nil)
}

參數 name 放的是通知的名稱 Notification.Name。參數 userInfo 內是要傳的資料的 dictionary,它有預設值,不傳資料時可以省略。

在 TableViewController 申請接受 notification:

// TableViewController
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(updateCoffeeNoti(noti:)), name: AddingViewController.coffeeUpdateNotification, object: nil)
}

selector 中填的是接受到通知後執行的方法,定義在 viewDidLoad 上方:

// TableViewController
@objc func updateCoffeeNoti(noti: Notification) {
if let userInfo = noti.userInfo,
let dayOfCoffee = userInfo["daysOfCoffee"] as? Coffee {
daysOfCoffee.insert(dayOfCoffee, at: 0)
}
tableView.reloadData()
}

我因為一開始在 dictionary 的 key 打錯字,因此沒成功。要避免錯字可以先增加一個 NotificationObjectKey:

// NotificationObjectKey.swift
struct NotificationObjectKey {
static let daysOfCoffee = "daysOfCoffee"
}

接著用 NotificationObjectKey 來引用 userInfo 的 key 值:

  // AddingViewController
@IBAction func save(_ sender: Any) {
let date = datePicker.date
let cupCount = cups
dayOfCoffee = Coffee(date: date, count: cupCount)
NotificationCenter.default.post(name: AddingViewController.coffeeUpdateNotification, object: nil, userInfo: [NotificationObjectKey.daysOfCoffee : dayOfCoffee!])
dismiss(animated: true, completion: nil)
}

// TableViewController
@objc func updateCoffeeNoti(noti: Notification) {
if let userInfo = noti.userInfo,
let dayOfCoffee = userInfo[NotificationObjectKey.daysOfCoffee] as? Coffee {
daysOfCoffee.insert(dayOfCoffee, at: 0)
}
tableView.reloadData()
}

成功:

通知也是 Manferdini 不推薦用來回傳資料的作法。

因為 notification 雖然能讓多個地方同時對一件事做出反應,但它的程式碼不與接收通知的程式碼相連,也就是間接的。這樣程式碼比較難以追踪和維護。

在回傳資料時,還是用前面幾種比較直接的通訊方法在 ViewController 之間共享 data 比較好。

章節開頭 | 回 Menu

--

--