#17 實現unwindSegue回傳資料以及UserDefault儲存資料

EJ Lo
彼得潘的 Swift iOS / Flutter App 開發教室
22 min readNov 26, 2022

這次的練習主要有兩個方向

  1. 使用unwindSegue新增、編輯資料並回傳至上一頁
  2. 將資料使用UserDefault的方式儲存下來

由於這次著重在程式碼的練習,因此畫面上也不做任何排版跟autoLayout,以下畫面依序為新增資料、刪除資料、編輯資料、app滑掉後重開資料還在的畫面。

新增資料:

修改資料:

刪除資料:

關掉App後重開:

StoryBoard畫面:

此次的設計為:

主畫面是動態tableView,新增資料之後回出現新增的資料,按右上角的add就可以進入新增資料的畫面

最右側則是新增資料的表格,與編輯資料共用同一個tableView

點擊主畫面cell會進入下一個tableView,僅僅會顯示資料,但如果按右上角的edit,依然會進入最右側的編輯tableView

而主畫面cell左滑後也會出現兩個選項,一個為delete,一個為編輯,delete會出現alert來確認是否要刪除資料,而選擇編輯則是會到右側的編輯tableView畫面

以下程式碼介紹:

Struct的部分:

import Foundation

struct ProfileItem : Codable {
var name : String
var phone : String
var height : Double
var weight : Double
var birthday : Date
var zodiacSign : ZodiacSign.RawValue
var isMale : Bool

static func loadProfiles() -> [ProfileItem]? {
let decoder = JSONDecoder()
let userDefault = UserDefaults.standard
guard let data = userDefault.data(forKey: "profiles") else { return nil}
return try? decoder.decode([ProfileItem].self, from: data)
}

static func saveProfile(_ profiles : [ProfileItem]) {
let encoder = JSONEncoder()
guard let data = try? encoder.encode(profiles) else {return}
let userDefault = UserDefaults.standard
userDefault.set(data, forKey: "profiles")
}
}

enum ZodiacSign: String {
case aquarius = "Aquarius 水瓶座"
case pisces = "Pisces 雙魚座"
case aries = "Aries 牡羊座"
case taurus = "Taurus 金牛座"
case gemini = "Gemini 雙子座"
case cancer = "Cancer 巨蟹座"
case leo = "Leo 獅子座"
case virgo = "Virgo 處女座"
case libra = "Libra 天秤座"
case scorpio = "Scorpio 天蠍座"
case sagittarius = "Sagittarius 射手座"
case capricorn = "Capricorn 摩羯座"
}

其實就是建立一個基本資料的型別,分別有名字、電話、身高、體重、生日、星座、性別等資料,另外有兩個static function,分別用來儲存跟讀取資料,由於之前練習的是串接API的方式儲存讀取資料,這次用UserDefault就特別容易上手。

另外建立一個enum,用來表示星座,其rawValue為String,這樣在之後的判別星座就不用一直打字串避免打錯字。

主要TableView的部分:

import UIKit

class ProfileTableViewController: UITableViewController {

var profiles : [ProfileItem] = []{
didSet{
ProfileItem.saveProfile(profiles)
}
}
var selectedIndexPath : IndexPath?

override func viewWillAppear(_ animated: Bool) {
selectedIndexPath = nil
}

override func viewDidLoad() {
if let profiles = ProfileItem.loadProfiles(){
self.profiles = profiles
}
super.viewDidLoad()
}

@IBAction func unwindToProfileTableView(_ unwindSegue: UIStoryboardSegue) {
if let source = unwindSegue.source as? EditProfileTableViewController, let profile = source.profile {
if let indexPath = selectedIndexPath{
profiles[indexPath.row] = profile
tableView.reloadRows(at: [indexPath], with: .none)

}else {
profiles.insert(profile, at: 0)
let indexPath = IndexPath(row: 0, section: 0)
tableView.insertRows(at: [indexPath], with: .left)

}
}
}

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

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "profile", for: indexPath)
cell.textLabel?.text = profiles[indexPath.row].name
return cell
}

override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let deleteFunction = UIContextualAction(style: .destructive, title: "Delete") { action, view, completion in
let alertController = UIAlertController(title: "是否刪除資料", message: nil, preferredStyle: .alert)
let deleteAlertAction = UIAlertAction(title: "OK", style: .default) { action in
self.profiles.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .left)
}
let cancelAlertAction = UIAlertAction(title: "Cancel", style: .cancel)
alertController.addAction(deleteAlertAction)
alertController.addAction(cancelAlertAction)
self.present(alertController, animated: true)
completion(true)
}

let editFunction = UIContextualAction(style: .normal, title: "Edit") { action, view, completion in
if let editProfileController = self.storyboard?.instantiateViewController(withIdentifier: "editProfile") as? EditProfileTableViewController{
editProfileController.profile = self.profiles[indexPath.row]
self.selectedIndexPath = indexPath
self.navigationController?.pushViewController(editProfileController, animated: true)
}
completion(true)
}
let swipeConfiguration = UISwipeActionsConfiguration(actions: [deleteFunction,editFunction])
return swipeConfiguration

}

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)
}

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let showDetailController = segue.destination as? ProfileDetailTableViewController, let indexPath = tableView.indexPathForSelectedRow {
showDetailController.profile = profiles[indexPath.row]
selectedIndexPath = tableView.indexPathForSelectedRow
}
}

}

首先先建立一個ProfileItem的Array型別,後面使用了觀察屬性,只要Array一變化就會儲存資料,這樣不管是新增修改刪除哪個,只要把資料傳進去Array裡,就會自動把Array的資了存在app裡面。

ViewDidLoad的部分比較簡單,就是每次讀取畫面就會把資料解碼顯示出來。

另外值得注意的是我使用的edit是左滑的功能,而如果使用這個功能tableView的indexPathForSelectedRow屬性為nil,因此編輯完也沒辦法取代原本的cell,反而變成新增,只有點擊的時候才有值,因此我多設定一個selectedIndexPath的變數來儲存不論是左滑還是點擊時候的indexPath,但要記得每次回到畫面這個變數要清空變成nil,否則在新增資料的時候會把剛剛選擇的cell取代掉。

而這次的重點unwindSegue function是寫在主畫面,用來接收回傳的值,但是拉segue的時候是在編輯的tableView拉的,如下圖:

程式寫在左邊的controller,segue則是右邊的done按鈕拉到右邊controller的exit上,並且選擇剛剛命名的unwindsegue名稱。

在資料得改變就是判斷selectedIndexPath是否有值,如果有則是改變selectedIndexPath的cell的資料,如果不是就是用insert的方式存進Array。

接下來是編輯Controller:

import UIKit

class EditProfileTableViewController: UITableViewController {

var profile : ProfileItem?
var isMale = true
var birthday: Date = Date.now

@IBOutlet weak var profileNameTextField: UITextField!
@IBOutlet weak var profilePhoneTextField: UITextField!
@IBOutlet weak var profileHeightTextField: UITextField!
@IBOutlet weak var profileWeightTextField: UITextField!
@IBOutlet weak var profileBirthdayPicker: UIDatePicker!
@IBOutlet weak var zodiacSignLabel: UILabel!

@IBOutlet var genderButtons: [UIButton]!

@IBAction func maleButtonAction(_ sender: Any) {
genderButtons[0].setImage(UIImage(named: "check"), for: .normal)
genderButtons[1].setImage(UIImage(named: "uncheck"), for: .normal)
isMale = true
}

@IBAction func femaleButtonAction(_ sender: Any) {
genderButtons[0].setImage(UIImage(named: "uncheck"), for: .normal)
genderButtons[1].setImage(UIImage(named: "check"), for: .normal)
isMale = false
}

func updateUI(){
if let profile = profile {
profileNameTextField.text = profile.name
profilePhoneTextField.text = profile.phone
profileHeightTextField.text = profile.height.description
profileWeightTextField.text = profile.weight.description
zodiacSignLabel.text = profile.zodiacSign
isMale = profile.isMale
profileBirthdayPicker.date = profile.birthday
updateZodiacSign(profileBirthdayPicker.date)
} else {
updateZodiacSign(Date.now)
}
}

func updateZodiacSign(_ date : Date){
let zodiac = ZodiacSign.self
let current = Calendar.current
let month = current.component(.month, from: date)
let day = current.component(.day, from: date)
switch month {
case 1 :
zodiacSignLabel.text = day > 20 ? zodiac.aquarius.rawValue : zodiac.capricorn.rawValue
case 2 :
zodiacSignLabel.text = day > 19 ? zodiac.pisces.rawValue : zodiac.aquarius.rawValue
case 3 :
zodiacSignLabel.text = day > 20 ? zodiac.aries.rawValue : zodiac.pisces.rawValue
case 4 :
zodiacSignLabel.text = day > 19 ? zodiac.taurus.rawValue : zodiac.aries.rawValue
case 5 :
zodiacSignLabel.text = day > 20 ? zodiac.gemini.rawValue : zodiac.taurus.rawValue
case 6 :
zodiacSignLabel.text = day > 21 ? zodiac.cancer.rawValue : zodiac.gemini.rawValue
case 7 :
zodiacSignLabel.text = day > 22 ? zodiac.leo.rawValue : zodiac.cancer.rawValue
case 8 :
zodiacSignLabel.text = day > 22 ? zodiac.virgo.rawValue : zodiac.leo.rawValue
case 9 :
zodiacSignLabel.text = day > 22 ? zodiac.libra.rawValue : zodiac.virgo.rawValue
case 10 :
zodiacSignLabel.text = day > 23 ? zodiac.scorpio.rawValue : zodiac.libra.rawValue
case 11 :
zodiacSignLabel.text = day > 21 ? zodiac.sagittarius.rawValue : zodiac.scorpio.rawValue
default :
zodiacSignLabel.text = day > 20 ? zodiac.capricorn.rawValue : zodiac.sagittarius.rawValue
}
}

@IBAction func pickBirthday(_ sender: UIDatePicker) {
birthday = sender.date
updateZodiacSign(birthday)
}

override func viewDidLoad() {
updateUI()
let checkMale = isMale ? "check" : "uncheck"
let checkFemale = !isMale ? "check" : "uncheck"
genderButtons[0].setImage(UIImage(named: checkMale), for: .normal)
genderButtons[1].setImage(UIImage(named: checkFemale), for: .normal)
super.viewDidLoad()
}

override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
if !profileNameTextField.text!.isEmpty, !profilePhoneTextField.text!.isEmpty, let _ = Double(profileHeightTextField.text!), let _ = Double(profileWeightTextField.text!), !zodiacSignLabel.text!.isEmpty{
return true
} else {
let alertController = UIAlertController(title: "錯誤", message: "請輸入正確資料", preferredStyle: .alert)
let alertAction = UIAlertAction(title: "OK", style: .cancel)
alertController.addAction(alertAction)
present(alertController, animated: true)
return false
}
}

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let name = profileNameTextField.text, let phoneNumber = profilePhoneTextField.text, let height = Double(profileHeightTextField.text!), let weight = Double(profileWeightTextField.text!), let zodiac = zodiacSignLabel.text, !name.isEmpty, !phoneNumber.isEmpty {
profile = ProfileItem(name: name, phone: phoneNumber, height: height, weight: weight, birthday: birthday, zodiacSign: zodiac, isMale: isMale)
}
}
}

基本上就是把畫面的IBOutlet跟IBAction拉一拉,其中性別的部分用button的方式去點擊按鈕,使用的是網上截圖的checkbox圖片…小小抱怨,checkBox很實用啊,為什麼沒有做成UIkit。

然後把輸入的資料都存進ProfileItem的型別變數裡,回傳資料則是用prepare的方式,先判定是否確定有值,且值都正確再回傳。

而這邊也使用了一個shouldPerformSegue來去判定,如果值錯誤就不會回傳並且show alert,如下圖:

updateUI的部分則是判定profile是否為nil,如果不是就把值傳到畫面,是則是空白。

最後是DetailViewController:

import UIKit

class ProfileDetailTableViewController: UITableViewController {

var profile : ProfileItem?

@IBOutlet weak var profileNameLabel: UILabel!
@IBOutlet weak var profilePhoneLabel: UILabel!
@IBOutlet weak var genderLebel: UILabel!
@IBOutlet weak var birthdayLabel: UILabel!
@IBOutlet weak var ZodiacSignLabel: UILabel!
@IBOutlet weak var heightLabel: UILabel!
@IBOutlet weak var weightLabel: UILabel!

func updateUI(){
if let profile = profile {
profileNameLabel.text = profile.name
profilePhoneLabel.text = profile.phone
genderLebel.text = profile.isMale ? "男性" : "女性"
let dateformatter = DateFormatter()
dateformatter.dateFormat = "yyyy-MM-dd"
birthdayLabel.text = dateformatter.string(from: profile.birthday)
ZodiacSignLabel.text = profile.zodiacSign
heightLabel.text = profile.height.description
weightLabel.text = profile.weight.description
}
}

override func viewDidLoad() {
updateUI()
super.viewDidLoad()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let editController = segue.destination as? EditProfileTableViewController {
editController.profile = profile
}
}

}

其實就是最簡單的把資料show出來而已,只有在按edit按鈕會觸發Segue到editController,依然可以編輯資料後回傳。

結語:上次訂飲料App使用protocol跟delegate的方式回傳資料,其實寫的自己不是很明白,使用unwind卻是非常淺顯易懂而且很方便,未來也會比較偏向這類的寫法。

GitHub: https://github.com/EJLO0805/UnwindSeguePractice

--

--