APP範例:優雅筆記,原始碼
關連項目:
雖然直接給AI生成也能夠依照檔案名稱生出一個差不多的內容,不過沒有可以參考的指標就無法確定自己撰寫的內容是否正確,所以想想還是貼出來。
不上GitHub只是覺得這個專案的規模沒有大到需要這樣做。
已經包含了Checklist的擴充,可自行斟酌參考是否要拿掉
ElegantNotes
ElegantNotesApp.swift
import SwiftUI
import SwiftData
@main
struct ElegantNotesApp: App {
//MARK: - values
private let container: ModelContainer = {
let schema = Schema([
Note.self,
Tag.self,
ChecklistItem.self//[Checklist]新增項目
])// ← 告訴 SwiftData 有哪些 Model
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("無法建立 SwiftData 容器: \(error)")
}
}()
//MARK: - scene
var body: some Scene {
WindowGroup {
NotesGridView()
.onAppear { preloadDefaultTagsIfNeeded() }
}
.modelContainer(container)// ← 注入到整個 App,任何子 View 都可取用
}
//MARK: - functions
//預載預設資料
//App 第一次啟動時,自動插入預設標籤和歡迎筆記
//依照APP需求是否要在第一次啟動載入預設資料,以及載入的方式是否要用方便管理的方式呈現
@MainActor
private func preloadDefaultTagsIfNeeded() {
let context = container.mainContext
let descriptor = FetchDescriptor<Tag>()
// 如果已有標籤,就不需要預載
guard let count = try? context.fetchCount(descriptor), count == 0 else { return }
let defaultTags = [
Tag(name: "工作", colorHex: "#4A90E2"),
Tag(name: "個人", colorHex: "#2ECC71"),
// ...
]
defaultTags.forEach { context.insert($0) }
try? context.save()
}
}
Models
ChecklistItem.swift
import SwiftData
//💡 @Model 讓 SwiftData 自動追蹤屬性變更。var note: Note? 是與 Note 的反向關聯(inverse),方便在刪除 Note 時一併清除項目。
@Model
final class ChecklistItem {
var text: String
var isChecked: Bool
var order: Int
// 反向關聯到所屬 Note
var note: Note?
init(text: String, isChecked: Bool = false, order: Int = 0) {
self.text = text
self.isChecked = isChecked
self.order = order
}
}
Note.swift
import Foundation
import SwiftData
@Model // ← 告訴 SwiftData 這是一個資料模型
final class Note {
//MARK: - values
@Attribute(.unique) var id: UUID // ← .unique 確保 id 不重複
var title: String
var content: String
var createdAt: Date
var updatedAt: Date
var colorHex: String // ← 存莫蘭迪色 (#F2F2F7 格式)
// 多對多關係:inverse 告訴 SwiftData Tag.notes 是反向關聯
@Relationship(inverse: \Tag.notes)
var tags: [Tag] = []
// 計算屬性(不儲存到資料庫)
var displayTitle: String {
let t = title.trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty ? "未命名筆記" : t
}
//預覽文字
var previewText: String {
let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedContent.isEmpty ? "開始寫下你的想法。" : trimmedContent
}
// [Checklist]是否為清單模式
var isChecklist: Bool = false
// [Checklist]清單項目(刪除筆記時一併刪除)
@Relationship(deleteRule: .cascade, inverse: \ChecklistItem.note)
var checklistItems: [ChecklistItem] = []
// [Checklist]已完成項目數(Computed Property)
var completedCount: Int {
checklistItems.filter { $0.isChecked }.count
}
//MARK: - init
init(title: String = "", content: String = "", colorHex: String = "#F2F2F7") {
self.id = UUID()
self.title = title
self.content = content
self.createdAt = Date()
self.updatedAt = Date()
self.colorHex = colorHex
}
}
Tag.swift
import Foundation
import SwiftData
// ⚠️ @Relationship(inverse:) 只需在其中一個 Model 寫,另一個留空陣列即可。兩邊都寫會報錯。
@Model
final class Tag {
@Attribute(.unique) var id: UUID
var name: String
var colorHex: String
// 反向關聯,SwiftData 自動維護
var notes: [Note] = []
init(name: String, colorHex: String = "#007AFF") {
self.id = UUID()
self.name = name
self.colorHex = colorHex
}
}
Utilities
Color+Hex.swift
因為原生的Color沒有用hex字串轉成顏色的功能,所以要自己寫一個
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 1, 1, 1)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
PreviewContainer.swift
import SwiftUI
import SwiftData
@MainActor
struct PreviewContainer {
static let shared: ModelContainer = {
let schema = Schema([Note.self,
Tag.self,
ChecklistItem.self//[Checklist]新增項目
])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true // ← 記憶體中,不寫入磁碟
)
do {// 插入假資料...
let container = try ModelContainer(for: schema, configurations: [config])
let context = container.mainContext
// 預設標籤與莫蘭迪顏色
let workTag = Tag(name: "工作", colorHex: "#4A90E2") // 經典藍
let personalTag = Tag(name: "個人", colorHex: "#2ECC71") // 翠綠
let ideaTag = Tag(name: "靈感", colorHex: "#E67E22") // 亮橘
let urgentTag = Tag(name: "緊急", colorHex: "#E74C3C") // 珊瑚紅
let thoughtTag = Tag(name: "想法", colorHex: "#9B59B6") // 經典紫
context.insert(workTag)
context.insert(personalTag)
context.insert(ideaTag)
context.insert(urgentTag)
context.insert(thoughtTag)
// 預設筆記與淡雅配色(莫蘭迪粉彩)
let note1 = Note(
title: "週一團隊週會",
content: "1. 檢視上週 OKR 達成狀況。\n2. 討論新版 UI 設計稿。\n3. 分配本週開發任務,預計週五完成首個 Beta 版。",
colorHex: "#E3F2FD" // 淡藍
)
note1.tags.append(workTag)
note1.tags.append(urgentTag)
//[Checklist]Checklist增加範例
let note2 = Note(
title: "週末超市採買清單",
content: "",
colorHex: "#E8F5E9" // 淡綠
)
note2.isChecklist = true
note2.tags.append(personalTag)
// 採買清單項目(有機雞蛋已標記為購買完成)
let shoppingItems: [(String, Bool)] = [
("鮮奶 2 瓶", false),
("有機雞蛋 1 盒", true),
("燕麥片", false),
("雞胸肉與酪梨", false),
("氣泡水一箱", false)
]
for (index, (text, checked)) in shoppingItems.enumerated() {
let item = ChecklistItem(text: text, isChecked: checked, order: index)
item.note = note2
context.insert(item)
note2.checklistItems.append(item)
}
// 💡 isStoredInMemoryOnly: true 的 PreviewContainer 每次 Preview 都是全新的資料,因此可以放心在 init 時直接插入假資料,不會影響正式資料庫。
let note3 = Note(
title: "ElegantNotes 開發構想",
content: "1. 增加筆記 Widget,支援在桌面展示最新筆記。\n2. 支援 Markdown 渲染以提供更豐富的文字格式。\n3. iCloud 雲端同步功能。",
colorHex: "#FFF3E0" // 淡橘
)
note3.tags.append(ideaTag)
note3.tags.append(workTag)
let note4 = Note(
title: "讀書筆記:《原子習慣》",
content: "「所有巨大的改變都源自微小的開始。」\n- 每天進步 1%,一年後會進步 37 倍。\n- 打造系統,而非單純設定目標。\n- 讓好習慣的提示顯而易見。",
colorHex: "#F3E5F5" // 淡紫
)
note4.tags.append(personalTag)
note4.tags.append(ideaTag)
let note5 = Note(
title: "極簡生活雜記",
content: "生活越簡單,心靈越自由。減少不必要的物品,專注在真正重要的事情上。",
colorHex: "#ECEFF1" // 淡灰
)
context.insert(note1)
context.insert(note2)
context.insert(note3)
context.insert(note4)
context.insert(note5)
try? context.save()
return container
}
catch {
fatalError("無法建立 PreviewContainer: \(error)")
}
}()
}
Views
ChecklistEditorView.swift
import SwiftUI
import SwiftData
struct ChecklistEditorView: View {
@Bindable var note: Note
@Environment(\.modelContext) private var modelContext
var sortedItems: [ChecklistItem] {
note.checklistItems.sorted { $0.order < $1.order }
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(sortedItems) { item in
ChecklistItemRow(item: item) { deleteItem(item) }
Divider().padding(.leading, 44)
}
// 新增項目按鈕
Button(action: addItem) {
HStack(spacing: 12) {
Image(systemName: "plus.circle.fill")
.font(.title3).foregroundColor(.secondary)
Text("新增項目").foregroundColor(.secondary)
}
.padding(.vertical, 8)
}
.buttonStyle(.plain)
}
}
private func addItem() {
let nextOrder = (note.checklistItems.map { $0.order }.max() ?? -1) + 1
let item = ChecklistItem(text: "", order: nextOrder)
item.note = note
modelContext.insert(item)
note.checklistItems.append(item)
}
private func deleteItem(_ item: ChecklistItem) {
note.checklistItems.removeAll {
$0.persistentModelID == item.persistentModelID
}
modelContext.delete(item)
}
}
#Preview {
let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
ChecklistEditorView(note: note)
}
ChecklistItemRow.swift
/**
💡 @Bindable 是 SwiftData 版本的 @Binding,讓 View 能雙向綁定 @Model 物件的屬性,任何修改都會自動寫入資料庫。
💡 .contentTransition(.symbolEffect(.replace)) 是 iOS 17 新增的 SF Symbols 動畫,勾選時圖示會有流暢的切換效果。
*/
import SwiftUI
struct ChecklistItemRow: View {
@Bindable var item: ChecklistItem
let onDelete: () -> Void
var body: some View {
HStack(spacing: 12) {
// 勾選按鈕:切換時播放 SF Symbol 動畫
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
item.isChecked.toggle()
}
} label: {
Image(systemName: item.isChecked ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundColor(item.isChecked ? .green : .secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
// 文字輸入:已完成時加刪除線
TextField("輸入項目...", text: $item.text)
.strikethrough(item.isChecked, color: .secondary)
.foregroundColor(item.isChecked ? .secondary : .primary)
.animation(.easeInOut(duration: 0.2), value: item.isChecked)
// 刪除按鈕
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary.opacity(0.4))
.font(.subheadline)
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
}
#Preview {
let tempItem = ChecklistItem(text: "test", isChecked: false)
ChecklistItemRow(item: tempItem, onDelete: {})
}
NoteCardView.swift
import SwiftUI
import SwiftData
struct NoteCardView: View {// ← 遵守 View 協定
//MARK: - values
let note: Note// ← 外部傳入(不可變)
//MARK: - view
var body: some View {// ← 必須實作 body
VStack(alignment: .leading, spacing: 12) {
Text(note.displayTitle) // 標題
//[Checklist]💡 .prefix(4) 限制卡片最多顯示四個項目,避免卡片過長破壞網格排版。completedCount 是 Note 上的 computed property,即時反映勾選狀態。
if note.isChecklist {
let sortedItems = note.checklistItems.sorted { $0.order < $1.order }
VStack(alignment: .leading, spacing: 5) {
ForEach(sortedItems.prefix(4)) { item in
HStack(spacing: 6) {
Image(systemName: item.isChecked
? "checkmark.circle.fill" : "circle")
.font(.caption)
.foregroundColor(item.isChecked ? .green : .secondary)
Text(item.text.isEmpty ? "(空白項目)" : item.text)
.font(.subheadline)
.strikethrough(item.isChecked, color: .secondary)
.lineLimit(1)
}
}
if !sortedItems.isEmpty {
Text("\(note.completedCount)/\(sortedItems.count) 已完成")
.font(.caption2).foregroundColor(.secondary).padding(.top, 2)
}
}
} else {
Text(note.previewText) // 摘要
.font(.subheadline).foregroundColor(.secondary)
.lineLimit(4).multilineTextAlignment(.leading)
}
Spacer(minLength: 0) // 彈性空間(推開下方元素)
HStack { /* 標籤 */ }
Text(formatDate(note.updatedAt)) // 日期
// 顯示標籤列表
ForEach(note.tags.sorted(by: { $0.name < $1.name })) { tag in
Text(tag.name)
.foregroundColor(Color(hex: tag.colorHex))
.padding(.horizontal, 8)
.background(Color(hex: tag.colorHex).opacity(0.12))
.cornerRadius(8)
}
}
.padding(16) // 內邊距
.frame(maxWidth: .infinity, minHeight: 140,
alignment: .leading) // 填滿寬度、最小高度
.background(Color(hex: note.colorHex)) // 背景色
.cornerRadius(16) // 圓角
.shadow(color: .black.opacity(0.06),
radius: 5, x: 0, y: 3) // 陰影
.overlay( // 疊加一層微妙邊框
RoundedRectangle(cornerRadius: 16)
.stroke(Color.primary.opacity(0.05), lineWidth: 1)
)
}
//MARK: - functions
// 格式化日期
//💡 private func 在 SwiftUI struct 中與 UIKit 的 private method 語義相同,只有本 struct 可呼叫。DateFormatter 不建議每次都重新建立,在小型 App 中影響不大,進階可考慮設為 static。
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy/MM/dd HH:mm"
return formatter.string(from: date)
}
}
//MARK: - preview
//💡 #Preview 使用記憶體 container(PreviewContainer)而非實際資料庫,所以預覽的資料不會真的存檔。
#Preview {
let container = PreviewContainer.shared // 假資料容器
let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
return NoteCardView(note: note)
.padding()
.modelContainer(container)
}
NoteEditorView.swift
import SwiftUI
import SwiftData
struct NoteEditorView: View {
//MARK: - values
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss // ← 取得關閉此 View 的函式
@Bindable var note: Note // ← 讓 note 的屬性可以用 $ 綁定
@State private var showTagSheet = false
// 莫蘭迪粉彩配色清單
// 備註:這個清單可以由其他方式統一掌控
let noteColors = [
"#F2F2F7", // 經典淺灰
"#FADBD8", // 莫蘭迪淡粉
"#D4E6F1", // 莫蘭迪淡藍
"#D5F5E3", // 莫蘭迪淡綠
"#FCF3CF", // 莫蘭迪淡黃
"#EBDEF0", // 莫蘭迪淡紫
"#F5CBA7" // 莫蘭迪淡橘
]
//MARK: - view
var body: some View {
ZStack {
// 背景色 (配合選定的 Hex 色彩)
Color(hex: note.colorHex)
.ignoresSafeArea()
.animation(.easeInOut(duration: 0.3), value: note.colorHex)
VStack(spacing: 0) {
// 1. 編輯區域
mainZone
// 2. 底部工具列 (配色選擇與標籤管理)
bottomTools
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("完成") {
saveAndDismiss()
}
.fontWeight(.semibold)
.accessibilityIdentifier("done-button")
}
}
.sheet(isPresented: $showTagSheet) {
TagSelectionView(note: note)
}
.onDisappear {
saveNote()
}
}
//主要編輯區域
var mainZone: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// 標題輸入框
TextField("輸入標題...", text: $note.title)// ← $note.title 雙向綁定
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
.submitLabel(.next)
.padding(.top, 16)
.accessibilityIdentifier("note-title-field")
// 關聯標籤展示列(如果有)
if !note.tags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(note.tags.sorted(by: { $0.name < $1.name })) { tag in
Text(tag.name)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(Color(hex: tag.colorHex))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Color(hex: tag.colorHex).opacity(0.12)
)
.cornerRadius(8)
}
}
}
}
// [Checklist]內文區域:清單模式 vs 一般模式
if note.isChecklist {
ChecklistEditorView(note: note)
.padding(.top, 4)
} else {
// 內文區域
TextEditor(text: $note.content)
.font(.body)
.scrollContentBackground(.hidden)
.frame(minHeight: 400)
.foregroundColor(.primary)
.accessibilityIdentifier("note-body-editor")
}
}
.padding(.horizontal, 20)
}
}
var bottomTools: some View {
VStack(spacing: 12) {
Divider()
HStack {
// 色票選擇
clolorSelecter
Spacer()
// [Checklist]清單模式切換按鈕
Button(action: {
withAnimation(.easeInOut(duration: 0.2)) {
note.isChecklist.toggle()
}
}) {
HStack(spacing: 6) {
Image(systemName: note.isChecklist
? "checkmark.circle.fill" : "list.bullet")
Text(note.isChecklist ? "清單" : "文字")
}
.font(.subheadline).fontWeight(.medium)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(note.isChecklist
? Color.green.opacity(0.15) : Color.primary.opacity(0.06))
.foregroundColor(note.isChecklist ? .green : .primary)
.cornerRadius(10)
}
// 標籤設定按鈕
Button(action: { showTagSheet = true }) {
HStack(spacing: 6) {
Image(systemName: "tag")
Text("標籤")
}
.font(.subheadline)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.primary.opacity(0.06))
.cornerRadius(10)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
.background(Color(hex: note.colorHex).opacity(0.95))
}
//色票選擇器
var clolorSelecter: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(noteColors, id: \.self) { hex in
Circle()
.fill(Color(hex: hex))
.frame(width: 28, height: 28)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.6), lineWidth: note.colorHex == hex ? 2.5 : 0)
)
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
.onTapGesture {
note.colorHex = hex
note.updatedAt = Date()
}
}
}
.padding(.vertical, 4)
}
}
//MARK: - functions
// 儲存並返回
private func saveAndDismiss() {
saveNote()
dismiss()
}
// 儲存筆記
private func saveNote() {
note.updatedAt = Date()
try? modelContext.save()
}
}
#Preview {
let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
NoteEditorView(note: note)
}
NotesGridView.swift
import SwiftUI
import SwiftData
struct NotesGridView: View {
//MARK: - values
// 取得 SwiftData 操作上下文(類比 CoreData 的 viewContext)
@Environment(\.modelContext) private var modelContext
// 自動從 SwiftData 查詢並訂閱更新
//💡 @Query 是 SwiftData 的殺手功能:當資料庫有任何變動,@Query 自動更新,View 自動重繪。不需要像 CoreData 那樣手動監聽 NSFetchedResultsController。
@Query(sort: \Note.updatedAt, order: .reverse) private var notes: [Note]
@Query(sort: \Tag.name) private var tags: [Tag]
// 本地 UI 狀態
@State private var searchText = ""
@State private var selectedTag: Tag? = nil
@State private var path = NavigationPath()// 導航路徑(堆疊)
// 雙欄網格設定
let columns = [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
]
// 過濾後的筆記清單
var filteredNotes: [Note] {
notes.filter { note in
let matchesSearch = searchText.isEmpty ||
note.title.localizedCaseInsensitiveContains(searchText) ||
note.content.localizedCaseInsensitiveContains(searchText)
let matchesTag = selectedTag == nil || note.tags.contains(selectedTag!)
return matchesSearch && matchesTag
}
}
//MARK: - view
var body: some View {
NavigationStack(path: $path) {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
// 主內容(標籤列 + 網格)
VStack(spacing: 0) {
// 1. 標籤篩選列 (橫向滾動)
tagDisplay
//2.筆記網格或空白提示頁面
if filteredNotes.isEmpty {
//💡 用 if/else 在「有資料」與「空白提示」之間切換是 SwiftUI 的常見模式。搜尋時根據 searchText.isEmpty 顯示不同的提示文字,讓使用者知道是「沒資料」還是「搜尋無結果」。
noteEmpty
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
else {
noteGrid
}
}
// 3. 右下角浮動新增按鈕 (FAB)
fabButton
}
.navigationTitle("優雅筆記")
.searchable(text: $searchText, prompt: "搜尋標題或內容...")
.navigationDestination(for: Note.self) { note in
NoteEditorView(note: note)// 當 path 加入 Note 物件,導航到此
}
}
}
//筆記空畫面
var noteEmpty: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "note.text.fuzzyfinder")
.font(.system(size: 60))
.foregroundColor(.secondary.opacity(0.6))
Text(searchText.isEmpty
? "還沒有任何筆記\n點擊右下角建立一篇吧!"
: "找不到符合關鍵字的筆記")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Spacer()
}
}
//筆記網格
var noteGrid: some View {
ScrollView {
//💡 Lazy 代表「延遲載入」,只有出現在螢幕上的 cell 才會被渲染,節省記憶體,類似 UICollectionView 的 cell 重用機制。
LazyVGrid(columns: columns, spacing: 12) {
ForEach(filteredNotes) { note in
NavigationLink(value: note) { // 點擊 → 推入 note 到 path
NoteCardView(note: note)
}
.buttonStyle(PlainButtonStyle()) // 移除預設藍色樣式
.contextMenu {
Button(role: .destructive) {
deleteNote(note)
} label: {
Label("刪除筆記", systemImage: "trash")
}
}
}//end foreach
}
.padding(16)
}
}
//標籤展示
var tagDisplay: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// "全部" 按鈕
Button(action: {
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
selectedTag = nil
}
}) {
Text("全部")
.font(.subheadline)
.fontWeight(.medium)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(selectedTag == nil ? Color.primary : Color(.systemBackground))
.foregroundColor(selectedTag == nil ? Color(.systemBackground) : .primary)
.cornerRadius(20)
.shadow(color: Color.black.opacity(0.04), radius: 3, x: 0, y: 1)
}
// 各個自訂標籤
ForEach(tags) { tag in
Button(action: {
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
selectedTag = selectedTag == tag ? nil : tag
}
}) {
HStack(spacing: 6) {
Circle()
.fill(Color(hex: tag.colorHex))
.frame(width: 8, height: 8)
Text(tag.name)
}
.font(.subheadline)
.fontWeight(.medium)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(selectedTag == tag ? Color(hex: tag.colorHex) : Color(.systemBackground))
.foregroundColor(selectedTag == tag ? .white : .primary)
.cornerRadius(20)
.shadow(color: Color.black.opacity(0.04), radius: 3, x: 0, y: 1)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.background(Color(.systemBackground))
.shadow(color: Color.black.opacity(0.02), radius: 5, x: 0, y: 3)
}
//FAB 按鈕 - 用 VStack/HStack Spacer 推到右下角
var fabButton: some View {
VStack {
Spacer()
HStack {
Spacer()
Button(action: createNewNote) {
Image(systemName: "plus")
.font(.title.bold())
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(
LinearGradient(
colors: [Color(hex: "#4A90E2"), Color(hex: "#50E3C2")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.cornerRadius(28)
.shadow(color: Color(hex: "#4A90E2").opacity(0.4), radius: 8, x: 0, y: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
.accessibilityIdentifier("empty-add-note-button")
}
}
}
//MARK: - functions
// 導航(類比 UINavigationController.pushViewController)
// 新增筆記
private func createNewNote() {
let newNote = Note()
modelContext.insert(newNote)
path.append(newNote) // 推入路徑 → 觸發 navigationDestination
}
// 刪除筆記
private func deleteNote(_ note: Note) {
withAnimation {
modelContext.delete(note)
try? modelContext.save()
}
}
}
#Preview {
NotesGridView()
}
TagSelectionView.swift
/**
TagSelectionView 是一個 Sheet,包含兩個區塊:
• 新增標籤區:TextField + 顏色選擇器 + 確認按鈕
• 標籤清單:List 顯示所有標籤,點擊切換與目前筆記的關聯
*/
import SwiftUI
import SwiftData
struct TagSelectionView: View {
//MARK: - values
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
// 綁定的筆記,點選時會與此筆記關聯/取消關聯
@Bindable var note: Note
@Query(sort: \Tag.name) private var allTags: [Tag]
@State private var newTagName = "" // 新標籤名稱輸入
@State private var selectedColorHex = "#4A90E2" // 目前選中的顏色
// 六個預設標籤顏色
let tagColors = [
"#4A90E2", // 經典藍
"#2ECC71", // 翠綠
"#E67E22", // 亮橘
"#E74C3C", // 珊瑚紅
"#9B59B6", // 經典紫
"#1ABC9C" // 青綠
]
//MARK: - view
var body: some View {
NavigationStack {
VStack(spacing: 20) {
// 1. 新增標籤區塊
addNewTagView
Divider()
// 2. 標籤清單與點選關聯區
tagListEdit
}
.navigationTitle("管理標籤")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("完成") {
dismiss()
}
}
}
}
}
//新增標籤區塊
var addNewTagView: some View {
VStack(alignment: .leading, spacing: 10) {
Text("新建標籤")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.secondary)
HStack(spacing: 12) {
TextField("標籤名稱...", text: $newTagName)
.padding(10)
.background(Color(.systemGray6))
.cornerRadius(8)
Button(action: createTag) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(Color(hex: selectedColorHex))
}
.disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
// 標籤顏色選擇器
HStack(spacing: 12) {
ForEach(tagColors, id: \.self) { hex in
Circle()
.fill(Color(hex: hex))
.frame(width: 24, height: 24)
.overlay(
Circle()
.stroke(Color.primary, lineWidth: selectedColorHex == hex ? 2 : 0)
)
.onTapGesture {
withAnimation(.spring()) {
selectedColorHex = hex
}
}
}
}
.padding(.top, 4)
}
.padding(.horizontal)
.padding(.top)
}
//標籤清單與點選關聯區
var tagListEdit: some View {
VStack(alignment: .leading, spacing: 10) {
Text("所有標籤 (點擊可加入/移出筆記)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.secondary)
.padding(.horizontal)
if allTags.isEmpty {
VStack(spacing: 12) {
Spacer()
Image(systemName: "tag.slash")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("目前還沒有標籤,在上方建立一個吧!")
.font(.footnote)
.foregroundColor(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(allTags) { tag in
HStack {
// 標籤樣式
HStack {
Circle()
.fill(Color(hex: tag.colorHex))
.frame(width: 12, height: 12)
Text(tag.name)
.foregroundColor(.primary)
}
Spacer()
// 關聯狀態勾選框
if note.tags.contains(tag) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(hex: tag.colorHex))
} else {
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
.contentShape(Rectangle())
.onTapGesture {
toggleTag(tag)
}
.accessibilityIdentifier("tag-\(tag.name)")
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
deleteTag(tag)
} label: {
Label("刪除", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
}
}
}
//MARK: - functions
// 新增標籤
private func createTag() {
let trimmedName = newTagName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// 檢查是否已存在同名標籤
if let existingTag = allTags.first(where: { $0.name.lowercased() == trimmedName.lowercased() }) {
// 直接與目前筆記綁定
if !note.tags.contains(existingTag) {
note.tags.append(existingTag)
}
} else {
let newTag = Tag(name: trimmedName, colorHex: selectedColorHex)
modelContext.insert(newTag)
note.tags.append(newTag)
}
newTagName = ""
try? modelContext.save()
}
// 切換標籤關聯
private func toggleTag(_ tag: Tag) {
if let index = note.tags.firstIndex(of: tag) {
note.tags.remove(at: index)
} else {
note.tags.append(tag)
}
try? modelContext.save()
}
// 徹底從資料庫刪除標籤
private func deleteTag(_ tag: Tag) {
modelContext.delete(tag)
try? modelContext.save()
}
}
#Preview {
let note = Note(title: "測試", content: "內容", colorHex: "#F5E6CC")
TagSelectionView(note: note)
}
總結
這些程式碼都可以在完全新建立ElegantNotes的SwiftUI專案中順利執行,這個做完之後也就幾乎不會看,但總有那麼一天突然想到要複習的時候就會拿出來練習。