「記錄型」APP結構筆記
前一篇是整理基礎概念,這一篇會整理出範例用到的要素與概念。
也不算是什麼機密內容,這個都是問Claude然後生成出來的。
統一概念
常見的要素:
- 資料儲存
- 新增資料
- 每筆資料呈現
- 刪除資料
- 修改資料
會因為這些要素而影響主畫面的呈現,大多時候是將「每筆資料」呈現在主要區域。
這些要素不一定都需要,因為「紀錄型」APP著重在「紀錄」這個部分,所以資料儲存相關的處理會是首要考慮的目標。
資料儲存
將資料儲存至手機的方式,SwiftUI有三種,分別是 @AppStorage、UserDefaults、SwiftData,可以依照需求選擇不同的儲存方式。
三種可以互相混用,依照APP設計的規模(通常是越做越大),最終都會選擇
SwiftData為主要儲存方式,次要的儲存方式由@AppStorage或UserDefaults擔任。
@AppStorage
是 SwiftUI 提供的 Property Wrapper,把 UserDefaults 包裝成可以直接綁定畫面的形式。
也就是說把 UserDefaults 包裝成 SwiftUI 能夠直接使用的標籤語法,只適合存簡單型別(字串、數字、布林值),不適合存複雜的物件。
UserDefaults
從 UIKit 時代就有的資料儲存方式,能支援多種型別儲存。不過實務上也是偏向使用簡單型別儲存,目的是降低理解門檻。 對於複雜物件會先把資料轉成 JSON 格式再進行儲存,屬於「陽春版」的資料庫做法。
JSON 格式是字串的特定表示法,整筆字串是簡單型別。多種程式語言都有編譯/解譯(Encodable/Decodable)的函式庫,很方便使用。
通常用於簡單或少量的資料儲存,如果要龐大的資料儲存,UserDefaults 不具備資料庫的特性。
修改資料時,因為是整包字串儲存,只能把整包資料重新編碼、整包覆蓋寫回去。
SwiftData
是蘋果比較新的資料庫框架,取代早期的 CoreData,讓你用比較貼近 Swift 語法的方式定義資料模型並存取。
建立核心三件事
- 在
class上用@Model定義資料模型(記得給常用參數預設值) - 在
WindowGroup用.modelContainer()於 App 進入點註冊資料庫 @Environment(\.modelContext)取得context,和 CoreData 取得資料庫實體的邏輯相似
查詢/新增/刪除/修改
查詢以
@Query標籤將資料從資料庫中取出- 通常會以
@Query(sort: \DiaryEntry.date, order: .reverse)將取出的資料排序好:直接在查詢時指定排序(最新日期在最上面),比抓出資料後自己.sorted()更省事。 - 不需要取得
context就能進行操作
- 通常會以
新增/刪除都需要取得
context後進行操作- 新增:
context.insert(_:) - 刪除:
context.delete(_:) - 只能操作
.modelContainer()註冊好的資料庫
- 新增:
修改:不需要
insert,也不是「覆蓋」邏輯- 因為
@Model定義出來的物件是 class(參考型別),@Query拿到的每一筆資料,本身就是資料庫紀錄的參考 - 直接修改屬性即可,SwiftData 會自動偵測變動並存回資料庫:
entry.content = "修改後的內容"
- 這點與 UserDefaults(整包覆蓋)、CoreData(有時需手動
save())不同,是 SwiftData 的一大特色 - 實作編輯畫面時,通常把既有的
entry直接傳入表單畫面,用@Bindable綁定它,使用者改欄位即是直接改這筆資料本身,甚至不需要獨立的儲存按鈕
- 因為
UUID
精確一點:UUID(Universally Unique Identifier)其實是一個 struct(結構),不是 class。蘋果用它來生成一個幾乎不可能重複的識別碼,這樣即使兩筆日記的日期、內容都一樣,系統也能靠這個 id 分辨是不同筆資料。
在
SwiftData裡通常會標記成@Attribute(.unique)來確保它不重複。
畫面設計
如果有學過設計的話可能會有自己的想法,但是像我並沒有學過幾乎都照著課本上操作,不外乎就是List(TableView)、Grid(CollectionView)的方式呈現,每個項目都做成「卡片」的形式閱覽。
或是可以請AI直接設計一整套UI。
範例:今天要做三件事(ThreeToday)
是一個刻意設計得極度簡單的待辦清單 APP:
| 規則 | 說明 |
|---|---|
| 只有三個槽位 | 強迫你思考今天最重要的三件事 |
| 打勾即完成 | 點選圓圈,任務劃線標示完成 |
| 隔天自動清空 | 每天早上打開都是空白的三個槽 |
| 不能無限新增 | 沒有「新增」按鈕,這是設計,不是限制 |
這個設計是藉由製作這個APP學習到SwiftUI的基本概念,感想是實用性不太高。
將元件從主畫面(ContentView)拆分出獨立元件
我們有三列任務,如果全寫在 ContentView,程式碼會變得很長且難以維護。把每一列抽出成獨立的 TaskRowView,可以:
- 讓程式碼更清楚
- 方便重複使用(三列直接用三次)
- 獨立預覽單一元件
每個任務的統一樣式:TaskRowView
import SwiftUI
struct TaskRowView: View {
let number: Int
@Binding var text: String
@Binding var isDone: Bool
@FocusState private var isFocused: Bool
var body: some View {
HStack(spacing: 14) {
Text("\(number)")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(isDone ? Color(.tertiaryLabel) : .accentColor)
.clipShape(Circle())
.animation(.easeInOut, value: isDone)
TextField("輸入今天第 \(number) 件事…", text: $text)
.font(.system(size: 17))
.strikethrough(isDone, color: .secondary)
.foregroundStyle(isDone ? Color.secondary : Color.primary)
.disabled(isDone)
.focused($isFocused)
Button {
guard !text.isEmpty else { return }
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isDone.toggle()
}
if isDone { isFocused = false }
} label: {
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
.font(.system(size: 26))
.foregroundStyle(isDone ? Color.green : Color(.tertiaryLabel))
.symbolEffect(.bounce, value: isDone)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 18)
.padding(.vertical, 18)
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
.animation(.easeInOut(duration: 0.25), value: isDone)
}
}
#Preview {
VStack(spacing: 12) {
TaskRowView(
number: 1,
text: .constant("完成 SwiftUI 教學手冊"),
isDone: .constant(true)
)
TaskRowView(
number: 2,
text: .constant("練習 @State 的用法"),
isDone: .constant(false)
)
TaskRowView(
number: 3,
text: .constant(""),
isDone: .constant(false)
)
}
.padding()
.background(Color(.systemGroupedBackground))
}
動畫與視覺
因為
Button有用到就提出來說明
withAnimation
SwiftUI 動畫非常簡單,只需要把狀態變更包在 withAnimation {} 裡:
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isDone.toggle() // 這個變化會有彈簧動畫
}
} label: {
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
}
常用的動畫類型:
withAnimation(.easeInOut) // 淡入淡出(預設)
withAnimation(.easeInOut(duration: 0.3)) // 指定秒數
withAnimation(.spring()) // 彈簧(有回彈感)
withAnimation(.spring(response: 0.3, dampingFraction: 0.7))
.animation modifier
另一種方式是直接在 View 上加 .animation,當指定的值改變時自動動畫:
Text("\(number)")
.background(isDone ? Color.gray : .accentColor)
.animation(.easeInOut, value: isDone)
// ↑ 動畫類型 ↑ 監聽這個值的變化
SF Symbols 的動畫
iOS 17+ 可以用 .symbolEffect 讓 SF Symbols 播放內建動畫:
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
.symbolEffect(.bounce, value: isDone) // 打勾時彈跳
Shadow 與圓角
讓卡片看起來有層次感:
HStack { /* ... */ }
.background(Color(.systemBackground)) // 白色背景
.cornerRadius(16) // 圓角
.shadow( // 陰影
color: .black.opacity(0.06),
radius: 6,
y: 2 // 向下偏移,製造懸浮感
)
主畫面(ContentView)使用TaskRowView
import SwiftUI
struct ContentView: View {
// 三個任務的文字與勾選狀態,用 @AppStorage 自動持久化
@AppStorage("task1") private var task1 = ""
@AppStorage("task2") private var task2 = ""
@AppStorage("task3") private var task3 = ""
@AppStorage("done1") private var done1 = false
@AppStorage("done2") private var done2 = false
@AppStorage("done3") private var done3 = false
// 記錄上次開啟的日期,用來偵測「是否換新的一天」
@AppStorage("lastOpenDate") private var lastOpenDate = ""
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
VStack(spacing: 0) {
headerView
VStack(spacing: 12) {
TaskRowView(number: 1, text: $task1, isDone: $done1)
TaskRowView(number: 2, text: $task2, isDone: $done2)
TaskRowView(number: 3, text: $task3, isDone: $done3)
}
.padding(.horizontal, 20)
.padding(.top, 8)
Spacer()
footerHint
.padding(.bottom, 36)
}
}
.onAppear {
resetIfNewDay()
}
}
// MARK: - Sub Views
private var headerView: some View {
VStack(spacing: 8) {
Text("今天要做三件事")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.primary)
Text(todayString)
.font(.system(size: 15))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
.padding(.bottom, 28)
}
private var footerHint: some View {
Text("✦ 明天自動清空,今天專注三件事就好")
.font(.system(size: 14))
.foregroundStyle(Color.accentColor)
.padding(.horizontal, 20)
.padding(.vertical, 13)
.background(Color.accentColor.opacity(0.1))
.cornerRadius(12)
.padding(.horizontal, 24)
}
// MARK: - Logic
private var todayString: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_TW")
f.dateFormat = "yyyy年M月d日 EEEE"
return f.string(from: Date())
}
private func resetIfNewDay() {
let today = DateFormatter.localizedString(
from: Date(), dateStyle: .short, timeStyle: .none
)
guard lastOpenDate != today else { return }
task1 = ""; task2 = ""; task3 = ""
done1 = false; done2 = false; done3 = false
lastOpenDate = today
}
}
#Preview {
ContentView()
}
隔天自動清空:日期邏輯
只用
@AppStorage就已經明講了只做簡單的儲存作業。
APP 的核心設計之一:隔天打開,自動重置。
思路
- 用
@AppStorage儲存「上次開啟的日期」 - 每次 APP 出現時(
onAppear),比對今天日期和儲存的日期 - 如果不同(換新的一天),就清空所有任務
@AppStorage("lastOpenDate") private var lastOpenDate = ""
取得今天的日期字串
private var todayString: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_TW")
f.dateFormat = "yyyy年M月d日 EEEE"
return f.string(from: Date())
}
重置邏輯
private func resetIfNewDay() {
let today = DateFormatter.localizedString(
from: Date(), dateStyle: .short, timeStyle: .none
)
guard lastOpenDate != today else { return }
task1 = ""; task2 = ""; task3 = ""
done1 = false; done2 = false; done3 = false
lastOpenDate = today
}
onAppear:View 出現時執行
var body: some View {
// ... view 內容 ...
.onAppear {
resetIfNewDay() // 每次 View 出現時檢查
}
}
onAppear的時機:View 第一次顯示在畫面上時觸發。類似 UIKit 的viewDidAppear。
總結
就教學的角度來看,這樣簡單的內容就算完成了「你的第一個APP」,不過因為踏入職場許久之後,多少會覺得這個APP不具備商業價值,而它的價值僅剩下「懂了」這個用途,並沒有給我帶很多大的成就感。因為現今這樣的APP請AI寫可以寫得更加精美複雜。
不過看不懂AI在寫什麼的話幾乎就是在說「把開發的主動權交給的別人(AI)」,某方面來說也算是再說「自己不重要到隨時可以被取代」吧?因此這個過程多少還是有必要的。