學 SwiftUI,最怕一開始就面對抽象名詞:List、@State、Timer……名字都對,卻不知道「用在哪裡」。
換一個問法會輕鬆很多:如果 App 是為日常生活或課堂而做,你會先做哪一個?
這篇介紹兩套給 STEAM/編程課用的 Swift Playgrounds 小專案——生活場景 10 個、學校/學習場景 10 個。每個資料夾都是可獨立貼上執行的 App:介面用溫暖書面語繁中,只靠本地 @State,無需網絡、無需 API Key。適合課堂投影,也適合學生跟住改成自己的版本。
為甚麼用「場景」學,而不是只背元件?
因為場景會逼你把零件組起來。買餸清單自然會碰到 List 與刪除;番茄鐘自然會碰到 Timer;考試倒數自然會碰到 Date。學生先看到「畫面會動、解決一件小事」,再回頭對元件名字,記憶會穩得多。
兩包是姊妹關係:結構相同、開啟方式相同,題材錯開——一邊是家裡與日常,一邊是功課與校園。課堂可以按主題輪流用,不必一次做完 20 個。
如何在 Swift Playgrounds 開啟
- 在 iPad 或 Mac 開啟 Swift Playgrounds。
- 建立新專案:選 App(不是空白 Playground 頁)。
- 用範例資料夾裡的
MyApp.swift取代專案入口;用ContentView.swift取代介面檔。 - 按 Run ▶。若提示缺少
import SwiftUI,多半是專案類型不是 App。
每個範例都是 self-contained,大致長這樣(以生活包 01 為例):
顯示/隱藏完整程式碼:MyApp.swift
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
完整程式與兩包 zip 已放在 Google Drive「程式大師」資料夾,可下載後按編號開啟對應子資料夾。

生活場景 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()
}

學校/學習場景 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 個「能當堂跑完、能帶回家改一版」的,往往比掃過全部更有學習效果。
課堂建議與延伸
- 投影示範 → 學生改一處:例如只改清單預設項目、只改番茄鐘分鐘數。
- 對照小百科:若學生忘了元件名字,可搭配站上的 SwiftUI 常用物件速查,先認零件再組場景。
- 安全與隱私:所有範例皆本地示範;提醒、點名、分數、藥/水提醒都不要接真實敏感資料或推播權限。
- 延伸方向:學生可把生活包改成「自己家的家務板」,或把學校包改成「本班專題里程碑」;進階再討論儲存(例如 AppStorage)仍可留待下一階段。
可選的下一包題材(例如簡易商業場景:排隊預約、庫存盤點)若之後有需要,可以同一結構再擴一包;現階段先把生活與學校兩包用熟即可。
結語
程式不必一開始就很大。一個會剔選的購物清單、一個會倒數的番茄鐘,已經足夠讓人明白:狀態一變,畫面就跟住變——這正是 SwiftUI 最核心的手感。
下載兩包範例,先在 Swift Playgrounds 開 App、貼上、按 Run。今天選一個最貼近你(或你班學生)的場景跑起來;跑得動,再談優化與美化,也不遲。
