iOS 開發 #13 | SwiftUI ( 3 )

學習 SwiftUI 從 Todo List 開始 ( 2 ) 資料處理

Han
彼得潘的 Swift iOS / Flutter App 開發教室
23 min readSep 26, 2024

--

圖源:What’s new in SwiftUI — WWDC21 — Videos — Apple Developer

前言

在上一篇完成了 Todo List App 的 UI 設計,接下來就是處理資料的部分了

資料處理

這次 Todo List App 的儲存方式我選擇用 UserDefault,原因是因為他比較簡單,我才不會說其他的我不會用

一開始先來建立一個 TodoModel ,建立一個叫做 TodoModel 的檔案並將下面的 Code 打上去

import Foundation

struct Todo: Codable {
var id: String
var title: String
var isFinish: Bool
}

讓這個 Model 遵從 CodableCodable 裡面有兩個等等會用到的東西,EncodableDecodable ,我們會把建立的待辦事項透過 Encodable 轉成 JSON 的封包來儲存到 UserDefault ,讀取時透過 Decodable 把 JSON 轉成 Todo Model 的格式來做顯示

定義好 Model 後,接下來就是 Todo List 的 CRUD 了,但在那之前先來講講 UserDefault

UserDefault

這個是一個鍵值對儲存系統,輕量且小型的資料,但不適合用來存大量或頻繁變動的資料

let userDefaults = UserDefaults.standard 先建立一個標準預設存儲區域,這樣我們之後就可以直接呼叫 userDefaults 來儲存和讀取資料

使用 UserDefault 儲存資料

將你要儲存的資料儲存在 name 這個 key 裡面

userDefaults.set("你要儲存的資料", forKey: "name")

使用 UserDefault 讀取資料

取出 name 這個 key 裡面的東西

userDefaults.string(forKey: "name")

使用 UserDefault 刪除資料

刪除 name 這個 key 裡面的東西

userDefaults.removeObject(forKey: "name") 

OK ,介紹完 UserDefault 後,接下來就是使用 UserDefault 來做 Todo List App 的 CRUD

CRUD

TodoModel 裡面建立一個 TodoManager 的 class,你也可以把 TodoManager 建立成一個獨立的檔案

class TodoManager {
private let userDefaults = UserDefaults.standard
private let todosKey = "todosKey"
}

可以看到我把 key 給單獨拎出來,是因為這次新增時是先把所有的資料從 JSON 封包轉成 Todo 的陣列,將新增的資料新增至陣列後再把陣列轉成 JSON 封包,由於這過程中只會運用到一個 key ,所以我直接把它單拎出來供後面的 func 做使用

C (Create)

func saveTodo(todo: String) {
// 先取得目前已存的 Note 陣列
var savedNotes = loadTodos()

let addTodo = Todo(id: UUID().uuidString, title: todo, isFinish: false)

// 加入新的 Note
savedNotes.append(addTodo)

// 將 Note 陣列編碼成 JSON Data
if let encodedData = try? JSONEncoder().encode(savedNotes) {
// 將編碼後的資料儲存至 UserDefaults
userDefaults.set(encodedData, forKey: todosKey)
}
}

R (Read)

func loadTodos() -> [Todo] {
// 從 UserDefaults 中取得資料
if let savedData = userDefaults.data(forKey: todosKey) {
// 將資料解碼回 [Note]
if let decodedNotes = try? JSONDecoder().decode([Todo].self, from: savedData) {
return decodedNotes
}
}
return [] // 如果沒有資料,返回空陣列
}

U (Update)

func updateTodo(id: String, finished: Bool) {
var savedTodos = loadTodos()

// 找到對應的 Todo 並修改它的 isFinish
if let index = savedTodos.firstIndex(where: { $0.id == id }) {
savedTodos[index].isFinish = finished

// 將更新後的陣列儲存回 UserDefaults
if let encodedData = try? JSONEncoder().encode(savedTodos) {
userDefaults.set(encodedData, forKey: todosKey)
}
}
}

D (Delete)

func deleteTodo(id: String) {
var savedTodos = loadTodos()

// 找到對應的 Todo 並刪除它
if let index = savedTodos.firstIndex(where: { $0.id == id }) {
savedTodos.remove(at: index)

// 將更新後的陣列儲存回 UserDefaults
if let encodedData = try? JSONEncoder().encode(savedTodos) {
userDefaults.set(encodedData, forKey: todosKey)
}
}
}

OK ,接著來修改一下上篇的 UI 介面吧,蛤你問我為什麼要修改 UI ?
因為在上篇是為了方便,內容顯示方面都是寫死的,總不可能說 TodoCard 永遠顯示 Title

Edit TodoCard

原來的 Code

import SwiftUI

// CheckBox
struct CheckBox: View {
@State var isChecked: Bool = false

var body: some View {
Button(
action: {
isChecked.toggle()
},
label: {
if (isChecked) {
Image(systemName: "checkmark.square.fill")
.font(.system(size: 30))
.foregroundStyle(.black)
} else {
Image(systemName: "square")
.font(.system(size: 30))
.foregroundStyle(.black)
}
}
)
}
}

// TodoCard
struct TodoCard: View {
var body: some View {
HStack {
CheckBoxTest()
.padding(10)
Text("title")
.font(.title)
.padding(5)
.foregroundStyle(.black)
Spacer()
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.swipeActions(edge: .leading, content: {
Button(action: {}) {
Image(uiImage: UIImage(named: "svg_delete")!)
}
})
.tint(.red)
}
}

TodoCard 中要修改的部分有:

  • TodoCard 的標題
  • 傳送 CheckBox 的資料
  • 處理 .swipeActions 裡面的 Button 點擊事件

修改的部分會在 code 用註解的方式解釋

在上篇有提到說 CheckBox 是我們設計的,並非原生的元件,且剛剛整理的修改部分中有提到需傳送 CheckBox 的資料,那先從 CheckBox 開始

CheckBox

import SwiftUI

struct CheckBox: View {
// 需傳入 isChecked: Bool
@State var isChecked: Bool

// 宣告 onCheck: (Bool) -> Void 的 closure
var onCheck: (Bool) -> Void

var body: some View {
Button(
action: {
isChecked.toggle()

// 當 CheckBox 被點擊時就會將當下的 isChecked 傳給 onCheck 供外部處理
onCheck(isChecked)
},

label: {
if (isChecked) {
Image(systemName: "checkmark.square.fill")
.font(.system(size: 30))
.foregroundStyle(.black)
} else {
Image(systemName: "square")
.font(.system(size: 30))
.foregroundStyle(.black)
}
}
)
}
}

在原先的 code 中,isChecked 是預設 false 的,但現在多了資料的傳輸,必須要讓 CheckBox 的內容與儲存在 UserDefault 裡面的資料一致,所以把他的預設值給移除,這樣就在呼叫 CheckBox 時就必須傳入資料進去

剛剛試過其實不移除預設值是沒問題的,但我個人習慣是會把它砍掉,我自己是怕當我設了預設值後會忘記傳值給他

再來是 onCheckclosure,就跟我在註解中解釋的一樣,透過這個接受 BooleanonCheckisChecked 的狀態傳出去供外部做處理

修改完 CheckBox 後,再接下去改 TodoCard

TodoCard

import SwiftUI

struct TodoCard: View {
// 需傳入 Todo 物件
var todo: Todo

// 宣告 onCheck: (Bool) -> Void 的 closure
var onCheck: (Bool) -> Void

// 宣告 onClick: () -> Void 的 closure
var onClick: () -> Void

var body: some View {
HStack {
// 將 Todo 物件的 isFinish 傳給 CheckBox
// {} 的部分是處理從 CheckBox 傳出來的 isChecked 狀態
CheckBox(isChecked: todo.isFinish, onCheck: { isChecked in
onCheck(isChecked)
})
.padding(10)

// 修改 Text 讓他顯示 Todo 物件的 title
// 讓他根據 isFinish 來決定是否加上刪除線
// true -> 加入刪除線
// false -> 移除刪除線
Text((todo.isFinish) ? "~\(todo.title)~" : "\(todo.title)")
.font(.title)
.padding(5)
.foregroundStyle(.black)
Spacer()
}
.padding()
.frame(maxWidth: .infinity)
.background(itemBg = Color(red: 171/255, green: 171/255, blue: 171/255))
.swipeActions(edge: .leading, allowsFullSwipe: false, content: {

// 透過 closure 讓外部處理這個 Button 的點擊事件
Button(action: onClick) {
Image(uiImage: UIImage(named: "svg_delete")!)
}
})
.tint(.red)
}
}

根據傳入的 Todo 物件來顯示 TodeCard,並使用 closure 的方式供外部處理 Button 的點擊事件以及 CheckBox 的狀態處理

Edit FloatActionButton

原來的 code

import SwiftUI

struct FloatActionButton: View {
var btnTitle: String
var img: String

var body: some View {
Button(action: {}, label: {
HStack {
Image(uiImage: UIImage(named: img)!)
Text(btnTitle)
.foregroundStyle(.black)
}
.padding()
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.clipShape(RoundedRectangle(cornerRadius: 14))
})
.shadow(radius: 10)
}
}

FloatActionButton 中要修改的部分有:

  • 處理 Button 點擊事件
import SwiftUI

struct FloatActionButton: View {
var btnTitle: String
var img: String

// 宣告 onClick: () -> Void 的 closure
var onClick: () -> Void

var body: some View {

// 透過 closure 讓外部處理這個 Button 的點擊事件
Button(action: onClick, label: {
HStack {
Image(uiImage: UIImage(named: img)!)
Text(btnTitle)
.foregroundStyle(.black)
}
.padding()
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.clipShape(RoundedRectangle(cornerRadius: 14))
})
.shadow(radius: 10)
}
}

使用 closure 的方式供外部處理 Button 的點擊事件

Edit AddTaskPage

原本的 code

import SwiftUI

struct AddTask: View {
@State private var task: String = ""

var body: some View {
VStack {
HStack {
Text("Add Task")
.font(.title)
.padding()
Spacer()
}
.padding()
TextField("Enter Task", text: $task)
.padding()
.overlay(RoundedRectangle(cornerRadius: 14).stroke(lineWidth: 1))
.padding()
HStack {
Spacer()
Button("Add") {}
.padding()
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.clipShape(RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.black)
.padding()
}
}
}
}

AddTaskPage 中要修改的部分有:

  • 傳送 TextField 的資料
import SwiftUI

struct AddTask: View {

// 宣告 onClick: (String) -> Void 的 closure
var onClick: (String) -> Void

@State private var task: String = ""

var body: some View {
VStack {
HStack {
Text("Add Task")
.font(.title)
.padding()
Spacer()
}
.padding()
TextField("Enter Task", text: $task)
.padding()
.overlay(RoundedRectangle(cornerRadius: 14).stroke(lineWidth: 1))
.padding()
HStack {
Spacer()
Button("Add") {

// 當按下 button 後會把 task 儲存的資料傳給 onClick 供外部處理
onClick(task)
}
.padding()
.background(Color(red: 171/255, green: 171/255, blue: 171/255))
.clipShape(RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.black)
.padding()
}
}
}
}

使用 closure 的方式供外部處理 task 儲存的資料

Edit HomePage

接著是修改 HomePage,在 HomePage 需要修改比較多的地方,我分兩部分講解:

  1. CRUD Function
  2. UI 修改

先從 CRUD 開始說起

CRUD Function

@State private var todos: [Todo] = []
private let todoManager: TodoManager = TodoManager()

func loadTodos() {
todos = todoManager.loadTodos()
}

func addTodo(todo: String) {
todoManager.saveTodo(todo: todo)
loadTodos()
}

func updateTodo(id: String, finished: Bool) {
todoManager.updateTodo(id: id, finished: finished)
loadTodos()
}

func deleteTodo(id: String) {
todoManager.deleteTodo(id: id)
loadTodos()
}

一開始宣告一個 @State 的陣列,用於儲存從 UserDefault 讀取的資料,而 loadTodosaddTodoupdateTododeleteTodo 則是去呼叫 TodoManager 裡面的 CRUD Function

UI 修改

原先的 code

import SwiftUI

struct HomePage: View {
@State private var showSheet: Bool = false

var body: some View {
VStack {
List(0..<10) { index in
TodoCard()
.listRowInsets(EdgeInsets())
.listRowSpacing(.infinity)
.listRowBackground(Color.clear)
}
.background(.clear)
}
.frame(maxWidth: .infinity)
.overlay(alignment: .bottomTrailing, content: {
FloatActionButton(btnTitle: "Add", img: "svg_add")
.padding(20)
})
.sheet(isPresented: $showSheet, content: {
AddTask()
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
})
}
}

HomePage 中要修改的部分有:

  • 加入 CRUD Function
  • 修改 TodoCard 的呼叫格式
  • 修改 FloatActionButton 的呼叫格式
  • 修改 AddTaskPage 的呼叫格式
  • 修改 List 顯示方式
  • 加上 .Appear 事件
import SwiftUI

struct HomePage: View {
@State private var showSheet: Bool = false

// 加上 CRUD Function
@State private var todos: [Todo] = []
private let todoManager: TodoManager = TodoManager()

func loadTodos() {
todos = todoManager.loadTodos()
}

func addTodo(todo: String) {
todoManager.saveTodo(todo: todo)
loadTodos()
}

func updateTodo(id: String, finished: Bool) {
todoManager.updateTodo(id: id, finished: finished)
loadTodos()
}

func deleteTodo(id: String) {
todoManager.deleteTodo(id: id)
loadTodos()
}

var body: some View {
VStack {

// 修改 List 顯示方式
List(todos, id: \.id) { todo in

// 修改 TodoCard 的呼叫格式
TodoCard(
todo: todo,

// 處理 onCheck 事件
onCheck: { isChecked in
updateTodo(id: todo.id, finished: isChecked)
print("\(todo.title) \(isChecked)")
},

// 處理 onClick 事件
onClick: {
deleteTodo(id: todo.id)
print("\(todo.title) onClicked")
}
)
.listRowInsets(EdgeInsets())
.listRowSpacing(.infinity)
.listRowBackground(Color.clear)
}
.listStyle(.grouped)
}
.frame(maxWidth: .infinity)
.background(Colors.bg)
.overlay(alignment: .bottomTrailing, content: {

// 修改 FloatActionButton 的呼叫格式
// {} 是處理 FloatActionButton 的 點擊事件
FloatActionButton(btnTitle: "Add", img: "svg_add") {
showSheet.toggle()
}
.padding(20)
})
.sheet(isPresented: $showSheet, content: {

// 修改 AddTaskPage 的呼叫格式
// {} 是處理 AddTaskPage 的 task
AddTask { todo in
addTodo(todo: todo)
showSheet.toggle()
}
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
})

// 加上 .Appear 事件
.onAppear {
loadTodos()
}
}
}

關於幾個 closure 的處理方式上面都有講解在這就不贅述了,這邊主要講講 List 顯示方式的改動與 .Appear 是什麼

List 顯示方式的改動

由於我找不太到 id:\.self 是什麼意思,所以我請我最好的工作夥伴 ChatGPT 來解釋一下這是什麼意思

使用 id: \.id 是為了讓 SwiftUI 知道如何唯一識別列表中的每個項目

SwiftUI 的 List 需要唯一的標識符來追蹤和更新列表中的每一個項目,這樣可以在資料更新時,只更新改變了的項目,而不必重建整個列表。如果每個項目有一個唯一的識別符,SwiftUI 可以更有效地處理列表的更新

  • id: SwiftUI 提供的參數,用於指定如何唯一標識每個列表項目
  • \: Swift 語法,用來引用某個屬性,類似於一個 key path
  • .id: 這裡 id 是你在 todo 結構中定義的屬性(id: String),用於唯一標識每個 todo 項目

這麼做的目的是告訴 SwiftUI,列表中的每一個 todo 可以通過 id 這個屬性來區分。例如:

struct Todo: Identifiable {
var id: String // 唯一標識符
var title: String
var isFinish: Bool
}

List(todos, id: \.id) 被使用時,SwiftUI 會透過 id 屬性來追蹤和處理每一個 todo。這樣在資料變動時,SwiftUI 能夠有效地知道應該更新哪個項目,從而提升效能

如果不指定 id,SwiftUI 可能無法有效地追蹤每一個項目,特別是在數據變更時,可能會出現無法正確更新視圖的問題。SwiftUI 會嘗試使用項目的記憶體位置來識別項目,但這在重新加載或數據更新時可能會導致不可預測的行為

.Appear 是什麼

.Appear 是在 View 渲染前所做的事,像在 HomePage 中是做 loadTodos ,代表在我的 HomePage 渲染到手機畫面前先把 UserDefault 的資料抓到 todos 的陣列中供 List 做顯示,如還有問題可以到下面 Apple 官網裡看看

UI 刻好了,資料傳輸的部分也做好了,現在我們來看看效果吧

你說下面灰灰那個提示框是什麼?

如果有接觸 Android 或 Flutter 應該對 snackBar 或 Toast 不陌生吧,這兩個也是我滿常用的元件,但 iOS 沒有啊,那怎麼辦呢?只能自己刻囉,但我功力不夠,這個 Toast 功能是我在 Youtube 上找到的

在上面 HomePage 的 code 中我把 Toast 的部分都砍掉了,如果對 Toast 有興趣可以前往下面這個連結去看看

附上 git 連結,有興趣的可以 clone 下來玩玩

後記

透過這次 Todo List App 的開發更加熟悉 SwiftUI 的開發,也從中學到幾種不同的寫法

--

--