iOS 開發 #13 | SwiftUI ( 3 )
學習 SwiftUI 從 Todo List 開始 ( 2 ) 資料處理
前言
在上一篇完成了 Todo List App 的 UI 設計,接下來就是處理資料的部分了
資料處理
這次 Todo List App 的儲存方式我選擇用 UserDefault
,原因是因為他比較簡單,我才不會說其他的我不會用
一開始先來建立一個 Todo
的 Model
,建立一個叫做 TodoModel
的檔案並將下面的 Code 打上去
import Foundation
struct Todo: Codable {
var id: String
var title: String
var isFinish: Bool
}
讓這個 Model
遵從 Codable
,Codable
裡面有兩個等等會用到的東西,Encodable
跟 Decodable
,我們會把建立的待辦事項透過 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
時就必須傳入資料進去
剛剛試過其實不移除預設值是沒問題的,但我個人習慣是會把它砍掉,我自己是怕當我設了預設值後會忘記傳值給他
再來是 onCheck
的 closure
,就跟我在註解中解釋的一樣,透過這個接受 Boolean
的 onCheck
把 isChecked
的狀態傳出去供外部做處理
修改完 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 需要修改比較多的地方,我分兩部分講解:
- CRUD Function
- 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
讀取的資料,而 loadTodos
、addTodo
、updateTodo
、deleteTodo
則是去呼叫 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 的開發,也從中學到幾種不同的寫法