用 Xcode 開發 iPhone App
從零開始的完整學習教材
以 To-Do-List-Demo(待辦事項 App)為例
本教材專為完全沒有寫過程式的初學者撰寫。
我們會逐行解釋專案中的每一段程式碼,
並完整說明一個 iOS App 從啟動到操作的程式流程。
技術主題:Swift \(\cdot\) SwiftUI \(\cdot\) SwiftData \(\cdot\) Xcode
適用 Xcode 26 / iOS 26 / Swift 5
文件以 LaTeX(XeLaTeX)排版產生
歡迎!如果你從來沒有寫過任何程式,這份教材正是為你準備的。我們會以一個真實、可以實際執行的小型 iPhone App ——待辦事項清單(To-Do List)—— 作為範例,帶你認識 iOS App 開發的完整樣貌。
這個 App 雖然小,但「五臟俱全」:它有資料的儲存、畫面的呈現、使用者的操作(新增、檢視、編輯、刪除),以及畫面之間的切換。換句話說,只要徹底搞懂這個專案,你就掌握了開發大多數 App 都會用到的核心觀念。
你會學到什麼
iOS 開發到底需要哪些工具與語言(Xcode、Swift、SwiftUI、SwiftData)。
一個 Xcode 專案裡的每個檔案、每個資料夾各自負責什麼。
Swift 程式語言的基礎語法(只挑本專案用得到的,務實學習)。
專案中每一行程式碼的意義。
App 從「使用者點下圖示」到「畫面顯示、操作、資料儲存」的完整流程。
如何在 Xcode 中執行、預覽、除錯你的 App。
建議的閱讀順序
本教材的章節是循序漸進的,強烈建議你從頭往後讀:
先讀第 2 節,建立「iOS 開發是什麼」的整體概念。
接著第 3 節,認識專案的檔案結構。
第 4 節補齊 Swift 語法基礎(看不懂程式時可隨時回來查)。
第 11 節把所有檔案串起來,理解完整程式流程。
看到不懂的英文名詞先別緊張。本教材每出現一個新名詞,都會用「名詞解釋」方塊說明。書末第 14 節還有一份完整的名詞對照表可供查閱。
第一章 iOS 開發的基礎概念
在打開任何程式碼之前,我們先用白話文把「開發一個 iPhone App 需要哪些東西」講清楚。
什麼是 App?什麼是 iOS?
App(應用程式)就是你手機上一個一個的圖示:相機、訊息、地圖等等。iOS 則是 iPhone 的「作業系統」——它是手機的大管家,負責管理畫面、觸控、記憶體、儲存空間,並決定哪個 App 可以執行、可以使用哪些資源。
我們寫的 App,最終會被安裝到 iOS 上,由 iOS 負責把它顯示給使用者、把使用者的觸控傳給 App。
開發 iPhone App 需要的四樣東西
Xcode:Apple 官方提供的「開發工具」(IDE,整合開發環境)。你在 Xcode 裡面寫程式、設計畫面、執行測試、把 App 跑在模擬器或真實手機上。可以把它想成是「寫 App 專用的超級記事本+工廠」。
Swift:開發 iOS App 使用的「程式語言」。程式語言是人類與電腦溝通的文字。Swift 是 Apple 在 2014 年推出的現代語言,語法簡潔、相對好讀。本專案所有 .swift 檔案裡寫的都是 Swift。
SwiftUI:一套用來「描述畫面長什麼樣子」的工具(稱為框架,framework)。你只要用程式碼宣告「我想要一個清單,裡面有文字和按鈕」,SwiftUI 就會幫你把它畫出來。這種寫法叫做宣告式 UI。
SwiftData:Apple 的「資料儲存」框架。App 關掉後資料還要留著(例如你新增的待辦事項),就需要把資料存到手機裡。SwiftData 讓你用很少的程式碼就能做到「永久儲存」。
宣告式 UI:一個重要的思維轉變
傳統寫畫面的方式是「一步一步下指令」:先建立一個按鈕、再設定它的位置、再設定顏色、使用者點了之後手動更新畫面……這叫命令式。
SwiftUI 採用宣告式:你只描述「在某個狀態下,畫面應該長成什麼樣子」,當資料改變時,SwiftUI 會自動重新繪製需要更新的部分。
用做菜比喻:命令式像是「拿鍋、開火、倒油、放蛋、翻面……」一步步指揮;宣告式則像是直接說「我要一份蛋包飯」,廚房自己會處理細節。SwiftUI 就是那個聰明的廚房。
本專案使用的技術版本
本專案是用較新的 Xcode 建立的,採用了現代化的寫法:
使用 SwiftUI 而非舊的 Storyboard 來建立畫面。
使用 SwiftData(
@Model)而非舊的 Core Data 來儲存資料。使用 檔案系統同步群組(File System Synchronized Group),代表只要把
.swift檔放進專案資料夾,Xcode 就會自動納入編譯,不需手動加入。
第二章 專案結構總覽
當你用 Xcode 建立一個新專案,它會自動產生一整組檔案。我們先鳥瞰整個 To-Do-List-Demo 專案的結構,再逐一深入。
資料夾與檔案地圖
To-Do-List-Demo/ <- 專案根目錄
|-- To-Do-List-Demo.xcodeproj <- 專案設定檔(Xcode 用)
|-- To-Do-List-Demo/ <- App 的主要程式碼
| |-- To_Do_List_DemoApp.swift <- 程式進入點(App 從這裡開始)
| |-- Item.swift <- 資料模型(一筆待辦事項長什麼樣)
| |-- ContentView.swift <- 主畫面(待辦清單)
| |-- AddItemView.swift <- 新增待辦的畫面
| |-- ItemDetailView.swift <- 檢視 / 編輯待辦的畫面
| `-- Assets.xcassets/ <- 圖片、顏色、App 圖示等資源
|-- To-Do-List-DemoTests/ <- 單元測試
| `-- To_Do_List_DemoTests.swift
`-- To-Do-List-DemoUITests/ <- 介面(UI)測試
|-- To_Do_List_DemoUITests.swift
`-- To_Do_List_DemoUITestsLaunchTests.swift
每個部分的職責
| 檔案/資料夾 | 負責什麼 |
|---|---|
.xcodeproj |
記錄專案的所有設定:要編譯哪些檔案、App 名稱、支援的 iOS 版本、簽章資訊等。你幾乎不會手動編輯它,都是透過 Xcode 介面修改。 |
To_Do_List_DemoApp.swift |
App 的「起點」。iOS 啟動 App 時,第一個執行的就是它,負責準備資料庫並顯示第一個畫面。 |
Item.swift |
定義「一筆待辦事項」包含哪些欄位(主旨、描述、建立時間),以及如何被儲存。 |
ContentView.swift |
App 打開後看到的主畫面:待辦清單,含「+」新增按鈕與刪除功能。 |
AddItemView.swift |
點「+」後彈出的新增畫面,讓使用者輸入主旨與描述。 |
ItemDetailView.swift |
點某一筆待辦後進入的詳細/編輯畫面,可修改後儲存或取消。 |
Assets.xcassets |
存放 App 圖示(AppIcon)、主題色(AccentColor)等視覺資源。 |
...Tests / ...UITests |
自動化測試程式。用來自動檢查 App 是否正常,初學階段可先略過。 |
為什麼檔名是 To_Do_List_DemoApp(有底線)? 專案名稱含有連字號「-」,但 Swift 的程式識別字不允許「-」,所以 Xcode 自動把「-」換成底線「_」來當作程式內部的名稱。
本專案使用「檔案系統同步群組」。這代表前面幾個畫面檔(AddItemView.swift、ItemDetailView.swift)即使是後來才新增的,只要放在 To-Do-List-Demo/ 資料夾內,Xcode 就會自動把它們納入編譯,你不必做任何額外設定。
第三章 Swift 語法快速入門
這一章不是要把 Swift 全部教完,而是只挑本專案用到的語法做最務實的說明。讀程式時若遇到不懂的符號,回到本章查閱即可。
變數 var 與常數 let
程式需要「記住」資料,記住資料的盒子就叫變數。
var subject = "買牛奶" // var:可以改變的變數
let pi = 3.14159 // let:不可改變的常數(設定後不能再變)
subject = "買麵包" // 合法:var 可以重新賦值
// pi = 3.0 // 錯誤:let 不能再改
var(variable):之後可以改變內容。let(constant):設定一次之後就固定。能用let就盡量用,比較安全。=是「賦值」:把右邊的值放進左邊的盒子,不是數學上的等於。
型別 Type:資料的種類
每個資料都有「種類」,稱為型別。本專案常見的有:
| 型別 | 代表 | 範例 |
|---|---|---|
String |
文字字串 | "買牛奶" |
Int |
整數 | 5、-3 |
Bool |
真/假(是非值) | true、false |
Double |
小數 | 3.14 |
Date |
日期與時間 | 現在時刻 |
Swift 通常能自動推斷型別。寫 var subject = "買牛奶",Swift 就知道 subject 是 String。也可以明寫:var subject: String = "買牛奶",冒號後面就是型別。
函式 func:把一段工作包起來
函式是一段「取了名字、可以重複使用」的程式碼。
func sayHello(name: String) {
print("Hello, \(name)!") // \(name) 會把變數的值嵌進字串
}
sayHello(name: "Nelson") // 呼叫函式,印出 Hello, Nelson!
func是宣告函式的關鍵字。括號內
name: String是參數:呼叫時要提供的資料。大括號
{ }內是函式的內容(要執行的工作)。\(name)是字串插值:把變數的值塞進文字裡。
結構 struct 與類別 class
這兩者都是「把一群相關的資料和功能打包在一起」的藍圖。
struct Point { // 一個「點」有 x 與 y 兩個座標
var x: Int
var y: Int
}
let p = Point(x: 3, y: 5) // 依藍圖造出一個實際的點
struct(結構):數值型別,複製時是「整份拷貝」。SwiftUI 的畫面(View)都是 struct。class(類別):參考型別,複製時是「共用同一份」。SwiftData 的資料模型用 class。
簡單記法:在 SwiftUI 中,畫面用 struct,要被長期儲存的資料用 class(加上 @Model)。本專案正是這樣安排:三個畫面是 struct,Item 資料是 class。
Optional:可能「沒有值」的資料
Swift 用「?」表示一個資料可能有值,也可能沒有(沒有時稱為 nil)。本專案中你會在 3...6 等地方間接接觸到相關概念,這裡先建立印象即可:見到 ? 就想到「這東西可能是空的」。
閉包 Closure:可以傳來傳去的一段程式
閉包是「一段沒有名字、但可以像資料一樣被傳遞」的程式碼,常用大括號 { } 包起來。本專案中按鈕被點擊時要執行的動作,就是用閉包來表達:
Button("Save") {
// 這整段大括號就是一個閉包:
// 「當按鈕被按下時,要做的事」
saveChanges()
}
屬性包裝器 Property Wrapper:以 @ 開頭的關鍵字
你會在本專案看到很多以 @ 開頭的字,例如 @State、@Query、@Model、@Environment。它們叫做屬性包裝器,是 SwiftUI/SwiftData 提供的「特殊標記」,用來賦予一個變數額外的能力。
| 標記 | 白話意義 |
|---|---|
@State |
「這是這個畫面自己擁有的狀態,變了就重畫畫面。」 |
@Binding / @Bindable |
「這個資料是別人給我的,我可以雙向地讀和改。」 |
@Environment |
「從系統環境拿一個現成的東西來用(例如資料庫、關閉畫面的能力)。」 |
@Query |
「自動從 SwiftData 資料庫撈出符合條件的資料,且資料一變畫面就更新。」 |
@Model |
「這個 class 是要被 SwiftData 儲存的資料模型。」 |
@main |
「整個 App 從這裡開始執行。」 |
現在看不太懂這些 @ 沒關係,後面逐行解說程式碼時,每一個出現的地方我們都會再次解釋它在「當下」扮演的角色。
第四章 程式進入點:To_Do_List_DemoApp.swift
我們從 App 的「起點」開始看。當使用者點下 App 圖示,iOS 會找到被標記為 @main 的地方,從那裡開始執行。
完整程式碼
import SwiftUI
import SwiftData
@main
struct To_Do_List_DemoApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}
逐行解說
第 1–2 行:引入工具箱
import SwiftUI
import SwiftData
import 的意思是「把某個工具箱拿進來用」。這裡引入了兩個:SwiftUI(畫面)與 SwiftData(資料儲存)。沒有 import,下面就無法使用這些工具。
第 4 行:@main 標記
@main
告訴系統:「整個 App 從這個結構開始執行。」一個 App 只能有一個 @main。
第 5 行:宣告 App 主體
struct To_Do_List_DemoApp: App {
struct To_Do_List_DemoApp:定義一個名為To_Do_List_DemoApp的結構。: App:冒號代表「遵循某個協定(protocol)」。這裡遵循App協定,等於宣告「我是一個 App」。遵循App就必須提供一個body(見後)。
協定(Protocol):一份「規格合約」。當你說「我遵循 App 協定」,就承諾會提供 App 規定的東西(例如 body)。協定確保不同的東西具備一致的能力。
第 6–18 行:建立資料庫容器
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
這一整段在準備 App 的「資料庫」。我們拆開看:
sharedModelContainer:一個變數,型別是ModelContainer(資料容器)。可以把它想成「整個 App 的倉庫總管」,負責保管所有待辦事項。Schema([Item.self]):綱要。告訴資料庫「我要儲存的資料種類有哪些」,這裡只有一種:Item。Item.self指的是「Item 這個型別本身」。ModelConfiguration(... isStoredInMemoryOnly: false):設定。isStoredInMemoryOnly: false表示「資料要真正存到磁碟」,所以 App 關掉再開,資料還在。若設成true,資料只放在記憶體,關掉就消失(測試時才會用)。do { ... } catch { ... }:錯誤處理。建立資料庫有可能失敗(例如磁碟壞了)。do區塊內嘗試建立,若失敗就跳到catch區塊。try ModelContainer(...):try表示「這個動作可能出錯」,必須搭配do/catch。fatalError(...):若真的失敗,就讓 App 立即崩潰並印出原因。對這種「沒有資料庫就無法運作」的情況,直接停止是合理的。最外層的
= { ... }():這是一個「立即執行的閉包」。大括號內準備好內容,結尾的()代表「現在就執行它」,把結果指派給sharedModelContainer。
ModelContainer / Schema / Configuration 三者關係:Schema 是「要存什麼資料」的清單;ModelConfiguration 是「要怎麼存」的設定;ModelContainer 則依據前兩者打造出實際運作的「倉庫」。
第 20–25 行:描述 App 的畫面
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
var body: some Scene:App協定要求提供一個body,用來描述 App 的內容。some Scene表示「這裡會回傳某一種場景」。WindowGroup { ... }:代表 App 的一個視窗。在 iPhone 上通常就是整個螢幕。ContentView():建立主畫面(第六章會詳解)。這就是 App 啟動後使用者第一眼看到的畫面。.modelContainer(sharedModelContainer):關鍵的一行。把前面建好的資料庫「注入」整個畫面階層。這之後,所有子畫面(ContentView 及其底下的畫面)都能透過@Environment取得這個資料庫來讀寫資料。
小結:這個檔案做了三件事:(1) 用 @main 宣告 App 起點;(2) 建立可永久儲存的資料庫;(3) 顯示 ContentView 作為第一個畫面,並把資料庫分享給所有畫面使用。
第五章 資料模型:Item.swift
這個檔案定義「一筆待辦事項」到底包含哪些資料。它是整個 App 的「資料藍圖」。
完整程式碼
import Foundation
import SwiftData
@Model
final class Item {
var subject: String
var itemDescription: String
var timestamp: Date
init(subject: String, itemDescription: String, timestamp: Date = Date()) {
self.subject = subject
self.itemDescription = itemDescription
self.timestamp = timestamp
}
}
逐行解說
第 1–2 行:引入工具
import Foundation
import SwiftData
Foundation 是最基礎的工具箱,提供 String、Date 等基本型別。SwiftData 提供 @Model 等儲存功能。
第 4 行:@Model 標記
@Model
這是最重要的一行。它告訴 SwiftData:「下面這個 class 是要被儲存的資料模型。」加上 @Model 後,SwiftData 會自動幫這個型別處理「存到磁碟、從磁碟讀出、變動追蹤」等繁瑣工作,我們完全不必自己寫資料庫程式。
第 5 行:宣告 Item 類別
final class Item {
class Item:定義一個名為Item的類別(資料用 class,因為要被 SwiftData 追蹤與共用同一份資料)。final:表示「這個類別不允許被其他類別繼承」。能避免不必要的複雜度,也讓編譯器最佳化。
第 6–8 行:三個資料欄位(屬性)
var subject: String
var itemDescription: String
var timestamp: Date
這三行定義了「一筆待辦事項」擁有的三項資料,稱為屬性(property):
subject: String:待辦事項的主旨(標題),文字。itemDescription: String:待辦事項的詳細描述,文字。timestamp: Date:這筆事項的建立時間,日期型別。
為什麼描述欄位叫 itemDescription 而不是 description?因為 Swift 裡 description 是一個已被內建使用的特殊名稱(用來描述物件)。若直接用 description 容易和系統行為衝突,所以這裡刻意改名為 itemDescription 以避開衝突。
第 10–14 行:初始化器 init
init(subject: String, itemDescription: String, timestamp: Date = Date()) {
self.subject = subject
self.itemDescription = itemDescription
self.timestamp = timestamp
}
init 是初始化器(建構子):當我們要「製造一筆新的 Item」時,會呼叫它,並提供必要的初始值。
括號內是製造一筆 Item 所需的三項資料。
timestamp: Date = Date()中的= Date()是預設值:如果呼叫時沒有特別指定時間,就自動用「現在」當作建立時間。Date()代表「此刻」。self.subject = subject:self指「這個正在被製造的 Item 自己」。這行把外面傳進來的subject(參數)存到自己的subject(屬性)裡。因為兩者同名,用self.來區分「自己的屬性」與「傳入的參數」。
實際使用範例:在主畫面新增待辦時會這樣呼叫——Item(subject: "買牛奶", itemDescription: "兩瓶")。因為沒給 timestamp,它會自動用現在時間。
第六章 主畫面:ContentView.swift
這是 App 啟動後看到的主畫面:一份待辦清單,上方有「+」可以新增、可以左滑刪除、點任一筆可進入詳細頁。
完整程式碼
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Item.timestamp, order: .reverse) private var items: [Item]
@State private var isAddingItem = false
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
ItemDetailView(item: item)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(item.subject)
.font(.headline)
if !item.itemDescription.isEmpty {
Text(item.itemDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
.onDelete(perform: deleteItems)
}
.navigationTitle("To-Do List")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button {
isAddingItem = true
} label: {
Label("Add Item", systemImage: "plus")
}
}
}
.sheet(isPresented: $isAddingItem) {
AddItemView { subject, description in
addItem(subject: subject, itemDescription: description)
}
}
} detail: {
Text("Select an item")
}
}
private func addItem(subject: String, itemDescription: String) {
withAnimation {
let newItem = Item(subject: subject, itemDescription: itemDescription)
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
逐行解說
宣告畫面
struct ContentView: View {
定義一個名為 ContentView 的畫面。: View 代表「我是一個 SwiftUI 畫面」,遵循 View 協定就必須提供 body(畫面內容)。
三個重要的屬性
@Environment(\.modelContext) private var modelContext
@Query(sort: \Item.timestamp, order: .reverse) private var items: [Item]
@State private var isAddingItem = false
modelContext:用@Environment從環境取得「資料庫的操作員」。還記得第四章把資料庫注入了環境嗎?這裡就是把它取出來用。新增、刪除資料都靠它。items:用@Query自動從資料庫撈出所有 Item。[Item]表示「Item 的陣列(清單)」。sort: \.Item.timestamp:依「建立時間」排序。order: .reverse:反向排序,也就是最新的排在最上面。@Query的魔力:只要資料庫裡的資料有任何變動(新增/刪除/修改),items會自動更新,畫面也跟著自動重畫——你完全不必手動刷新清單。
isAddingItem:用@State宣告的開關,型別是Bool,初始為false。它記錄「現在是否正要顯示新增畫面」。設成true時,新增畫面就會彈出來。private:表示這些屬性只屬於這個畫面內部使用,外部看不到,有助於封裝與安全。
\.modelContext 與 \Item.timestamp 中的反斜線是什麼? 這叫 KeyPath(鍵路徑),是一種「指向某個屬性」的寫法。\Item.timestamp 的意思就是「Item 的 timestamp 這個屬性」,用來告訴 @Query 要依哪個欄位排序。
畫面主體與導覽容器
var body: some View {
NavigationSplitView {
...
} detail: {
Text("Select an item")
}
}
NavigationSplitView:一個「可以切換頁面」的容器,支援主從式版面。在 iPhone 上,它讓我們可以從清單「推進」到詳細頁,並提供返回功能。第一個大括號是主側(清單)的內容;
detail:後面是詳細側的內容。在大畫面(iPad)上,右側預設顯示「Select an item」;在 iPhone 上,點清單項目會直接推進詳細頁。
清單與每一列
List {
ForEach(items) { item in
NavigationLink {
ItemDetailView(item: item)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(item.subject).font(.headline)
if !item.itemDescription.isEmpty {
Text(item.itemDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
.onDelete(perform: deleteItems)
}
List { ... }:建立一個可滾動的清單。ForEach(items) { item in ... }:把items陣列裡的每一筆都拿出來,逐一產生一列。item in代表「目前處理的這一筆叫做 item」。NavigationLink:把這一列變成「可點擊的連結」。點下去會推進到大括號裡指定的畫面——也就是ItemDetailView(item: item)(把這一筆 item 交給詳細頁)。label:後面是「這一列顯示出來的長相」:VStack(alignment: .leading, spacing: 4):垂直堆疊,靠左對齊,元素間距 4 點。第一行
Text(item.subject).font(.headline):顯示主旨,字體用較大的標題樣式。if !item.itemDescription.isEmpty:條件判斷——只有當描述「不是空的」時才顯示描述。!是「非(不)」,isEmpty是「是否為空」。描述文字使用較小字體、次要灰色(
.foregroundStyle(.secondary)),最多顯示兩行(.lineLimit(2))。
.onDelete(perform: deleteItems):為清單加上「左滑刪除」功能,刪除時呼叫deleteItems函式。
.font(.headline) 這種「點 + 名稱」的寫法叫修飾器(modifier)。它像是「在原本的元件上再貼一張設定貼紙」,可以一個接一個串起來,例如先設字體、再設顏色、再設行數。
標題與工具列
.navigationTitle("To-Do List")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button {
isAddingItem = true
} label: {
Label("Add Item", systemImage: "plus")
}
}
}
.navigationTitle("To-Do List"):在畫面頂端顯示標題「To-Do List」。.toolbar { ... }:在導覽列上放按鈕。EditButton():系統內建的「編輯」按鈕,點了會進入編輯模式,方便整理/刪除清單。第二個
ToolbarItem是「+」新增按鈕:Button { isAddingItem = true }:被點擊時,把開關isAddingItem設為true。一旦
isAddingItem變成true,下面的.sheet就會偵測到並彈出新增畫面(這就是宣告式 UI 的威力:改變狀態,畫面自動反應)。Label("Add Item", systemImage: "plus"):按鈕的長相——文字「Add Item」配上系統的「plus(加號)」圖示。systemImage用的是 Apple 內建的 SF Symbols 圖示庫。
彈出新增畫面(sheet)
.sheet(isPresented: $isAddingItem) {
AddItemView { subject, description in
addItem(subject: subject, itemDescription: description)
}
}
.sheet(isPresented: $isAddingItem):當isAddingItem為true時,從畫面底部彈出一個半頁卡片(sheet)。$isAddingItem前面的$代表傳入的是「雙向綁定」而非單純的值。這很重要:使用者把新增畫面往下滑關閉時,sheet 會自動把isAddingItem改回false,狀態與畫面保持同步。AddItemView { subject, description in ... }:建立新增畫面,並提供一段「儲存時要做的事」的閉包。當使用者在新增畫面按下 Save,它會回頭呼叫這段閉包,把使用者輸入的subject與description傳回來,我們再交給addItem真正存進資料庫。
新增資料的函式
private func addItem(subject: String, itemDescription: String) {
withAnimation {
let newItem = Item(subject: subject, itemDescription: itemDescription)
modelContext.insert(newItem)
}
}
withAnimation { ... }:把區塊內造成的畫面變化加上動畫,新項目會平順地滑入清單,而非突然出現。let newItem = Item(...):依第五章的藍圖,製造一筆新的待辦事項。modelContext.insert(newItem):把新事項插入資料庫。一旦插入,@Query偵測到資料變動,items自動更新,清單立刻顯示新項目。
刪除資料的函式
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
offsets: IndexSet:使用者左滑刪除時,系統會告訴我們「被刪掉的是第幾列(可能多列)」,這組位置就是offsets。for index in offsets { ... }:迴圈,把每一個要刪的位置都跑過一次。modelContext.delete(items[index]):items[index]取出第index筆,交給資料庫刪除。刪除後@Query同樣會自動更新清單。
本章關鍵心法:你會發現我們從頭到尾沒有寫過「請刷新清單」的程式。我們只是「改變資料」(插入、刪除),畫面就自動跟上。這正是 SwiftUI + SwiftData 的核心:資料是唯一真相來源,畫面是資料的倒影。
第七章 新增畫面:AddItemView.swift
點下「+」後彈出的畫面。讓使用者輸入主旨與描述,按 Save 儲存、按 Cancel 取消。
完整程式碼
import SwiftUI
struct AddItemView: View {
@Environment(\.dismiss) private var dismiss
@State private var subject = ""
@State private var itemDescription = ""
let onSave: (String, String) -> Void
var body: some View {
NavigationStack {
Form {
Section("Subject") {
TextField("Enter subject", text: $subject)
}
Section("Description") {
TextField("Enter description", text: $itemDescription, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle("New To-Do")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(subject.trimmingCharacters(in: .whitespacesAndNewlines),
itemDescription.trimmingCharacters(in: .whitespacesAndNewlines))
dismiss()
}
.disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
#Preview {
AddItemView { _, _ in }
}
逐行解說
屬性宣告
@Environment(\.dismiss) private var dismiss
@State private var subject = ""
@State private var itemDescription = ""
let onSave: (String, String) -> Void
@Environment(\.dismiss) ... dismiss:從環境取得「關閉這個畫面」的能力。之後呼叫dismiss()就能把這個彈出畫面收起來。subject/itemDescription:兩個@State,初始為空字串""。它們即時記錄使用者在輸入框打了什麼。使用者每打一個字,這裡就更新。let onSave: (String, String) -> Void:這是最關鍵的設計。它宣告一個叫onSave的閉包屬性,型別是「接收兩個 String、不回傳東西(Void)」。白話說:「我(新增畫面)不知道資料要怎麼存,那是別人的事。我只負責收集輸入,按 Save 時把資料交給外面(透過
onSave)。」在第六章,
ContentView正是提供了這段onSave,內容是呼叫addItem存進資料庫。這種「把後續動作交給呼叫者」的設計叫回呼(callback),能讓畫面更單純、更好重複使用。
表單與輸入框
NavigationStack {
Form {
Section("Subject") {
TextField("Enter subject", text: $subject)
}
Section("Description") {
TextField("Enter description", text: $itemDescription, axis: .vertical)
.lineLimit(3...6)
}
}
NavigationStack:提供頂部導覽列(標題與左右按鈕)的容器。Form:專門用來做「設定/輸入表單」的容器,會自動套用分組、底色等系統樣式。Section("Subject"):表單裡的一個「分區」,標題為 Subject。TextField("Enter subject", text: $subject):文字輸入框。第一個參數 "Enter subject" 是提示文字(placeholder),輸入框空白時顯示的灰字。
text: $subject把輸入框和subject狀態做雙向綁定(注意$):使用者打字會更新subject,反過來subject改變也會反映到框內。
描述輸入框多了
axis: .vertical與.lineLimit(3...6):允許多行輸入,高度介於 3 到 6 行之間,內容多時自動長高。
標題與工具列按鈕
.navigationTitle("New To-Do")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(subject.trimmingCharacters(in: .whitespacesAndNewlines),
itemDescription.trimmingCharacters(in: .whitespacesAndNewlines))
dismiss()
}
.disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.navigationTitle("New To-Do"):標題顯示「New To-Do」。.navigationBarTitleDisplayMode(.inline):標題用「小字、置中」的樣式(而非大標題)。Cancel 按鈕:放在
.cancellationAction(慣例上是左側)。點了只呼叫dismiss()關閉畫面,不做任何儲存,使用者輸入的內容就被丟棄。Save 按鈕:放在
.confirmationAction(慣例上是右側)。點了會:呼叫
onSave(...)把整理後的主旨與描述交給外面(即ContentView的addItem)。接著
dismiss()關閉新增畫面,回到清單。
.trimmingCharacters(in: .whitespacesAndNewlines):把使用者輸入頭尾的空白與換行去掉。例如「 買牛奶 」會變成「買牛奶」,避免存進一堆無意義的空白。.disabled(... .isEmpty):當主旨「去掉空白後仍是空的」時,把 Save 按鈕變成灰色不可點。這確保使用者至少要輸入主旨才能儲存——一種基本的輸入驗證。
預覽
#Preview {
AddItemView { _, _ in }
}
#Preview 是給 Xcode即時預覽用的。在 Xcode 右側的畫布(Canvas)上,你不必執行整個 App 就能看到這個畫面的長相。大括號內提供一個「空的 onSave」({ _, _ in } 表示「兩個參數都用不到,什麼也不做」),只是為了讓預覽能建立這個畫面。
資料流回顧:使用者輸入 → 存在 @State(subject/itemDescription)→ 按 Save → 透過 onSave 回呼把資料交給 ContentView → ContentView 呼叫 addItem 存入資料庫 → @Query 自動更新 → 清單出現新項目。
第八章 詳細/編輯畫面:ItemDetailView.swift
點清單中任一筆待辦後進入的畫面。可以檢視與編輯主旨、描述,按 Save 儲存變更並返回、按 Cancel 放棄變更並返回。
完整程式碼
import SwiftUI
struct ItemDetailView: View {
@Environment(\.dismiss) private var dismiss
@Bindable var item: Item
@State private var subject = ""
@State private var itemDescription = ""
private var trimmedSubject: String {
subject.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedDescription: String {
itemDescription.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool {
!trimmedSubject.isEmpty
}
var body: some View {
Form {
Section("Subject") {
TextField("Enter subject", text: $subject)
}
Section("Description") {
TextField("Enter description", text: $itemDescription, axis: .vertical)
.lineLimit(3...6)
}
Section("Created") {
Text(item.timestamp, format: Date.FormatStyle(date: .complete, time: .standard))
}
}
.navigationTitle(trimmedSubject.isEmpty ? item.subject : trimmedSubject)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveChanges()
dismiss()
}
.disabled(!canSave)
}
}
.onAppear {
loadFromItem()
}
}
private func loadFromItem() {
subject = item.subject
itemDescription = item.itemDescription
}
private func saveChanges() {
item.subject = trimmedSubject
item.itemDescription = trimmedDescription
}
}
逐行解說
屬性宣告
@Environment(\.dismiss) private var dismiss
@Bindable var item: Item
@State private var subject = ""
@State private var itemDescription = ""
dismiss:同前,用來返回上一頁的能力。@Bindable var item: Item:這是本檔的重點。item是從清單傳進來的那一筆待辦。@Bindable讓我們能對這個 SwiftData 物件做雙向綁定並直接修改它的屬性;一旦修改,變更會被 SwiftData 自動儲存。subject/itemDescription(@State):草稿暫存區。注意:我們沒有直接綁定到item的欄位,而是先複製到這兩個草稿狀態。使用者編輯的是草稿,按 Save 才寫回item。這樣「Cancel 放棄變更」才有意義。
為什麼要用草稿(@State)而不是直接改 item? 如果直接綁定 item 的欄位,使用者每打一個字都會立刻寫進資料庫,按 Cancel 也救不回來。改用草稿後:編輯只動草稿 → Save 才寫回 → Cancel 直接丟棄草稿、原資料毫髮無傷。這就是「可取消的編輯」標準作法。
計算屬性 computed property
private var trimmedSubject: String {
subject.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedDescription: String {
itemDescription.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool {
!trimmedSubject.isEmpty
}
這三個是計算屬性:它們不儲存固定值,而是每次被讀取時即時算出結果。
trimmedSubject:隨時回傳「去掉頭尾空白後的主旨」。trimmedDescription:同理,去空白後的描述。canSave:回傳「現在是否可以儲存」——只要去空白後的主旨不是空的(!...isEmpty)就為true。Save 按鈕會依這個值決定能不能按。
表單三個分區
Form {
Section("Subject") {
TextField("Enter subject", text: $subject)
}
Section("Description") {
TextField("Enter description", text: $itemDescription, axis: .vertical)
.lineLimit(3...6)
}
Section("Created") {
Text(item.timestamp, format: Date.FormatStyle(date: .complete, time: .standard))
}
}
前兩區是可編輯的主旨與描述輸入框,綁定到草稿
subject/itemDescription。第三區「Created」顯示建立時間,這是唯讀的(用
Text而非TextField)。Date.FormatStyle(date: .complete, time: .standard):把日期格式化成「完整日期(含星期)+標準時間」,例如圖中的Thursday, June 25, 2026 at 15:23:47。
動態標題
.navigationTitle(trimmedSubject.isEmpty ? item.subject : trimmedSubject)
這裡用了三元運算子 條件 ? A : B,意思是「如果條件成立就用 A,否則用 B」。
若草稿主旨是空的 → 顯示原本的
item.subject。否則 → 顯示使用者正在編輯的草稿主旨。
效果是:標題會即時跟著使用者輸入的主旨變化。
工具列:Save 與 Cancel
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveChanges()
dismiss()
}
.disabled(!canSave)
}
Cancel:只呼叫
dismiss()返回。因為我們從頭到尾只改草稿、沒碰item,所以「放棄變更」就是直接離開,原資料保持不變。Save:先
saveChanges()把草稿寫回item,再dismiss()返回。.disabled(!canSave):主旨為空時,Save 不可點,避免存出沒有主旨的待辦。
畫面出現時載入資料
.onAppear {
loadFromItem()
}
.onAppear 是「當這個畫面出現在螢幕上時」要做的事。這裡呼叫 loadFromItem(),把 item 目前的內容複製進草稿,使輸入框一打開就顯示既有資料,讓使用者在原內容上修改。
兩個輔助函式
private func loadFromItem() {
subject = item.subject
itemDescription = item.itemDescription
}
private func saveChanges() {
item.subject = trimmedSubject
item.itemDescription = trimmedDescription
}
loadFromItem():把真實資料 → 複製到草稿(進場時用)。saveChanges():把草稿(去空白後)→ 寫回真實資料(按 Save 時用)。一旦寫回,SwiftData 會自動把變更存到磁碟,清單也會自動反映新主旨。
Save / Cancel 的本質差異:兩者都會 dismiss() 返回,差別只在 Save 多做了 saveChanges()。理解「編輯草稿、確認才寫回」這個模式,你就掌握了幾乎所有 App 編輯頁的設計精髓。
第九章 測試檔案簡介
專案附帶了測試檔。測試是「自動檢查 App 有沒有壞掉」的程式,初學階段可先了解概念即可。
單元測試 To_Do_List_DemoTests.swift
單元測試用來檢查「某一小段邏輯是否正確」。本專案目前只有一個空樣板:
import Testing
@testable import To_Do_List_Demo
struct To_Do_List_DemoTests {
@Test func example() async throws {
// 在這裡撰寫測試,用 #expect(...) 檢查預期結果
}
}
import Testing:Apple 新版的測試框架。@testable import ...:把我們的 App 拿進來,以便測試其內部。@Test:標記「這是一個測試項目」。將來可在裡面用#expect(...)驗證,例如「新增一筆後,數量應為 1」。
介面測試 To_Do_List_DemoUITests.swift
UI 測試會真的啟動 App,模擬使用者點按、輸入,檢查畫面行為。
func testExample() throws {
let app = XCUIApplication()
app.launch() // 啟動 App
// 之後可加入:尋找按鈕、點擊、輸入文字、驗證結果
}
另外 testLaunchPerformance 會測量 App 啟動速度,To_Do_List_DemoUITestsLaunchTests 則會在啟動後自動截圖保存,方便檢視啟動畫面。
測試雖然初學可略過,但養成「寫一點測試」的習慣,能在你日後修改程式時,自動幫你抓出「改壞了的地方」,是專業開發的重要一環。
第十章 完整程式流程:把所有檔案串起來
讀完每個檔案後,這一章把它們連成一條完整的故事線,讓你看清資料與畫面如何協同運作。
流程一:App 啟動
使用者點下 App 圖示,iOS 找到
@main標記的To_Do_List_DemoApp。App 建立
ModelContainer(資料倉庫),準備好Item的儲存空間。App 顯示
WindowGroup裡的ContentView,並用.modelContainer(...)把資料庫分享給所有畫面。ContentView的@Query自動從資料庫撈出所有待辦,依時間由新到舊排好,顯示成清單。
流程二:新增一筆待辦
使用者點工具列的「+」,按鈕把
isAddingItem設為true。.sheet偵測到狀態變化,從底部彈出AddItemView。使用者在主旨、描述輸入框打字,內容即時存進
AddItemView的@State。主旨為空時 Save 為灰色不可按。使用者按
Save:AddItemView透過onSave回呼,把(去空白後的)主旨與描述交回ContentView,然後關閉自己。ContentView的addItem製造一筆新Item,呼叫modelContext.insert(...)存進資料庫。資料庫一變,
@Query自動更新items,清單帶動畫出現新項目(排在最上方,因為最新)。
流程三:檢視與編輯一筆待辦
使用者點清單中某一列,
NavigationLink推進到ItemDetailView,並把該筆item傳進去。.onAppear觸發loadFromItem(),把item內容複製到草稿狀態,輸入框顯示既有資料。使用者修改主旨/描述,改的是草稿;標題即時跟著主旨變動。
若按
Cancel:直接dismiss()返回,草稿丟棄,原資料不變。若按
Save:saveChanges()把草稿寫回item,SwiftData 自動存檔,再dismiss()返回;清單因@Query自動更新而顯示新主旨。
流程四:刪除一筆待辦
使用者在清單左滑某列(或進入 EditButton 編輯模式)。
觸發
.onDelete,呼叫deleteItems(offsets:)。函式依位置從資料庫
modelContext.delete(...)刪除對應的Item。@Query自動更新,該列帶動畫消失。
一張圖看懂資料流
使用者操作 \(\;\longrightarrow\;\) 改變狀態 / 資料(@State、modelContext)
\(\Big\downarrow\)
SwiftData 儲存變動 \(\;\longrightarrow\;\) @Query 偵測到資料改變
\(\Big\downarrow\)
SwiftUI 自動重畫相關畫面(清單、標題等自動更新)
整個 App 的靈魂:你(開發者)只負責「描述畫面」和「改變資料」;「畫面如何隨資料更新」交給 SwiftUI 自動處理。掌握這個分工,你就真正理解了現代 iOS 開發。
第十一章 在 Xcode 中實際操作
理解程式後,這一章帶你實際把 App 跑起來。
開啟專案
在 Finder 中找到
To-Do-List-Demo.xcodeproj,雙擊開啟,Xcode 會載入整個專案。左側是導覽器(Navigator),列出所有檔案;中間是編輯區;右側可顯示預覽畫布(Canvas)。
用模擬器執行 App
在 Xcode 上方中央,選擇一個模擬器(例如 iPhone 17)。
按下左上角的執行鈕(三角形 ),或按快捷鍵
Cmd + R。Xcode 會編譯程式(把 Swift 翻譯成手機看得懂的指令),然後在模擬器啟動 App。
你就能在模擬器中點「+」新增、點項目編輯、左滑刪除,親手驗證前面學到的每個流程。
模擬器(Simulator):在 Mac 上「假裝成一支 iPhone」的程式。不用真的手機就能測試 App,非常方便。
使用即時預覽 Canvas
打開任一畫面檔(如 AddItemView.swift),右側 Canvas 會顯示 #Preview 的即時畫面。改程式碼,預覽立即更新,不必每次都啟動整個 App,大幅加快開發速度。
基本除錯
紅色錯誤:程式有語法錯誤無法編譯。點紅點看說明,通常會提示哪一行、缺了什麼。
黃色警告:程式可以跑,但有可改進之處(例如宣告了沒用到的變數)。
print(...):在程式中插入print("到這裡了"),執行時會在 Xcode 底部的主控台(Console)印出訊息,幫你了解程式跑到哪、變數是什麼值。中斷點(Breakpoint):點程式行號旁可設藍色中斷點,執行到該行會暫停,讓你逐步檢查。
資料模型改變後的注意事項
如果你修改了 Item.swift 的欄位(例如本專案從只有 timestamp 加上了 subject、itemDescription),舊的資料庫結構會與新模型不相容,可能導致 App 啟動就崩潰。最簡單的解法:在模擬器或手機上把 App 刪除後重新安裝,讓 SwiftData 用新結構重建資料庫。
第十二章 動手練習與延伸挑戰
學程式最有效的方法是動手改。以下練習由易到難,建議逐題嘗試。
初級練習
改文字:把主畫面標題從 "To-Do List" 改成「我的待辦清單」。(提示:
.navigationTitle)改圖示:把新增按鈕的圖示從
"plus"換成"plus.circle.fill"。(提示:systemImage)改排序:把清單改成「由舊到新」排序。(提示:把
.reverse拿掉或改.forward)
中級練習
顯示時間:在主畫面每一列的描述下方,加一行小字顯示建立時間。(提示:仿照
ItemDetailView的Text(item.timestamp, format: ...))描述也要驗證:讓 Save 在「主旨或描述任一為空」時都不可按。
字數限制:主旨超過 50 字時,在輸入框下方顯示紅色提醒文字。
進階挑戰
完成狀態:在
Item加一個isDone: Bool欄位,清單列加上可勾選的圓圈,完成的項目顯示刪除線。搜尋功能:在清單上方加入搜尋框,依主旨過濾待辦。(提示:
.searchable)分類標籤:為待辦加入分類(工作/生活/購物),並可依分類篩選。
每完成一個練習,就用 Cmd + R 跑跑看。出錯不要怕——讀錯誤訊息、修正、再試,這正是每個工程師每天在做的事。
附錄 名詞對照與速查表
核心名詞對照表
| 名詞 | 白話解釋 |
|---|---|
| Xcode | Apple 的官方開發工具,用來寫程式、設計畫面、執行測試。 |
| Swift | 開發 iOS App 的程式語言。 |
| SwiftUI | 用程式碼宣告畫面長相的框架。 |
| SwiftData | 負責把資料永久儲存到裝置的框架。 |
| View(畫面) | SwiftUI 中代表「一塊畫面」的元件,通常是 struct。 |
| Modifier(修飾器) | 以「.名稱(...)」串接、用來調整元件外觀或行為的設定。 |
| @State | 畫面自己擁有的狀態;改變時自動重畫。 |
| @Binding / @Bindable | 對「別人給的資料」做雙向讀寫的綁定。 |
| @Environment | 從系統環境取得現成能力或物件(如資料庫、dismiss)。 |
| @Query | 自動撈出 SwiftData 資料,且資料變動時畫面自動更新。 |
| @Model | 標記一個 class 為可被 SwiftData 儲存的資料模型。 |
| ModelContainer | 資料的「倉庫」,保管所有被儲存的物件。 |
| ModelContext | 對資料庫進行新增、刪除、修改的「操作員」。 |
| Closure(閉包) | 一段沒有名字、可被傳遞的程式碼,常用 {} 包住。 |
| Callback(回呼) | 把「之後要做的事」交給呼叫者處理的設計(如 onSave)。 |
| Optional | 可能有值、也可能沒有(nil)的資料,型別後加「?」。 |
| struct / class | 把資料與功能打包的藍圖;畫面用 struct,儲存資料用 class。 |
| init(初始化器) | 製造一個新物件時呼叫、用來設定初始值的函式。 |
| KeyPath | 以「\型別.屬性」指向某個屬性的寫法。 |
| Simulator(模擬器) | 在 Mac 上模擬 iPhone 來測試 App 的工具。 |
| Canvas(畫布) | Xcode 右側的即時預覽區,由 #Preview 驅動。 |
本專案修飾器速查
| 修飾器 | 作用 |
|---|---|
.font(.headline) |
設定文字字體大小/樣式。 |
.foregroundStyle(.secondary) |
設定文字顏色(次要灰)。 |
.lineLimit(2) / (3...6) |
限制顯示行數/可變高度範圍。 |
.navigationTitle(...) |
設定導覽列標題。 |
.navigationBarTitleDisplayMode(.inline) |
標題用小字置中樣式。 |
.toolbar { ... } |
在導覽列放置按鈕。 |
.sheet(isPresented:) |
依狀態從底部彈出畫面。 |
.onDelete(perform:) |
為清單加上滑動刪除。 |
.onAppear { ... } |
畫面出現時執行某段程式。 |
.disabled(...) |
在條件成立時讓控制項變灰不可用。 |
.modelContainer(...) |
將 SwiftData 資料庫注入畫面階層。 |
恭喜你讀完整份教材!
你已經完整走過一個 iOS App 的資料模型、五個程式檔、以及新增/檢視/編輯/刪除的完整流程。
接下來最重要的事只有一件:打開 Xcode,動手改、動手玩。