iOS| #23 | 使用TableView與Core Data實作ToDo App

Tommy
彼得潘的 Swift iOS / Flutter App 開發教室
19 min readJan 30, 2022

--

在生活中總是有許多瑣碎的代辦事項等著我們去完成,有時難免會遺忘掉某些事情或者是因為某些因素導致效率低下。今天就來透過TableView實作ToDo App來避免這些問題吧!

2022/03/17 新增功能 - iOS| #26 | 使用TableView與Core Data實作ToDo App — 新增功能

技術應用

  • TableView
  • Unwind Segue
  • Core Data

成果展示

UI畫面

Model設計

  • struct ListCoreData:負責執行CoreData相關功能
  • enum DataStatus :判斷狀態為Insert或是Update
enum DataStatus {case insert , update}

功能解說&程式解說

  • 點擊Cell展開DatePicker

實作好TableView,這邊欄位都是固定的,所以使用Static Cells

宣告屬性負責控制UIDatePicker的顯示與否

預設為關閉的,所以為false。並且宣告為計算屬性,在值改變的時候就會呼叫更新tableView的資料。

var datePickerStatus = false 

使tableView有動畫效果

tableView.performBatchUpdates{}

更新tableView資料,這句一定要加

tableView.reloadData()

值得注意的是後面我們會利用func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath)來控制DatePicker所屬Cell的高度。
然而當我們控制DatePicker開關時需要呼叫tableView.reloadData()才能重複觸發func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath),使其達到收合的功能。

實作方法:

1. func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath):選擇tableViewCell的row時觸發。
2. func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath):顯示tableViewCell時觸發。

判斷選擇日期欄位時要打開/關閉DatePicker。

if indexPath.section == 2 {/* true變false;false變true */datePickerStatus.toggle()}

根據datePickerStatus控制Row的高度。
在這邊用正向表列,只有第三個Section的第二個Row需要調整高度,其餘都讓Table自己去算出Cell 的高度。

if indexPath.section == 2 ,indexPath.row == 1{if datePickerStatus {return UITableView.automaticDimension}else{return 0}}else {return UITableView.automaticDimension}
  • 使用Unwind Segue回前頁

在ListTableViewController(第一頁)實作unwind method

unwindSegue.source:代表unwindSegue的來源,在這邊為EditTableViewController,因為是從EditTableViewController(第二頁)回到ListTableViewController(第一頁)。

在EditTableViewController(第二頁)的UIButton拉Exit

選擇我們剛剛在第一頁宣告好的@IBAction

  • 建立DataModel

建立專案的時候勾選Use Core Data

若沒有勾選的話也可以自己新增(如下圖),但是相關的AppDelegate、SceneDelegate內容會有些許不同,需要再補上相關程式碼。

在這邊如果忘記勾選,建議可以直接建立一個project勾選Use Core Data,然後將程式碼複製貼上即可。

可以看到Project Navigator生出了一個xcdatamodeld檔案

Add Entity

自定義Entity名稱

新增屬性(欄位名稱)與類型

選擇Entity並且建立類別

建立完成Xcode會自動幫我們生出這兩個檔案

這時可能會跳出編譯錯誤的訊息

透過上述方法會造成以上錯誤,那是因為Codegen選擇Class Definition的緣故,其實Xcode默默地在背後已經幫我們生成好Class了,剛才我們又再建立一次才會導致編譯錯誤。

將Codegen改為Manual/None錯誤就會消失

補充:關於Codegen詳細說明可以參考此文章
  • 點擊Cell完成Todo事項
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)

實作以上method,點擊cell會自動觸發此程式

let todoData = todoDataList![indexPath.row]

取得該筆資料

todoData.done.toggle()

屬性done負責控制該事項是否完成,為Bool。
toggle():true變false;false變true

tableView.deselectRow(at: indexPath, animated: true)

取消選取該cell,避免視覺顯示看起來狀態為一直被選取

do {try ListCoreData.shared.updateData(todoData: todoData)} catch let error {showAlert(message: error.localizedDescription)return}

更新CoreData資料,若有錯誤會顯示在alert controller上

func showAlert(message: String ){let alert = UIAlertController(title: "錯誤", message: message, preferredStyle: .alert)let action = UIAlertAction(title: "OK", style: .default, handler: nil)alert.addAction(action)present(alert, animated: true, completion: nil)}

func showAlert(message: )說明:

let alert = UIAlertController(title: "錯誤", message: message, preferredStyle: .alert)

生成UIAlertController
title:標題
message:訊息
preferredStyle:顯示的alert樣式,.alert將會從中間跳出視窗

let action = UIAlertAction(title: "OK", style: .default, handler: nil)

生成UIAlertAction(UIAlertController的按鈕)
title:按鈕的標題
style:文字的樣式顏色
handler:點擊按鈕後要做的事

tableView.reloadData()

更新tableView

接下來要根據todoData的屬性done來改變cell的樣子

  • Core Data — Insert

利用 struct ListCoreData 的 func insertData(name: , descript: , date: )新增資料

if let appDelegate = UIApplication.shared.delegate as? AppDelegate{ }

取得AppDelegate

let context = appDelegate.persistentContainer.viewContext

宣告用來操作Core Data的常數

let toDo = NSEntityDescription.insertNewObject(forEntityName: "\(ToDoData.self)", into: context) as! ToDoData

使用NSEntityDescription.insertNewObject新增一筆資料,並且轉型為ToDoData。注意:此時資料並沒有新增進去。
forEntityName:當初建立Entity的名稱。
context:上述宣告之用來操作Core Data的常數。

toDo.name = nametoDo.descript = descripttoDo.date = datetoDo.done = false

把該筆資料的屬性值填進去

do {if context.hasChanges {try context.save()}} catch let error {throw error}

因為context.save()是一個拋出函式,所以用 do-catch包起來。先確認context內容是否有變動,確認有變動再save。注意:此時資料才新增進去。

  • Core Data — Update

利用 struct ListCoreData 的func updateData(todoData: )更新資料

if let appDelegate = UIApplication.shared.delegate as? AppDelegate{ }

取得AppDelegate

let context = appDelegate.persistentContainer.viewContext

宣告用來操作Core Data的常數

do {if context.hasChanges {try context.save()}} catch let error {throw error}

因為context.save()是一個拋出函式,所以用 do-catch包起來。先確認context內容是否有變動,確認有變動再save。注意:此時資料才真正更新。

  • Core Data — Read

利用 struct ListCoreData 的func fetchData()讀取資料

var toDoData: [ToDoData]?

宣告一個容器來裝抓資料的結果

if let appDelegate = UIApplication.shared.delegate as? AppDelegate{ }

取得AppDelegate

let context = appDelegate.persistentContainer.viewContext

宣告用來操作Core Data的常數

if let result = try? context.fetch(ToDoData.fetchRequest()) {toDoData = result}

利用context.fetch抓取資料(根據ToDoData.fetchRequest()的請求),並把結果給toDoData。
ToDoData.fetchRequest():建立抓資料的請求。

  • Core Data — Delete

利用 struct ListCoreData 的func deleteData(todoData: )刪除資料

if let appDelegate = UIApplication.shared.delegate as? AppDelegate{ }

取得AppDelegate

let context = appDelegate.persistentContainer.viewContext

宣告用來操作Core Data的常數

context.delete(todoData)

刪除該筆CoreData資料
注意:此時資料並沒有被刪除。

do {if context.hasChanges {try context.save()}} catch let error {throw error}

因為context.save()是一個拋出函式,所以用 do-catch包起來。先確認context內容是否有變動,確認有變動再save。注意:此時資料才真正被刪除。

  • 客製TableViewCell的Swipe功能
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration?{ }

實作此方法,可以實現向左滑的功能;若想要往右滑動可以實作:

override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration?

接下來取得Swipe的該筆CoreData,我們後面會使用到它

//當筆coreData的資料let todoData = self.todoDataList![indexPath.row]

後續會分為兩點說明,分別為:

  1. 刪除
let delete = UIContextualAction(style: .normal, title: "") { action, view, bool in以下省略}

宣告給刪除按鈕用的UIContextualAction
style:按鈕的style
title:按鈕的title(這邊不需要給,後續會用圖片表示)

do {//刪除CoreData的當筆資料try ListCoreData.shared.deleteData(todoData: todoData)//刪除儲存todo的當筆資料self.todoDataList?.remove(at: indexPath.row)//刪除tableView上顯示的當筆資料tableView.deleteRows(at: [indexPath], with: .left)} catch let error {self.showAlert(message: error.localizedDescription)}

撰寫程式對應點擊按鈕後的動作
注意:在這邊需要刪除三樣東西,分別為CoreData的當筆資料、作為tableView資料來源的當筆資料、tableView上顯示的當筆資料。

***若tableView資料來源數量與顯示的不同,則程式會直接閃退***

以下我們來做個實驗:

將刪除tableView資料來源的當筆資料程式註解掉。

程式碼閃退,並出現錯誤訊息

接下來讓我們回到正題繼續說明

bool(true)

這個method一定要執行,否則Cell在結束動作以後顯示會不正常。但對於他的功能還是沒有全面的了解,歡迎各位大神幫忙補充。

2. 編輯

let edit = UIContextualAction(style: .normal, title: "Edit") { action, view, bool inself.performSegue(withIdentifier: "clickSwipeEditToEditController", sender: todoData)bool(true)}

在這邊我們直接call performSegue 讓程式走進 @IBSegueAction func clickSwipeEditToEditController中準備去下一頁。

值得注意的為參數sender,我們傳入在前面已經取得我們cell的該筆CoreData。

注意:在拉@IBSegueAction時要選擇有sender的,這樣我們才可以傳遞資料過去。

@IBSegueAction func clickSwipeEditToEditController(_ coder: NSCoder, sender: Any?) -> EditTableViewController? {guard let todoData = sender as? ToDoData else {return EditTableViewController(status: .insert, coder: coder) }return EditTableViewController(status: .update, todoData: todoData, coder: coder)}

先取出sender的值,若成功轉型為ToDoData就代表是update模式;若失敗的話就代表是insert新的資料(理論上不會走到這裡,因為目前只有update會進來這個@IBSegueAction)。

以下會說明關於swipe功能的設定與調整button屬性:

delete.backgroundColor = .red
edit.backgroundColor = .systemOrange

設定delete、edit的背景顏色

delete.image = UIImage(systemName: "trash")

設定delete顯示的圖片

let swipeConfig = UISwipeActionsConfiguration(actions: [delete,edit])

建立物件UISwipeActionsConfiguration
actions:滑動後要handle的動作(button)。注意:陣列的順序與顯示的位置有關係

swipeConfig.performsFirstActionWithFullSwipe = false

此參數負責控制滑動到底是否直接執行button功能
true:滑到底會執行陣列中第一個UIContextualAction的功能(此例為delete)
false:不執行滑到底的功能

若內容有誤煩請指教,感謝收看。

--

--