希紋的部落格

「記錄型」APP結構筆記

前一篇是整理基礎概念,這一篇會整理出範例用到的要素與概念。

也不算是什麼機密內容,這個都是問Claude然後生成出來的。

統一概念

常見的要素:

會因為這些要素而影響主畫面的呈現,大多時候是將「每筆資料」呈現在主要區域。

這些要素不一定都需要,因為「紀錄型」APP著重在「紀錄」這個部分,所以資料儲存相關的處理會是首要考慮的目標。

資料儲存

將資料儲存至手機的方式,SwiftUI有三種,分別是 @AppStorageUserDefaultsSwiftData,可以依照需求選擇不同的儲存方式。

三種可以互相混用,依照APP設計的規模(通常是越做越大),最終都會選擇SwiftData為主要儲存方式,次要的儲存方式由@AppStorageUserDefaults擔任。

@AppStorage

是 SwiftUI 提供的 Property Wrapper,把 UserDefaults 包裝成可以直接綁定畫面的形式。

也就是說把 UserDefaults 包裝成 SwiftUI 能夠直接使用的標籤語法,只適合存簡單型別(字串、數字、布林值),不適合存複雜的物件。

UserDefaults

從 UIKit 時代就有的資料儲存方式,能支援多種型別儲存。不過實務上也是偏向使用簡單型別儲存,目的是降低理解門檻。 對於複雜物件會先把資料轉成 JSON 格式再進行儲存,屬於「陽春版」的資料庫做法。

JSON 格式是字串的特定表示法,整筆字串是簡單型別。多種程式語言都有編譯/解譯(Encodable/Decodable)的函式庫,很方便使用。

通常用於簡單或少量的資料儲存,如果要龐大的資料儲存,UserDefaults 不具備資料庫的特性。

修改資料時,因為是整包字串儲存,只能把整包資料重新編碼、整包覆蓋寫回去。

SwiftData

是蘋果比較新的資料庫框架,取代早期的 CoreData,讓你用比較貼近 Swift 語法的方式定義資料模型並存取。

建立核心三件事

  1. class 上用 @Model 定義資料模型(記得給常用參數預設值)
  2. WindowGroup.modelContainer() 於 App 進入點註冊資料庫
  3. @Environment(\.modelContext) 取得 context,和 CoreData 取得資料庫實體的邏輯相似

查詢/新增/刪除/修改

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 的核心設計之一:隔天打開,自動重置

思路

  1. @AppStorage 儲存「上次開啟的日期」
  2. 每次 APP 出現時(onAppear),比對今天日期和儲存的日期
  3. 如果不同(換新的一天),就清空所有任務
@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)」,某方面來說也算是再說「自己不重要到隨時可以被取代」吧?因此這個過程多少還是有必要的。

#swift