20 個 Swift Playgrounds 小專案

學 SwiftUI,最怕一開始就面對抽象名詞:List、@State、Timer……名字都對,卻不知道「用在哪裡」。

換一個問法會輕鬆很多:如果 App 是為日常生活或課堂而做,你會先做哪一個?

這篇介紹兩套給 STEAM/編程課用的 Swift Playgrounds 小專案——生活場景 10 個、學校/學習場景 10 個。每個資料夾都是可獨立貼上執行的 App:介面用溫暖書面語繁中,只靠本地 @State,無需網絡、無需 API Key。適合課堂投影,也適合學生跟住改成自己的版本。

為甚麼用「場景」學,而不是只背元件?

因為場景會逼你把零件組起來。買餸清單自然會碰到 List 與刪除;番茄鐘自然會碰到 Timer;考試倒數自然會碰到 Date。學生先看到「畫面會動、解決一件小事」,再回頭對元件名字,記憶會穩得多。

兩包是姊妹關係:結構相同、開啟方式相同,題材錯開——一邊是家裡與日常,一邊是功課與校園。課堂可以按主題輪流用,不必一次做完 20 個。

如何在 Swift Playgrounds 開啟

  1. 在 iPad 或 Mac 開啟 Swift Playgrounds。
  2. 建立新專案:選 App(不是空白 Playground 頁)。
  3. 用範例資料夾裡的 MyApp.swift 取代專案入口;用 ContentView.swift 取代介面檔。
  4. 按 Run ▶。若提示缺少 import SwiftUI,多半是專案類型不是 App。

每個範例都是 self-contained,大致長這樣(以生活包 01 為例):

顯示/隱藏完整程式碼:MyApp.swift
import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

完整程式與兩包 zip 已放在 Google Drive「程式大師」資料夾,可下載後按編號開啟對應子資料夾。

生活場景 Swift Playgrounds 小專案

生活場景 01–10

適合引入「清單、表單、計時、統計」等基礎;題材貼近家庭與個人日常。

編號 標題 一句用途 會學到的概念
01 買餸/購物清單 新增、剔選、刪除今日要買的食材 List、ForEach、@State、Identifiable、onDelete、TextField
02 家務分工打卡 按人篩選家務並打卡完成進度 Picker、ProgressView、篩選、LazyVStack
03 日常開支記帳 記一筆開支、選分類、看今日合計 Double、reduce、keyboardType、格式化字串
04 服藥/喝水提醒 本地倒數+清單打卡(無需推播) Timer.publish、onReceive、倒數
05 運動打卡 用 Stepper 記分鐘、看本週長條 Stepper、自訂長條、計算屬性
06 週末行程規劃 六/日行程表,按時間排序 Binding、Picker 切換資料、排序
07 今日穿搭/天氣 模擬氣溫給穿衣建議 Slider、Toggle、範圍匹配、animation
08 食譜步驟計時器 逐步煮飯倒數,完成跳下一步 Timer、步驟索引、ContentUnavailableView
09 水電抄表 記水/電讀數,計算今期用量 Optional、篩選排序、差值計算
10 生日/禮物願望 願望清單+優先級+已擁有 多欄位模型、自訂排序、安全刪除

課堂小提示: 可先跑 01 講清單骨架,再挑 04 或 08 講 Timer;最後請學生改文案,或加一項自己的生活功能。提醒與藥相關的範例僅作程式練習,請勿填入真實藥名劑量指示。

以下是生活包每個示範的完整 ContentView.swift。同一資料夾的 MyApp.swift 與開頭示範相同結構,用該檔取代 App 入口即可。

01 買餸/購物清單

新增、剔選、刪除今日要買的食材。

顯示/隱藏完整程式碼:01 買餸/購物清單
import SwiftUI

/// 生活場景:買餸/購物清單
/// 示範 List、Toggle、新增項目、刪除,適合初學 SwiftUI。
struct GroceryItem: Identifiable {
    let id = UUID()
    var name: String
    var isBought: Bool
}

struct ContentView: View {
    @State private var items: [GroceryItem] = [
        GroceryItem(name: "雞蛋", isBought: false),
        GroceryItem(name: "牛奶", isBought: false),
        GroceryItem(name: "青菜", isBought: true),
        GroceryItem(name: "米", isBought: false)
    ]
    @State private var newItem = ""

    private var remaining: Int {
        items.filter { !$0.isBought }.count
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 12) {
                // 頂部統計
                HStack {
                    Label("仲有 \(remaining) 樣未買", systemImage: "cart")
                        .font(.headline)
                    Spacer()
                    Text("共 \(items.count) 樣")
                        .foregroundStyle(.secondary)
                }
                .padding(.horizontal)

                // 新增列
                HStack {
                    TextField("加入食材,例如:豆腐", text: $newItem)
                        .textFieldStyle(.roundedBorder)
                    Button {
                        let name = newItem.trimmingCharacters(in: .whitespaces)
                        guard !name.isEmpty else { return }
                        items.append(GroceryItem(name: name, isBought: false))
                        newItem = ""
                    } label: {
                        Image(systemName: "plus.circle.fill")
                            .font(.title2)
                    }
                    .disabled(newItem.trimmingCharacters(in: .whitespaces).isEmpty)
                }
                .padding(.horizontal)

                // 清單
                List {
                    ForEach($items) { $item in
                        Button {
                            item.isBought.toggle()
                        } label: {
                            HStack {
                                Image(systemName: item.isBought ? "checkmark.circle.fill" : "circle")
                                    .foregroundStyle(item.isBought ? .green : .secondary)
                                Text(item.name)
                                    .strikethrough(item.isBought)
                                    .foregroundStyle(item.isBought ? .secondary : .primary)
                                Spacer()
                            }
                        }
                        .buttonStyle(.plain)
                    }
                    .onDelete { indexSet in
                        items.remove(atOffsets: indexSet)
                    }
                }
                .listStyle(.plain)
            }
            .navigationTitle("今日買餸")
            .toolbar {
                EditButton()
            }
        }
    }
}

#Preview {
    ContentView()
}

02 家務分工打卡

按人篩選家務並打卡完成進度。

顯示/隱藏完整程式碼:02 家務分工打卡
import SwiftUI

/// 生活場景:家務分工打卡
/// 示範分段選擇、卡片式清單、完成狀態切換。
struct Chore: Identifiable {
    let id = UUID()
    var title: String
    var person: String
    var isDone: Bool
}

struct ContentView: View {
    @State private var filter = "全部"
    @State private var chores: [Chore] = [
        Chore(title: "洗碗", person: "小明", isDone: false),
        Chore(title: "拖地", person: "小華", isDone: true),
        Chore(title: "倒垃圾", person: "小明", isDone: false),
        Chore(title: "摺衫", person: "媽媽", isDone: false),
        Chore(title: "澆花", person: "小華", isDone: false)
    ]

    private let people = ["全部", "小明", "小華", "媽媽"]

    private var filtered: [Chore] {
        if filter == "全部" { return chores }
        return chores.filter { $0.person == filter }
    }

    private var doneCount: Int {
        chores.filter(\.isDone).count
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                // 進度
                VStack(alignment: .leading, spacing: 6) {
                    Text("今日進度 \(doneCount)/\(chores.count)")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    ProgressView(value: Double(doneCount), total: Double(max(chores.count, 1)))
                }
                .padding(.horizontal)

                Picker("篩選", selection: $filter) {
                    ForEach(people, id: \.self) { Text($0) }
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                ScrollView {
                    LazyVStack(spacing: 10) {
                        ForEach($chores) { $chore in
                            if filter == "全部" || chore.person == filter {
                                HStack {
                                    VStack(alignment: .leading, spacing: 4) {
                                        Text(chore.title)
                                            .font(.headline)
                                            .strikethrough(chore.isDone)
                                        Text(chore.person)
                                            .font(.caption)
                                            .foregroundStyle(.secondary)
                                    }
                                    Spacer()
                                    Button {
                                        chore.isDone.toggle()
                                    } label: {
                                        Image(systemName: chore.isDone ? "checkmark.circle.fill" : "circle")
                                            .font(.title2)
                                            .foregroundStyle(chore.isDone ? .green : .gray)
                                    }
                                }
                                .padding()
                                .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
                            }
                        }
                    }
                    .padding(.horizontal)
                }
            }
            .navigationTitle("家務打卡")
        }
    }
}

#Preview {
    ContentView()
}

03 日常開支記帳

記一筆開支、選分類、看今日合計。

顯示/隱藏完整程式碼:03 日常開支記帳
import SwiftUI

/// 生活場景:日常開支記帳
/// 示範數字輸入、分類、合計計算。
struct Expense: Identifiable {
    let id = UUID()
    var title: String
    var amount: Double
    var category: String
}

struct ContentView: View {
    @State private var expenses: [Expense] = [
        Expense(title: "早餐", amount: 28, category: "飲食"),
        Expense(title: "巴士", amount: 12.8, category: "交通"),
        Expense(title: "文具", amount: 35, category: "學習")
    ]
    @State private var title = ""
    @State private var amountText = ""
    @State private var category = "飲食"

    private let categories = ["飲食", "交通", "學習", "娛樂", "其他"]

    private var total: Double {
        expenses.reduce(0) { $0 + $1.amount }
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 12) {
                // 合計卡
                VStack(spacing: 4) {
                    Text("今日開支")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    Text("HK$\(total, specifier: "%.1f")")
                        .font(.largeTitle.bold())
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal)

                // 新增
                VStack(spacing: 8) {
                    TextField("項目名稱", text: $title)
                        .textFieldStyle(.roundedBorder)
                    TextField("金額", text: $amountText)
                        .textFieldStyle(.roundedBorder)
                        .keyboardType(.decimalPad)
                    Picker("分類", selection: $category) {
                        ForEach(categories, id: \.self) { Text($0) }
                    }
                    .pickerStyle(.menu)
                    Button("記一筆") {
                        guard let amount = Double(amountText), amount > 0,
                              !title.trimmingCharacters(in: .whitespaces).isEmpty else { return }
                        expenses.insert(
                            Expense(title: title.trimmingCharacters(in: .whitespaces),
                                    amount: amount,
                                    category: category),
                            at: 0
                        )
                        title = ""
                        amountText = ""
                    }
                    .buttonStyle(.borderedProminent)
                    .frame(maxWidth: .infinity)
                }
                .padding(.horizontal)

                List {
                    ForEach(expenses) { e in
                        HStack {
                            VStack(alignment: .leading) {
                                Text(e.title).font(.headline)
                                Text(e.category).font(.caption).foregroundStyle(.secondary)
                            }
                            Spacer()
                            Text("$\(e.amount, specifier: "%.1f")")
                                .fontWeight(.semibold)
                        }
                    }
                    .onDelete { expenses.remove(atOffsets: $0) }
                }
            }
            .navigationTitle("記帳本")
        }
    }
}

#Preview {
    ContentView()
}

04 服藥/喝水提醒

本地倒數+清單打卡(無需推播)。

顯示/隱藏完整程式碼:04 服藥/喝水提醒
import SwiftUI

/// 生活場景:服藥/喝水提醒(本地計時/清單,無需推播權限)
/// 示範 Timer、倒數、清單完成狀態。
struct ReminderItem: Identifiable {
    let id = UUID()
    var title: String
    var emoji: String
    var isDone: Bool
}

struct ContentView: View {
    @State private var items: [ReminderItem] = [
        ReminderItem(title: "早上吃藥", emoji: "💊", isDone: false),
        ReminderItem(title: "喝一杯水", emoji: "💧", isDone: false),
        ReminderItem(title: "午飯後吃藥", emoji: "💊", isDone: false),
        ReminderItem(title: "再喝一杯水", emoji: "💧", isDone: false)
    ]
    /// 簡易本地倒數(秒),模擬「下一次提醒」
    @State private var secondsLeft = 10
    @State private var isRunning = false

    private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                // 倒數卡
                VStack(spacing: 8) {
                    Text("下一次提醒倒數")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    Text(timeString(secondsLeft))
                        .font(.system(size: 48, weight: .bold, design: .rounded))
                        .monospacedDigit()
                    HStack {
                        Button(isRunning ? "暫停" : "開始") {
                            isRunning.toggle()
                        }
                        .buttonStyle(.borderedProminent)
                        Button("重設 10 秒") {
                            secondsLeft = 10
                            isRunning = false
                        }
                        .buttonStyle(.bordered)
                    }
                }
                .padding()
                .frame(maxWidth: .infinity)
                .background(.teal.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal)

                Text("今日清單(本地示範,無需網絡)")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.horizontal)

                List {
                    ForEach($items) { $item in
                        HStack {
                            Text(item.emoji)
                            Text(item.title)
                            Spacer()
                            Button {
                                item.isDone.toggle()
                            } label: {
                                Image(systemName: item.isDone ? "checkmark.seal.fill" : "seal")
                                    .foregroundStyle(item.isDone ? .green : .secondary)
                            }
                        }
                    }
                }
                .listStyle(.insetGrouped)
            }
            .navigationTitle("提醒助手")
            .onReceive(timer) { _ in
                guard isRunning, secondsLeft > 0 else {
                    if secondsLeft == 0 { isRunning = false }
                    return
                }
                secondsLeft -= 1
            }
        }
    }

    private func timeString(_ s: Int) -> String {
        String(format: "%02d:%02d", s / 60, s % 60)
    }
}

#Preview {
    ContentView()
}

05 運動打卡

用 Stepper 記分鐘、看本週長條。

顯示/隱藏完整程式碼:05 運動打卡
import SwiftUI

/// 生活場景:運動打卡
/// 示範 Stepper、本週進度、簡單統計。
struct WorkoutDay: Identifiable {
    let id = UUID()
    var weekday: String
    var minutes: Int
    var didExercise: Bool
}

struct ContentView: View {
    @State private var days: [WorkoutDay] = [
        WorkoutDay(weekday: "一", minutes: 30, didExercise: true),
        WorkoutDay(weekday: "二", minutes: 0, didExercise: false),
        WorkoutDay(weekday: "三", minutes: 20, didExercise: true),
        WorkoutDay(weekday: "四", minutes: 0, didExercise: false),
        WorkoutDay(weekday: "五", minutes: 45, didExercise: true),
        WorkoutDay(weekday: "六", minutes: 0, didExercise: false),
        WorkoutDay(weekday: "日", minutes: 0, didExercise: false)
    ]
    @State private var todayMinutes = 15

    private var totalMinutes: Int {
        days.reduce(0) { $0 + $1.minutes }
    }

    private var activeDays: Int {
        days.filter(\.didExercise).count
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    // 統計
                    HStack(spacing: 16) {
                        statCard(title: "本週分鐘", value: "\(totalMinutes)")
                        statCard(title: "打卡日數", value: "\(activeDays)/7")
                    }
                    .padding(.horizontal)

                    // 今日打卡
                    VStack(alignment: .leading, spacing: 12) {
                        Text("今日運動多久?")
                            .font(.headline)
                        Stepper(value: $todayMinutes, in: 0...120, step: 5) {
                            Text("\(todayMinutes) 分鐘")
                        }
                        Button("為「今日」打卡") {
                            // 示範:寫入「日」
                            if let i = days.firstIndex(where: { $0.weekday == "日" }) {
                                days[i].minutes = todayMinutes
                                days[i].didExercise = todayMinutes > 0
                            }
                        }
                        .buttonStyle(.borderedProminent)
                        .frame(maxWidth: .infinity)
                    }
                    .padding()
                    .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                    .padding(.horizontal)

                    // 一週概覽
                    VStack(alignment: .leading, spacing: 8) {
                        Text("本週概覽")
                            .font(.headline)
                            .padding(.horizontal)
                        HStack(alignment: .bottom, spacing: 8) {
                            ForEach(days) { day in
                                VStack {
                                    RoundedRectangle(cornerRadius: 6)
                                        .fill(day.didExercise ? Color.orange : Color.gray.opacity(0.25))
                                        .frame(height: CGFloat(max(day.minutes, 4)) * 1.2)
                                    Text(day.weekday)
                                        .font(.caption2)
                                }
                                .frame(maxWidth: .infinity)
                            }
                        }
                        .padding(.horizontal)
                        .frame(height: 140, alignment: .bottom)
                    }
                }
                .padding(.vertical)
            }
            .navigationTitle("運動打卡")
        }
    }

    private func statCard(title: String, value: String) -> some View {
        VStack(spacing: 4) {
            Text(value).font(.title.bold())
            Text(title).font(.caption).foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
        .padding()
        .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
    }
}

#Preview {
    ContentView()
}

06 週末行程規劃

六/日行程表,按時間排序。

顯示/隱藏完整程式碼:06 週末行程規劃
import SwiftUI

/// 生活場景:週末行程規劃
/// 示範 Tab/分段日子、時間排序清單。
struct PlanItem: Identifiable {
    let id = UUID()
    var time: String
    var activity: String
    var place: String
}

struct ContentView: View {
    @State private var day = "星期六"
    @State private var saturday: [PlanItem] = [
        PlanItem(time: "10:00", activity: "圖書館溫習", place: "區圖書館"),
        PlanItem(time: "13:00", activity: "午餐", place: "茶餐廳"),
        PlanItem(time: "15:30", activity: "踢足球", place: "球場")
    ]
    @State private var sunday: [PlanItem] = [
        PlanItem(time: "11:00", activity: "探望婆婆", place: "家"),
        PlanItem(time: "16:00", activity: "寫功課", place: "房間")
    ]
    @State private var newTime = "14:00"
    @State private var newActivity = ""
    @State private var newPlace = ""

    private var current: Binding<[PlanItem]> {
        Binding(
            get: { day == "星期六" ? saturday : sunday },
            set: { if day == "星期六" { saturday = $0 } else { sunday = $0 } }
        )
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 12) {
                Picker("日子", selection: $day) {
                    Text("星期六").tag("星期六")
                    Text("星期日").tag("星期日")
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                List {
                    ForEach(current.wrappedValue) { item in
                        HStack(alignment: .top) {
                            Text(item.time)
                                .font(.headline.monospacedDigit())
                                .frame(width: 60, alignment: .leading)
                            VStack(alignment: .leading, spacing: 2) {
                                Text(item.activity).font(.body.weight(.semibold))
                                Text(item.place).font(.caption).foregroundStyle(.secondary)
                            }
                        }
                    }
                    .onDelete { offsets in
                        current.wrappedValue.remove(atOffsets: offsets)
                    }
                }

                // 快速加入
                VStack(spacing: 8) {
                    TextField("活動,例如:睇電影", text: $newActivity)
                        .textFieldStyle(.roundedBorder)
                    TextField("地點", text: $newPlace)
                        .textFieldStyle(.roundedBorder)
                    HStack {
                        TextField("時間", text: $newTime)
                            .textFieldStyle(.roundedBorder)
                            .frame(width: 90)
                        Button("加入行程") {
                            let a = newActivity.trimmingCharacters(in: .whitespaces)
                            guard !a.isEmpty else { return }
                            current.wrappedValue.append(
                                PlanItem(time: newTime,
                                         activity: a,
                                         place: newPlace.isEmpty ? "待定" : newPlace)
                            )
                            current.wrappedValue.sort { $0.time < $1.time }
                            newActivity = ""
                            newPlace = ""
                        }
                        .buttonStyle(.borderedProminent)
                    }
                }
                .padding()
            }
            .navigationTitle("週末行程")
        }
    }
}

#Preview {
    ContentView()
}

07 今日穿搭/天氣

模擬氣溫給穿衣建議。

顯示/隱藏完整程式碼:07 今日穿搭/天氣
import SwiftUI

/// 生活場景:今日穿搭/天氣穿衣建議(模擬天氣)
/// 示範 Slider、條件建議、簡單動畫。
struct ContentView: View {
    /// 模擬氣溫(攝氏)
    @State private var temperature: Double = 28
    @State private var isRaining = false
    @State private var selectedStyle = "休閒"

    private let styles = ["休閒", "校服日", "運動", "出門見客"]

    private var advice: (emoji: String, tip: String) {
        if isRaining {
            return ("☂️", "記得帶傘,外套選防水或快乾物料。")
        }
        switch temperature {
        case ..<18:
            return ("🧥", "天氣偏涼,加件外套或冷衫會舒服啲。")
        case 18..<24:
            return ("👕", "溫度適中,薄長袖或恤衫就啱。")
        case 24..<30:
            return ("🩳", "有啲熱,透氣短袖、薄褲最啱。")
        default:
            return ("🧊", "好熱呀!淺色、透氣,記得防曬同補水。")
        }
    }

    private var outfitIdea: String {
        switch selectedStyle {
        case "校服日": return "校服+按天氣加減外套;雨天帶備換襪。"
        case "運動": return "運動服、跑鞋;熱天淺色、帶水壺。"
        case "出門見客": return "整齊裇衫/裙裝;涼就加薄外套。"
        default: return "T 恤+牛仔褲/短褲;按溫度加減層數。"
        }
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    // 模擬天氣
                    VStack(spacing: 12) {
                        Text("模擬今日天氣")
                            .font(.headline)
                        Text("\(Int(temperature))°C")
                            .font(.system(size: 56, weight: .bold, design: .rounded))
                        Slider(value: $temperature, in: 10...36, step: 1)
                        Toggle("落雨", isOn: $isRaining)
                    }
                    .padding()
                    .background(.cyan.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))

                    // 建議
                    VStack(spacing: 8) {
                        Text(advice.emoji)
                            .font(.system(size: 48))
                        Text(advice.tip)
                            .multilineTextAlignment(.center)
                            .font(.body)
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
                    .animation(.easeInOut, value: temperature)
                    .animation(.easeInOut, value: isRaining)

                    // 場合
                    VStack(alignment: .leading, spacing: 8) {
                        Text("今日場合")
                            .font(.headline)
                        Picker("場合", selection: $selectedStyle) {
                            ForEach(styles, id: \.self) { Text($0) }
                        }
                        .pickerStyle(.segmented)
                        Text(outfitIdea)
                            .foregroundStyle(.secondary)
                    }
                    .padding()
                    .background(.purple.opacity(0.08), in: RoundedRectangle(cornerRadius: 16))
                }
                .padding()
            }
            .navigationTitle("今日穿乜好")
        }
    }
}

#Preview {
    ContentView()
}

08 食譜步驟計時器

逐步煮飯倒數,完成跳下一步。

顯示/隱藏完整程式碼:08 食譜步驟計時器
import SwiftUI

/// 生活場景:食譜步驟計時器
/// 示範步驟清單、逐步完成、每步倒數。
struct RecipeStep: Identifiable {
    let id = UUID()
    var text: String
    var seconds: Int
    var isDone: Bool
}

struct ContentView: View {
    @State private var steps: [RecipeStep] = [
        RecipeStep(text: "洗米、加水浸泡", seconds: 8, isDone: false),
        RecipeStep(text: "大火煮滾", seconds: 10, isDone: false),
        RecipeStep(text: "轉小火燜煮", seconds: 12, isDone: false),
        RecipeStep(text: "靜待 3 分鐘再開蓋", seconds: 6, isDone: false)
    ]
    @State private var currentIndex = 0
    @State private var remaining = 8
    @State private var isTiming = false

    private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                if currentIndex < steps.count {
                    VStack(spacing: 8) {
                        Text("步驟 \(currentIndex + 1)/\(steps.count)")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                        Text(steps[currentIndex].text)
                            .font(.title3.weight(.semibold))
                            .multilineTextAlignment(.center)
                        Text(String(format: "%02d:%02d", remaining / 60, remaining % 60))
                            .font(.system(size: 44, weight: .bold, design: .rounded))
                            .monospacedDigit()
                        HStack {
                            Button(isTiming ? "暫停" : "開始此步") {
                                isTiming.toggle()
                            }
                            .buttonStyle(.borderedProminent)
                            Button("完成此步") {
                                finishCurrent()
                            }
                            .buttonStyle(.bordered)
                        }
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.pink.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                    .padding(.horizontal)
                } else {
                    ContentUnavailableView("煮好啦!", systemImage: "fork.knife", description: Text("所有步驟已完成,可以開飯。"))
                }

                List {
                    ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in
                        HStack {
                            Image(systemName: step.isDone ? "checkmark.circle.fill" : (index == currentIndex ? "flame.fill" : "circle"))
                                .foregroundStyle(step.isDone ? .green : (index == currentIndex ? .orange : .secondary))
                            VStack(alignment: .leading) {
                                Text(step.text)
                                Text("約 \(step.seconds) 秒(示範用)")
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
            }
            .navigationTitle("煮飯計時")
            .onReceive(tick) { _ in
                guard isTiming, remaining > 0 else {
                    if isTiming && remaining == 0 {
                        finishCurrent()
                    }
                    return
                }
                remaining -= 1
            }
            .onAppear {
                remaining = steps[0].seconds
            }
        }
    }

    private func finishCurrent() {
        guard currentIndex < steps.count else { return }
        steps[currentIndex].isDone = true
        isTiming = false
        currentIndex += 1
        if currentIndex < steps.count {
            remaining = steps[currentIndex].seconds
        }
    }
}

#Preview {
    ContentView()
}

09 水電抄表

記水/電讀數,計算今期用量。

顯示/隱藏完整程式碼:09 水電抄表
import SwiftUI

/// 生活場景:水電抄表/用量記錄
/// 示範分段類型、讀數差值、歷史列表。
struct MeterReading: Identifiable {
    let id = UUID()
    var kind: String   // 水 / 電
    var value: Double
    var note: String
    var dateLabel: String
}

struct ContentView: View {
    @State private var kind = "電"
    @State private var readings: [MeterReading] = [
        MeterReading(kind: "電", value: 10240, note: "上個月", dateLabel: "9 月"),
        MeterReading(kind: "電", value: 10385, note: "今個月", dateLabel: "10 月"),
        MeterReading(kind: "水", value: 320.5, note: "上個月", dateLabel: "9 月"),
        MeterReading(kind: "水", value: 335.0, note: "今個月", dateLabel: "10 月")
    ]
    @State private var newValue = ""
    @State private var note = "今個月"

    private var filtered: [MeterReading] {
        readings.filter { $0.kind == kind }.sorted { $0.value < $1.value }
    }

    private var usage: Double? {
        let list = filtered
        guard list.count >= 2 else { return nil }
        return list.last!.value - list[list.count - 2].value
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 14) {
                Picker("類型", selection: $kind) {
                    Text("電").tag("電")
                    Text("水").tag("水")
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                VStack(spacing: 4) {
                    Text(kind == "電" ? "今期用電(度)" : "今期用水(立方米)")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    if let usage {
                        Text("\(usage, specifier: "%.1f")")
                            .font(.largeTitle.bold())
                    } else {
                        Text("至少兩筆讀數才可計差")
                            .foregroundStyle(.secondary)
                    }
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(.indigo.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal)

                HStack {
                    TextField("新讀數", text: $newValue)
                        .textFieldStyle(.roundedBorder)
                        .keyboardType(.decimalPad)
                    TextField("備註", text: $note)
                        .textFieldStyle(.roundedBorder)
                    Button("記錄") {
                        guard let v = Double(newValue) else { return }
                        readings.append(
                            MeterReading(kind: kind, value: v, note: note.isEmpty ? "—" : note, dateLabel: "新")
                        )
                        newValue = ""
                    }
                    .buttonStyle(.borderedProminent)
                }
                .padding(.horizontal)

                List {
                    ForEach(filtered) { r in
                        HStack {
                            VStack(alignment: .leading) {
                                Text("\(r.value, specifier: "%.1f")")
                                    .font(.headline.monospacedDigit())
                                Text(r.note).font(.caption).foregroundStyle(.secondary)
                            }
                            Spacer()
                            Text(r.dateLabel)
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("抄表小幫手")
        }
    }
}

#Preview {
    ContentView()
}

10 生日/禮物願望

願望清單+優先級+已擁有。

顯示/隱藏完整程式碼:10 生日/禮物願望
import SwiftUI

/// 生活場景:生日/禮物願望清單
/// 示範優先級、標記已擁有、簡單排序。
struct WishItem: Identifiable {
    let id = UUID()
    var name: String
    var forPerson: String
    var priority: Int   // 1 高~3 低
    var isOwned: Bool
}

struct ContentView: View {
    @State private var wishes: [WishItem] = [
        WishItem(name: "繪本一套", forPerson: "細佬", priority: 1, isOwned: false),
        WishItem(name: "無線耳機", forPerson: "自己", priority: 2, isOwned: false),
        WishItem(name: "手作卡片", forPerson: "媽媽", priority: 1, isOwned: true),
        WishItem(name: "球鞋", forPerson: "自己", priority: 3, isOwned: false)
    ]
    @State private var name = ""
    @State private var person = "自己"
    @State private var priority = 2

    private let people = ["自己", "媽媽", "爸爸", "細佬", "朋友"]

    private var sorted: [WishItem] {
        wishes.sorted {
            if $0.isOwned != $1.isOwned { return !$0.isOwned && $1.isOwned }
            return $0.priority < $1.priority
        }
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 12) {
                // 新增
                VStack(spacing: 8) {
                    TextField("願望/禮物名稱", text: $name)
                        .textFieldStyle(.roundedBorder)
                    Picker("送給誰", selection: $person) {
                        ForEach(people, id: \.self) { Text($0) }
                    }
                    .pickerStyle(.menu)
                    Picker("優先", selection: $priority) {
                        Text("高").tag(1)
                        Text("中").tag(2)
                        Text("低").tag(3)
                    }
                    .pickerStyle(.segmented)
                    Button("加入願望") {
                        let n = name.trimmingCharacters(in: .whitespaces)
                        guard !n.isEmpty else { return }
                        wishes.append(WishItem(name: n, forPerson: person, priority: priority, isOwned: false))
                        name = ""
                    }
                    .buttonStyle(.borderedProminent)
                    .frame(maxWidth: .infinity)
                }
                .padding(.horizontal)

                List {
                    ForEach(sorted) { item in
                        HStack {
                            VStack(alignment: .leading, spacing: 4) {
                                Text(item.name)
                                    .font(.headline)
                                    .strikethrough(item.isOwned)
                                Text("給:\(item.forPerson) 優先:\(priorityLabel(item.priority))")
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                            Spacer()
                            Button {
                                if let i = wishes.firstIndex(where: { $0.id == item.id }) {
                                    wishes[i].isOwned.toggle()
                                }
                            } label: {
                                Image(systemName: item.isOwned ? "gift.fill" : "gift")
                                    .foregroundStyle(item.isOwned ? .pink : .secondary)
                            }
                        }
                    }
                    .onDelete { offsets in
                        let ids = offsets.map { sorted[$0].id }
                        wishes.removeAll { ids.contains($0.id) }
                    }
                }
            }
            .navigationTitle("願望清單")
        }
    }

    private func priorityLabel(_ p: Int) -> String {
        switch p {
        case 1: return "高"
        case 2: return "中"
        default: return "低"
        }
    }
}

#Preview {
    ContentView()
}
學校場景 Swift Playgrounds 小專案

學校/學習場景 01–10

結構與生活包相同,題材改為功課、溫習、實驗、小組與考試——方便直接對應中學課堂。

編號 標題 一句用途 會學到的概念
01 功課清單/截止日期 新增功課、記限期、標記已交 List、ForEach、@State、Picker、TextField、onDelete
02 課堂計時器/番茄鐘 專注時段倒數,累計完成輪數 Timer.publish、onReceive、分段 Picker
03 測驗分數記錄 輸入得分與滿分,看平均百分率 reduce、平均、格式化字串
04 小組分工板 按成員篩選任務並打卡進度 Picker、ProgressView、篩選
05 科學實驗步驟 逐步勾選程序與安全提示 步驟索引、清單狀態、ContentUnavailableView
06 閱讀打卡 用 Stepper 記頁數,看本週合計 Stepper、reduce、計算屬性
07 詞彙閃卡 正面/背面翻轉溫習詞彙 索引狀態、withAnimation、布林切換
08 座位/點名簡易表 示範資料標記出席或缺席 清單狀態、批量更新(僅假名示範)
09 專題進度條 里程碑打卡,ProgressView 看進度 ProgressView、計算屬性、里程碑
10 考試倒數日曆 本地日期計算距考試還有幾日 Date、Calendar、DateFormatter、排序

課堂小提示: 可先跑 01,再挑 02/10 講 Timer 與 Date;04/09 適合專題課講進度與分工。點名表示範請只用假名,勿輸入真實學生個人資料;分數記錄亦勿接上真實帳號系統。

以下是學校包每個示範的完整 ContentView.swift。同一資料夾的 MyApp.swift 與開頭示範相同結構,用該檔取代 App 入口即可。

01 功課清單/截止日期

新增功課、記限期、標記已交。

顯示/隱藏完整程式碼:01 功課清單/截止日期
import SwiftUI

/// 學校場景:功課清單/截止日期
/// 示範 List、日期顯示、標記完成、新增項目。
struct Homework: Identifiable {
    let id = UUID()
    var subject: String
    var title: String
    var dueLabel: String
    var isDone: Bool
}

struct ContentView: View {
    @State private var items: [Homework] = [
        Homework(subject: "中文", title: "作文:我的暑假", dueLabel: "週五", isDone: false),
        Homework(subject: "數學", title: "練習冊 P.42–45", dueLabel: "週三", isDone: true),
        Homework(subject: "英文", title: "Vocab Unit 3", dueLabel: "週四", isDone: false),
        Homework(subject: "科學", title: "實驗報告草稿", dueLabel: "下週一", isDone: false)
    ]
    @State private var subject = "中文"
    @State private var title = ""
    @State private var dueLabel = "週五"

    private let subjects = ["中文", "英文", "數學", "科學", "通識", "其他"]
    private let dues = ["今日", "明日", "週三", "週四", "週五", "下週一"]

    private var remaining: Int {
        items.filter { !$0.isDone }.count
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 12) {
                HStack {
                    Label("尚有 \(remaining) 項未交", systemImage: "book.closed")
                        .font(.headline)
                    Spacer()
                    Text("共 \(items.count) 項")
                        .foregroundStyle(.secondary)
                }
                .padding(.horizontal)

                VStack(spacing: 8) {
                    TextField("功課名稱,例如:默書温習", text: $title)
                        .textFieldStyle(.roundedBorder)
                    HStack {
                        Picker("科目", selection: $subject) {
                            ForEach(subjects, id: \.self) { Text($0) }
                        }
                        .pickerStyle(.menu)
                        Picker("限期", selection: $dueLabel) {
                            ForEach(dues, id: \.self) { Text($0) }
                        }
                        .pickerStyle(.menu)
                    }
                    Button("加入功課") {
                        let t = title.trimmingCharacters(in: .whitespaces)
                        guard !t.isEmpty else { return }
                        items.insert(
                            Homework(subject: subject, title: t, dueLabel: dueLabel, isDone: false),
                            at: 0
                        )
                        title = ""
                    }
                    .buttonStyle(.borderedProminent)
                    .frame(maxWidth: .infinity)
                }
                .padding(.horizontal)

                List {
                    ForEach($items) { $item in
                        Button {
                            item.isDone.toggle()
                        } label: {
                            HStack {
                                Image(systemName: item.isDone ? "checkmark.circle.fill" : "circle")
                                    .foregroundStyle(item.isDone ? .green : .secondary)
                                VStack(alignment: .leading, spacing: 2) {
                                    Text(item.title)
                                        .strikethrough(item.isDone)
                                        .foregroundStyle(item.isDone ? .secondary : .primary)
                                    Text("\(item.subject) 限期:\(item.dueLabel)")
                                        .font(.caption)
                                        .foregroundStyle(.secondary)
                                }
                                Spacer()
                            }
                        }
                        .buttonStyle(.plain)
                    }
                    .onDelete { items.remove(atOffsets: $0) }
                }
                .listStyle(.plain)
            }
            .navigationTitle("功課清單")
            .toolbar { EditButton() }
        }
    }
}

#Preview {
    ContentView()
}

02 課堂計時器/番茄鐘

專注時段倒數,累計完成輪數。

顯示/隱藏完整程式碼:02 課堂計時器/番茄鐘
import SwiftUI

/// 學校場景:課堂計時器/番茄鐘
/// 示範 Timer.publish、本地倒數、分段選擇專注時長。
struct ContentView: View {
    @State private var selectedMinutes = 25
    @State private var secondsLeft = 25 * 60
    @State private var isRunning = false
    @State private var completedRounds = 0

    private let presets = [15, 25, 40]
    private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                VStack(spacing: 8) {
                    Text("專注時段")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    Text(timeString(secondsLeft))
                        .font(.system(size: 56, weight: .bold, design: .rounded))
                        .monospacedDigit()
                    Text(isRunning ? "專心溫習中…" : "準備好就開始")
                        .font(.callout)
                        .foregroundStyle(.secondary)
                }
                .padding()
                .frame(maxWidth: .infinity)
                .background(.indigo.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal)

                Picker("時長", selection: $selectedMinutes) {
                    ForEach(presets, id: \.self) { m in
                        Text("\(m) 分鐘").tag(m)
                    }
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)
                .disabled(isRunning)
                .onChange(of: selectedMinutes) { _, newValue in
                    guard !isRunning else { return }
                    secondsLeft = newValue * 60
                }

                HStack(spacing: 12) {
                    Button(isRunning ? "暫停" : "開始") {
                        isRunning.toggle()
                    }
                    .buttonStyle(.borderedProminent)
                    Button("重設") {
                        isRunning = false
                        secondsLeft = selectedMinutes * 60
                    }
                    .buttonStyle(.bordered)
                }

                Label("已完成 \(completedRounds) 個番茄鐘(示範)", systemImage: "checkmark.seal")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)

                Spacer()
            }
            .padding(.top)
            .navigationTitle("番茄鐘")
            .onReceive(tick) { _ in
                guard isRunning, secondsLeft > 0 else {
                    if isRunning && secondsLeft == 0 {
                        isRunning = false
                        completedRounds += 1
                    }
                    return
                }
                secondsLeft -= 1
            }
        }
    }

    private func timeString(_ s: Int) -> String {
        String(format: "%02d:%02d", s / 60, s % 60)
    }
}

#Preview {
    ContentView()
}

03 測驗分數記錄

輸入得分與滿分,看平均百分率。

顯示/隱藏完整程式碼:03 測驗分數記錄
import SwiftUI

/// 學校場景:測驗分數記錄
/// 示範數字輸入、reduce 平均、清單刪除。
struct QuizScore: Identifiable {
    let id = UUID()
    var subject: String
    var title: String
    var score: Double
    var fullMark: Double
}

struct ContentView: View {
    @State private var records: [QuizScore] = [
        QuizScore(subject: "數學", title: "小測 1", score: 82, fullMark: 100),
        QuizScore(subject: "英文", title: "Vocab Quiz", score: 18, fullMark: 20),
        QuizScore(subject: "科學", title: "單元測驗", score: 76, fullMark: 100)
    ]
    @State private var subject = "數學"
    @State private var title = ""
    @State private var scoreText = ""
    @State private var fullMarkText = "100"

    private let subjects = ["中文", "英文", "數學", "科學", "其他"]

    private var averagePercent: Double {
        guard !records.isEmpty else { return 0 }
        let percents = records.map { $0.fullMark > 0 ? ($0.score / $0.fullMark) * 100 : 0 }
        return percents.reduce(0, +) / Double(percents.count)
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 12) {
                VStack(spacing: 4) {
                    Text("平均百分率")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    Text("\(averagePercent, specifier: "%.1f")%")
                        .font(.largeTitle.bold())
                    Text("共 \(records.count) 次紀錄")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(.mint.opacity(0.15), in: RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal)

                VStack(spacing: 8) {
                    TextField("測驗名稱", text: $title)
                        .textFieldStyle(.roundedBorder)
                    HStack {
                        Picker("科目", selection: $subject) {
                            ForEach(subjects, id: \.self) { Text($0) }
                        }
                        .pickerStyle(.menu)
                        TextField("得分", text: $scoreText)
                            .textFieldStyle(.roundedBorder)
                            .keyboardType(.decimalPad)
                        TextField("滿分", text: $fullMarkText)
                            .textFieldStyle(.roundedBorder)
                            .keyboardType(.decimalPad)
                    }
                    Button("加入分數") {
                        guard let score = Double(scoreText),
                              let full = Double(fullMarkText), full > 0,
                              !title.trimmingCharacters(in: .whitespaces).isEmpty else { return }
                        records.insert(
                            QuizScore(
                                subject: subject,
                                title: title.trimmingCharacters(in: .whitespaces),
                                score: score,
                                fullMark: full
                            ),
                            at: 0
                        )
                        title = ""
                        scoreText = ""
                    }
                    .buttonStyle(.borderedProminent)
                    .frame(maxWidth: .infinity)
                }
                .padding(.horizontal)

                List {
                    ForEach(records) { r in
                        HStack {
                            VStack(alignment: .leading, spacing: 2) {
                                Text(r.title).font(.headline)
                                Text(r.subject).font(.caption).foregroundStyle(.secondary)
                            }
                            Spacer()
                            Text("\(r.score, specifier: "%.0f") / \(r.fullMark, specifier: "%.0f")")
                                .fontWeight(.semibold)
                        }
                    }
                    .onDelete { records.remove(atOffsets: $0) }
                }
            }
            .navigationTitle("測驗分數")
        }
    }
}

#Preview {
    ContentView()
}

04 小組分工板

按成員篩選任務並打卡進度。

顯示/隱藏完整程式碼:04 小組分工板
import SwiftUI

/// 學校場景:小組分工板
/// 示範 Picker 篩選、ProgressView、成員任務打卡。
struct GroupTask: Identifiable {
    let id = UUID()
    var title: String
    var member: String
    var role: String
    var isDone: Bool
}

struct ContentView: View {
    @State private var filter = "全部"
    @State private var tasks: [GroupTask] = [
        GroupTask(title: "收集資料", member: "阿希", role: "研究員", isDone: true),
        GroupTask(title: "做簡報投影片", member: "小晴", role: "設計師", isDone: false),
        GroupTask(title: "撰寫講稿", member: "Jason", role: "講者", isDone: false),
        GroupTask(title: "準備示範", member: "阿希", role: "研究員", isDone: false),
        GroupTask(title: "檢查時間控制", member: "小晴", role: "設計師", isDone: false)
    ]

    private let members = ["全部", "阿希", "小晴", "Jason"]

    private var doneCount: Int {
        tasks.filter(\.isDone).count
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                VStack(alignment: .leading, spacing: 6) {
                    Text("小組進度 \(doneCount)/\(tasks.count)")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                    ProgressView(value: Double(doneCount), total: Double(max(tasks.count, 1)))
                }
                .padding(.horizontal)

                Picker("成員", selection: $filter) {
                    ForEach(members, id: \.self) { Text($0) }
                }
                .pickerStyle(.segmented)
                .padding(.horizontal)

                ScrollView {
                    LazyVStack(spacing: 10) {
                        ForEach($tasks) { $task in
                            if filter == "全部" || task.member == filter {
                                HStack {
                                    VStack(alignment: .leading, spacing: 4) {
                                        Text(task.title)
                                            .font(.headline)
                                            .strikethrough(task.isDone)
                                        Text("\(task.member) · \(task.role)")
                                            .font(.caption)
                                            .foregroundStyle(.secondary)
                                    }
                                    Spacer()
                                    Button {
                                        task.isDone.toggle()
                                    } label: {
                                        Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
                                            .font(.title2)
                                            .foregroundStyle(task.isDone ? .green : .gray)
                                    }
                                }
                                .padding()
                                .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
                            }
                        }
                    }
                    .padding(.horizontal)
                }
            }
            .navigationTitle("小組分工")
        }
    }
}

#Preview {
    ContentView()
}

05 科學實驗步驟

逐步勾選程序與安全提示。

顯示/隱藏完整程式碼:05 科學實驗步驟
import SwiftUI

/// 學校場景:科學實驗步驟
/// 示範步驟清單、逐步勾選、目前步驟提示。
struct LabStep: Identifiable {
    let id = UUID()
    var text: String
    var note: String
    var isDone: Bool
}

struct ContentView: View {
    @State private var steps: [LabStep] = [
        LabStep(text: "戴上護目鏡與實驗袍", note: "安全第一", isDone: false),
        LabStep(text: "量取 50 ml 清水", note: "用量筒", isDone: false),
        LabStep(text: "加入試劑 A,輕輕搖匀", note: "勿劇烈搖動", isDone: false),
        LabStep(text: "觀察並記錄顏色變化", note: "寫在實驗簿", isDone: false),
        LabStep(text: "清洗器材、收拾桌面", note: "完結", isDone: false)
    ]

    private var currentIndex: Int {
        steps.firstIndex(where: { !$0.isDone }) ?? steps.count
    }

    private var allDone: Bool {
        steps.allSatisfy(\.isDone)
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                if allDone {
                    ContentUnavailableView(
                        "實驗完成!",
                        systemImage: "flask.fill",
                        description: Text("記得核對紀錄,再清洗器材。")
                    )
                    .frame(height: 140)
                } else {
                    VStack(spacing: 6) {
                        Text("目前步驟 \(currentIndex + 1)/\(steps.count)")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                        Text(steps[currentIndex].text)
                            .font(.title3.weight(.semibold))
                            .multilineTextAlignment(.center)
                        Text(steps[currentIndex].note)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.cyan.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                    .padding(.horizontal)
                }

                List {
                    ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in
                        Button {
                            steps[index].isDone.toggle()
                        } label: {
                            HStack(alignment: .top) {
                                Image(systemName: step.isDone ? "checkmark.circle.fill" : (index == currentIndex ? "arrow.right.circle.fill" : "circle"))
                                    .foregroundStyle(step.isDone ? .green : (index == currentIndex ? .cyan : .secondary))
                                VStack(alignment: .leading, spacing: 2) {
                                    Text(step.text)
                                        .strikethrough(step.isDone)
                                        .foregroundStyle(step.isDone ? .secondary : .primary)
                                    Text(step.note)
                                        .font(.caption)
                                        .foregroundStyle(.secondary)
                                }
                                Spacer()
                            }
                        }
                        .buttonStyle(.plain)
                    }
                }

                Button("全部重設") {
                    for i in steps.indices { steps[i].isDone = false }
                }
                .buttonStyle(.bordered)
                .padding(.bottom, 8)
            }
            .navigationTitle("實驗步驟")
        }
    }
}

#Preview {
    ContentView()
}

06 閱讀打卡

用 Stepper 記頁數,看本週合計。

顯示/隱藏完整程式碼:06 閱讀打卡
import SwiftUI

/// 學校場景:閱讀打卡
/// 示範 Stepper 記頁數、本週統計、清單新增。
struct ReadingEntry: Identifiable {
    let id = UUID()
    var book: String
    var pages: Int
    var day: String
}

struct ContentView: View {
    @State private var entries: [ReadingEntry] = [
        ReadingEntry(book: "小王子", pages: 12, day: "一"),
        ReadingEntry(book: "小王子", pages: 18, day: "三"),
        ReadingEntry(book: "科學月刊", pages: 8, day: "五")
    ]
    @State private var book = ""
    @State private var pages = 10
    @State private var day = "日"

    private let days = ["一", "二", "三", "四", "五", "六", "日"]

    private var totalPages: Int {
        entries.reduce(0) { $0 + $1.pages }
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 16) {
                    HStack(spacing: 16) {
                        statCard(title: "本週頁數", value: "\(totalPages)")
                        statCard(title: "打卡次數", value: "\(entries.count)")
                    }
                    .padding(.horizontal)

                    VStack(alignment: .leading, spacing: 10) {
                        Text("今日閱讀")
                            .font(.headline)
                        TextField("書名/文章", text: $book)
                            .textFieldStyle(.roundedBorder)
                        Stepper(value: $pages, in: 1...100, step: 1) {
                            Text("讀了 \(pages) 頁")
                        }
                        Picker("星期", selection: $day) {
                            ForEach(days, id: \.self) { Text("週\($0)") }
                        }
                        .pickerStyle(.segmented)
                        Button("打卡") {
                            let b = book.trimmingCharacters(in: .whitespaces)
                            guard !b.isEmpty else { return }
                            entries.insert(ReadingEntry(book: b, pages: pages, day: day), at: 0)
                            book = ""
                        }
                        .buttonStyle(.borderedProminent)
                        .frame(maxWidth: .infinity)
                    }
                    .padding()
                    .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                    .padding(.horizontal)

                    VStack(alignment: .leading, spacing: 8) {
                        Text("本週紀錄")
                            .font(.headline)
                            .padding(.horizontal)
                        ForEach(entries) { e in
                            HStack {
                                VStack(alignment: .leading) {
                                    Text(e.book).font(.headline)
                                    Text("週\(e.day)").font(.caption).foregroundStyle(.secondary)
                                }
                                Spacer()
                                Text("\(e.pages) 頁")
                                    .fontWeight(.semibold)
                            }
                            .padding()
                            .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
                            .padding(.horizontal)
                        }
                    }
                }
                .padding(.vertical)
            }
            .navigationTitle("閱讀打卡")
        }
    }

    private func statCard(title: String, value: String) -> some View {
        VStack(spacing: 4) {
            Text(value).font(.title.bold())
            Text(title).font(.caption).foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
        .padding()
        .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
    }
}

#Preview {
    ContentView()
}

07 詞彙閃卡

正面/背面翻轉溫習詞彙。

顯示/隱藏完整程式碼:07 詞彙閃卡
import SwiftUI

/// 學校場景:詞彙閃卡
/// 示範前後面翻轉、索引切換、掌握標記。
struct FlashCard: Identifiable {
    let id = UUID()
    var front: String
    var back: String
    var mastered: Bool
}

struct ContentView: View {
    @State private var cards: [FlashCard] = [
        FlashCard(front: "photosynthesis", back: "光合作用", mastered: false),
        FlashCard(front: "hypothesis", back: "假說/假設", mastered: false),
        FlashCard(front: "equation", back: "方程式", mastered: true),
        FlashCard(front: "variable", back: "變數", mastered: false),
        FlashCard(front: "circumference", back: "圓周", mastered: false)
    ]
    @State private var index = 0
    @State private var showBack = false

    private var current: FlashCard? {
        guard cards.indices.contains(index) else { return nil }
        return cards[index]
    }

    private var masteredCount: Int {
        cards.filter(\.mastered).count
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
                Text("已掌握 \(masteredCount)/\(cards.count)")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)

                if let card = current {
                    Button {
                        withAnimation(.easeInOut(duration: 0.25)) {
                            showBack.toggle()
                        }
                    } label: {
                        VStack(spacing: 12) {
                            Text(showBack ? "背面(點一下翻回)" : "正面(點一下看意思)")
                                .font(.caption)
                                .foregroundStyle(.secondary)
                            Text(showBack ? card.back : card.front)
                                .font(.title.bold())
                                .multilineTextAlignment(.center)
                                .foregroundStyle(.primary)
                                .frame(maxWidth: .infinity, minHeight: 160)
                        }
                        .padding()
                        .background(
                            (showBack ? Color.purple : Color.blue).opacity(0.12),
                            in: RoundedRectangle(cornerRadius: 20)
                        )
                    }
                    .buttonStyle(.plain)
                    .padding(.horizontal)

                    HStack(spacing: 12) {
                        Button("上一張") {
                            showBack = false
                            index = max(0, index - 1)
                        }
                        .buttonStyle(.bordered)
                        .disabled(index == 0)

                        Button(card.mastered ? "取消掌握" : "標記掌握") {
                            cards[index].mastered.toggle()
                        }
                        .buttonStyle(.borderedProminent)

                        Button("下一張") {
                            showBack = false
                            index = min(cards.count - 1, index + 1)
                        }
                        .buttonStyle(.bordered)
                        .disabled(index >= cards.count - 1)
                    }

                    Text("第 \(index + 1) / \(cards.count) 張")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                Spacer()
            }
            .padding(.top)
            .navigationTitle("詞彙閃卡")
        }
    }
}

#Preview {
    ContentView()
}

08 座位/點名簡易表

示範資料標記出席或缺席。

顯示/隱藏完整程式碼:08 座位/點名簡易表
import SwiftUI

/// 學校場景:座位/點名簡易表
/// 示範示範資料點名、Toggle/按鈕切換出席、統計。
struct StudentSeat: Identifiable {
    let id = UUID()
    var seat: String
    var name: String
    var isPresent: Bool
}

struct ContentView: View {
    @State private var students: [StudentSeat] = [
        StudentSeat(seat: "A1", name: "陳同學", isPresent: true),
        StudentSeat(seat: "A2", name: "李同學", isPresent: true),
        StudentSeat(seat: "A3", name: "黃同學", isPresent: false),
        StudentSeat(seat: "B1", name: "張同學", isPresent: true),
        StudentSeat(seat: "B2", name: "林同學", isPresent: true),
        StudentSeat(seat: "B3", name: "周同學", isPresent: false),
        StudentSeat(seat: "C1", name: "吳同學", isPresent: true),
        StudentSeat(seat: "C2", name: "鄭同學", isPresent: true)
    ]

    private var presentCount: Int {
        students.filter(\.isPresent).count
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 12) {
                HStack {
                    Label("出席 \(presentCount)/\(students.count)", systemImage: "person.3")
                        .font(.headline)
                    Spacer()
                    Text("缺席 \(students.count - presentCount)")
                        .foregroundStyle(.secondary)
                }
                .padding(.horizontal)

                Text("以下為課堂示範資料,請勿輸入真實學生個人資料。")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.horizontal)

                List {
                    ForEach($students) { $s in
                        HStack {
                            Text(s.seat)
                                .font(.headline.monospaced())
                                .frame(width: 40, alignment: .leading)
                            Text(s.name)
                            Spacer()
                            Button {
                                s.isPresent.toggle()
                            } label: {
                                Text(s.isPresent ? "出席" : "缺席")
                                    .font(.subheadline.weight(.semibold))
                                    .padding(.horizontal, 10)
                                    .padding(.vertical, 4)
                                    .background(s.isPresent ? Color.green.opacity(0.2) : Color.red.opacity(0.15),
                                                in: Capsule())
                                    .foregroundStyle(s.isPresent ? .green : .red)
                            }
                            .buttonStyle(.plain)
                        }
                    }
                }
                .listStyle(.insetGrouped)

                HStack {
                    Button("全部出席") {
                        for i in students.indices { students[i].isPresent = true }
                    }
                    .buttonStyle(.bordered)
                    Button("重設示範") {
                        // 恢復初始:偶數缺席示範
                        for i in students.indices {
                            students[i].isPresent = ![2, 5].contains(i)
                        }
                    }
                    .buttonStyle(.borderedProminent)
                }
                .padding(.bottom, 8)
            }
            .navigationTitle("點名表")
        }
    }
}

#Preview {
    ContentView()
}

09 專題進度條

里程碑打卡,ProgressView 看進度。

顯示/隱藏完整程式碼:09 專題進度條
import SwiftUI

/// 學校場景:專題進度條
/// 示範里程碑清單、ProgressView、完成比例。
struct Milestone: Identifiable {
    let id = UUID()
    var title: String
    var detail: String
    var isDone: Bool
}

struct ContentView: View {
    @State private var projectTitle = "STEAM 專題:智慧校園"
    @State private var milestones: [Milestone] = [
        Milestone(title: "訂題目與分組", detail: "選定題目、分工", isDone: true),
        Milestone(title: "資料搜集", detail: "訪談/問卷/文獻", isDone: true),
        Milestone(title: "原型設計", detail: "草圖或簡易模型", isDone: false),
        Milestone(title: "製作與測試", detail: "完成作品並調校", isDone: false),
        Milestone(title: "簡報綵排", detail: "講稿+時間控制", isDone: false),
        Milestone(title: "正式發表", detail: "課堂/開放日", isDone: false)
    ]

    private var doneCount: Int {
        milestones.filter(\.isDone).count
    }

    private var progress: Double {
        Double(doneCount) / Double(max(milestones.count, 1))
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                VStack(alignment: .leading, spacing: 8) {
                    Text(projectTitle)
                        .font(.headline)
                    ProgressView(value: progress) {
                        Text("完成 \(doneCount)/\(milestones.count)")
                    } currentValueLabel: {
                        Text("\(Int(progress * 100))%")
                    }
                }
                .padding()
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal)

                List {
                    ForEach($milestones) { $m in
                        Button {
                            m.isDone.toggle()
                        } label: {
                            HStack(alignment: .top) {
                                Image(systemName: m.isDone ? "checkmark.circle.fill" : "circle")
                                    .foregroundStyle(m.isDone ? .green : .secondary)
                                    .padding(.top, 2)
                                VStack(alignment: .leading, spacing: 2) {
                                    Text(m.title)
                                        .font(.headline)
                                        .strikethrough(m.isDone)
                                        .foregroundStyle(m.isDone ? .secondary : .primary)
                                    Text(m.detail)
                                        .font(.caption)
                                        .foregroundStyle(.secondary)
                                }
                                Spacer()
                            }
                        }
                        .buttonStyle(.plain)
                    }
                }
            }
            .navigationTitle("專題進度")
        }
    }
}

#Preview {
    ContentView()
}

10 考試倒數日曆

本地日期計算距考試還有幾日。

顯示/隱藏完整程式碼:10 考試倒數日曆
import SwiftUI

/// 學校場景:考試倒數日曆
/// 示範本地 Date 計算天數(無需推播)、清單多科倒數。
struct ExamItem: Identifiable {
    let id = UUID()
    var subject: String
    var examDate: Date
}

struct ContentView: View {
    @State private var exams: [ExamItem] = [
        ExamItem(subject: "中文", examDate: Calendar.current.date(byAdding: .day, value: 12, to: Date())!),
        ExamItem(subject: "英文", examDate: Calendar.current.date(byAdding: .day, value: 14, to: Date())!),
        ExamItem(subject: "數學", examDate: Calendar.current.date(byAdding: .day, value: 7, to: Date())!),
        ExamItem(subject: "科學", examDate: Calendar.current.date(byAdding: .day, value: 21, to: Date())!)
    ]
    @State private var subject = "通識"
    @State private var daysAhead = 10

    private let subjects = ["中文", "英文", "數學", "科學", "通識", "其他"]

    private var sorted: [ExamItem] {
        exams.sorted { $0.examDate < $1.examDate }
    }

    private var nearest: ExamItem? {
        sorted.first
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                if let n = nearest {
                    VStack(spacing: 6) {
                        Text("最近考試")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                        Text(n.subject)
                            .font(.title2.bold())
                        Text(daysLabel(until: n.examDate))
                            .font(.system(size: 36, weight: .bold, design: .rounded))
                            .foregroundStyle(.orange)
                        Text(dateString(n.examDate))
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 16))
                    .padding(.horizontal)
                }

                VStack(spacing: 8) {
                    Picker("科目", selection: $subject) {
                        ForEach(subjects, id: \.self) { Text($0) }
                    }
                    .pickerStyle(.menu)
                    Stepper(value: $daysAhead, in: 1...90) {
                        Text("距今 \(daysAhead) 日後考試")
                    }
                    Button("加入考試日") {
                        let date = Calendar.current.date(byAdding: .day, value: daysAhead, to: Date()) ?? Date()
                        exams.append(ExamItem(subject: subject, examDate: date))
                    }
                    .buttonStyle(.borderedProminent)
                    .frame(maxWidth: .infinity)
                }
                .padding(.horizontal)

                List {
                    ForEach(sorted) { exam in
                        HStack {
                            VStack(alignment: .leading, spacing: 2) {
                                Text(exam.subject).font(.headline)
                                Text(dateString(exam.examDate))
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                            Spacer()
                            Text(daysLabel(until: exam.examDate))
                                .fontWeight(.semibold)
                                .foregroundStyle(.orange)
                        }
                    }
                    .onDelete { offsets in
                        let ids = offsets.map { sorted[$0].id }
                        exams.removeAll { ids.contains($0.id) }
                    }
                }

                Text("只用本地日期計算,不會發送推播通知。")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                    .padding(.bottom, 4)
            }
            .navigationTitle("考試倒數")
        }
    }

    private func daysUntil(_ date: Date) -> Int {
        let cal = Calendar.current
        let start = cal.startOfDay(for: Date())
        let end = cal.startOfDay(for: date)
        return cal.dateComponents([.day], from: start, to: end).day ?? 0
    }

    private func daysLabel(until date: Date) -> String {
        let d = daysUntil(date)
        if d < 0 { return "已過 \(abs(d)) 日" }
        if d == 0 { return "就是今天" }
        return "還有 \(d) 日"
    }

    private func dateString(_ date: Date) -> String {
        let f = DateFormatter()
        f.locale = Locale(identifier: "zh_HK")
        f.dateStyle = .medium
        f.timeStyle = .none
        return f.string(from: date)
    }
}

#Preview {
    ContentView()
}

兩包怎麼搭配用?

Pack 主題焦點 建議切入
生活場景 家務、開支、提醒、煮食 建立「App 可以幫日常」的直覺
學校/學習 功課、溫習、實驗、考試 直接對應校本專題、溫習習慣、實驗安全

橫向共通能力包括:@State 與 Identifiable 模型、List/ForEach、表單控制(Picker、Toggle、Stepper、TextField)、本地 Timer 與 Date、用計算屬性做合計/篩選/排序,以及溫暖繁中 UI 文案。

不必追求一學期做滿 20 個。選 3~5 個「能當堂跑完、能帶回家改一版」的,往往比掃過全部更有學習效果。

課堂建議與延伸

  1. 投影示範 → 學生改一處:例如只改清單預設項目、只改番茄鐘分鐘數。
  2. 對照小百科:若學生忘了元件名字,可搭配站上的 SwiftUI 常用物件速查,先認零件再組場景。
  3. 安全與隱私:所有範例皆本地示範;提醒、點名、分數、藥/水提醒都不要接真實敏感資料或推播權限。
  4. 延伸方向:學生可把生活包改成「自己家的家務板」,或把學校包改成「本班專題里程碑」;進階再討論儲存(例如 AppStorage)仍可留待下一階段。

可選的下一包題材(例如簡易商業場景:排隊預約、庫存盤點)若之後有需要,可以同一結構再擴一包;現階段先把生活與學校兩包用熟即可。

結語

程式不必一開始就很大。一個會剔選的購物清單、一個會倒數的番茄鐘,已經足夠讓人明白:狀態一變,畫面就跟住變——這正是 SwiftUI 最核心的手感。

下載兩包範例,先在 Swift Playgrounds 開 App、貼上、按 Run。今天選一個最貼近你(或你班學生)的場景跑起來;跑得動,再談優化與美化,也不遲。