用 Xcode + Storyboard + Swift
打造你的第一個 iOS App
待辦事項清單(To-Do List)完整開發教學
從專案建立、介面設計、Core Data 資料儲存
到逐行程式解說與自動化測試
| 開發工具 | Xcode 26 |
| 介面技術 | Storyboard(UIKit) |
| 程式語言 | Swift 5 |
| 資料儲存 | Core Data |
| 適用對象 | iOS 開發初學者 |
2026 年 7 月
我們要做什麼?
在這份教材中,我們會從零開始,用 Xcode、Storyboard 與 Swift 開發一個功能完整的「待辦事項清單(To-Do List)」App。這是 iOS 開發最經典的入門題目,因為它涵蓋了幾乎所有 App 開發的核心觀念:
介面設計:使用 Storyboard 拖拉元件、設定 Auto Layout 約束。
表格列表:使用
UITableView顯示動態資料。使用者互動:點擊按鈕、彈出對話框、滑動刪除/編輯。
資料永久儲存:使用 Core Data 把資料存在手機裡,關掉 App 也不會消失。
MVC 架構:理解 Model(資料)、View(畫面)、Controller(邏輯)的分工。
完成後的 App 功能
新增:點右上角「+」,輸入文字即可新增一筆待辦事項。
完成:點擊某一筆事項,切換「已完成/未完成」;已完成的事項會加上刪除線、變灰、並顯示藍色勾勾。
編輯:向右滑動某一筆事項,出現藍色「編輯」按鈕,可修改文字。
刪除:向左滑動某一筆事項,出現紅色「刪除」按鈕。
永久儲存:所有資料透過 Core Data 存放在裝置上,重開 App 資料仍在。
空狀態提示:清單為空時,畫面中央顯示友善的提示文字。
成品畫面

專案檔案總覽
完成後的專案結構如下,本教材會逐一講解每個檔案的角色:
TodoList-Storyboard/
├── TodoList-Storyboard.xcodeproj ← Xcode 專案檔
└── TodoList-Storyboard/
├── AppDelegate.swift ← App 進入點與 Core Data 堆疊
├── SceneDelegate.swift ← 視窗(Scene)生命週期
├── ViewController.swift ← 主畫面邏輯(本教材重點)
├── Base.lproj/
│ ├── Main.storyboard ← 主畫面介面設計
│ └── LaunchScreen.storyboard ← 啟動畫面
├── TodoList_Storyboard.xcdatamodeld ← Core Data 資料模型
├── Assets.xcassets ← 圖片與顏色資源
└── Info.plist ← App 設定檔
開發環境與專案建立
需要準備什麼?
一台 Mac 電腦(macOS)。
Xcode:Apple 官方的整合開發環境(IDE),可從 Mac App Store 免費下載。本教材使用 Xcode 26。
不需要實體 iPhone——Xcode 內建「模擬器(Simulator)」即可執行與測試 App。
建立新專案(Step by Step)
打開 Xcode,選擇 File → New → Project...(或在歡迎視窗點「Create New Project...」)。
在範本選擇畫面,平台選 iOS,範本選 App,按 Next。
填寫專案資訊:
Product Name:
TodoList-StoryboardInterface:Storyboard(重點!不要選 SwiftUI)
Language:Swift
Storage:勾選 Core Data(Xcode 會自動幫我們產生 Core Data 的樣板程式碼)
Include Tests:勾選(我們最後會寫 UI 測試)
按 Next,選擇專案儲存位置,按 Create。
「Interface: Storyboard」代表用「視覺化拖拉」的方式設計畫面,適合初學者建立對 UIKit 的直觀理解;「SwiftUI」則是 Apple 較新的宣告式介面框架。兩者觀念相通,先學會其中一種,另一種會更容易上手。
認識 Xcode 介面
打開專案後,Xcode 視窗分為幾大區域:
左側導覽區(Navigator):顯示專案所有檔案,點檔案即可開啟。
中央編輯區(Editor):編輯程式碼,或以視覺化方式編輯 Storyboard。
右側檢查器(Inspector):選取 Storyboard 元件後,在這裡調整屬性(文字、顏色、字型等)。
上方工具列:選擇模擬器機型、按 Run(播放鍵) 建置並執行 App(快捷鍵
Cmd + R)。下方除錯區(Debug Area):顯示
print()輸出與錯誤訊息。
Xcode 幫我們產生了哪些檔案?
建立專案後,Xcode 已自動產生下列檔案,每個檔案的角色如下表:
| 檔案 | 角色 |
|---|---|
AppDelegate.swift |
App 的「總管」:App 啟動、進入背景等系統事件都在這裡處理;勾選 Core Data 後,這裡也放 Core Data 的核心程式碼。 |
SceneDelegate.swift |
管理「一個視窗(Scene)」的生命週期(前景/背景切換等)。 |
ViewController.swift |
主畫面的控制器,我們大部分的程式都寫在這裡。 |
Main.storyboard |
主畫面的視覺化設計檔。 |
LaunchScreen.storyboard |
App 啟動瞬間顯示的過場畫面。 |
TodoList_Storyboard.xcdatamodeld |
Core Data 的「資料模型」設計檔。 |
Assets.xcassets |
放 App 圖示、圖片、顏色等資源。 |
Info.plist |
App 的設定檔(例如支援的畫面方向)。 |
核心觀念:MVC 與 App 的運作流程
MVC 架構
UIKit App 採用 MVC(Model-View-Controller)架構,把程式分成三種角色:
MVC 三種角色
Model(模型):資料本身。在本專案中就是 Core Data 的
TodoItem(一筆待辦事項)。View(視圖):使用者看到的畫面。在本專案中就是 Storyboard 裡的 Table View、儲存格與按鈕。
Controller(控制器):居中協調者。負責把 Model 的資料顯示到 View 上,並把使用者在 View 上的操作(點擊、滑動)轉成對 Model 的修改。本專案的
ViewController就是這個角色。
App 從啟動到顯示畫面的流程
使用者點擊 App 圖示後,系統依序執行:
系統載入 App,呼叫
AppDelegate的didFinishLaunchingWithOptions(App 級初始化)。系統建立 Scene(視窗),呼叫
SceneDelegate的willConnectTo。因為
Info.plist中指定了主 Storyboard 為Main,系統自動載入Main.storyboard,並實例化其「初始視圖控制器(Initial View Controller)」。本專案的初始控制器是一個 Navigation Controller,它再載入其根控制器——我們的
ViewController。ViewController的viewDidLoad()被呼叫,我們在這裡做畫面初始化與讀取資料。
UITableView 的運作原理
待辦清單的核心元件是 UITableView(表格視圖)。它的運作方式和一般直覺不同——Table View 不會自己知道要顯示什麼,而是透過「委任(Delegation)」模式反過來問你:
Delegation(委任)模式 Table View 定義了兩份「協定(Protocol)」:
UITableViewDataSource(資料來源):Table View 問你「有幾列?」「第 3 列長什麼樣子?」你必須實作對應方法來回答。UITableViewDelegate(委任):Table View 通知你「使用者點了第 2 列」「使用者在第 5 列向左滑動」,你實作對應方法來回應。
只要把 ViewController 設成 Table View 的 dataSource 和 delegate,這些問題就會傳給它。
另外,Table View 使用「儲存格重用(Cell Reuse)」機制:畫面上只需要十幾個儲存格,滑出畫面的儲存格會被回收、換上新資料後重新使用,因此就算清單有一萬筆資料也不會耗盡記憶體。
用 Storyboard 設計介面
本章在 Main.storyboard 中完成主畫面的設計。請在左側導覽區點開 Main.storyboard,進入視覺化編輯模式。
步驟一:嵌入 Navigation Controller
我們希望畫面上方有一條導覽列(顯示標題「待辦事項」和「+」按鈕),因此要把現有的 View Controller 包進一個 Navigation Controller:
在 Storyboard 中點選現有的 View Controller。
選單列選擇 Editor → Embed In → Navigation Controller。
Xcode 會自動加入一個 Navigation Controller,並把「初始視圖控制器」的箭頭移到它身上。
點選 Navigation Controller 裡的 Navigation Bar,在右側屬性檢查器勾選 Prefers Large Titles,讓標題以 iOS 原生風格的大字顯示。
步驟二:設定導覽列標題與「+」按鈕
點選 View Controller 上方的 Navigation Item,在右側屬性檢查器把 Title 改為「待辦事項」。
打開右上角的 元件庫(Library,快捷鍵 Cmd + Shift + L),搜尋 Bar Button Item,拖到導覽列的右側。
選取這個 Bar Button Item,在屬性檢查器中把 System Item 設為 Add,它就會變成系統標準的「+」圖示。
步驟三:加入 Table View 與 Auto Layout 約束
從元件庫拖一個 Table View 到 View Controller 的畫面上,調整大小蓋滿整個畫面。
選取 Table View,點編輯區右下角的 Add New Constraints(十字方框圖示),設定四邊約束:
Top 貼齊 Safe Area 上緣,間距 0
Leading(左)、Trailing(右)、Bottom(下)貼齊畫面邊緣,間距 0
在屬性檢查器中,把 Table View 的 Style 設為 Inset Grouped(圓角卡片風格,更接近 iOS 原生質感)。
Auto Layout 與 Safe Area Auto Layout 是 iOS 的自動排版系統:你不是指定元件的絕對座標,而是描述「約束(Constraints)」——例如「Table View 的左邊貼齊父視圖左邊」。這樣不管是小螢幕 iPhone 還是大螢幕 iPad,畫面都能自動調整。Safe Area(安全區域)則是避開瀏海、動態島與底部手勢條的區域,把內容約束在 Safe Area 內就不會被遮住。
步驟四:設計 Prototype Cell(原型儲存格)
選取 Table View,在屬性檢查器把 Prototype Cells 數量設為 1,Table View 上會出現一個空白儲存格。
選取這個儲存格(Table View Cell),做兩個設定:
Style 設為 Subtitle:儲存格會有「主標題+副標題」兩行文字,我們用主標題顯示事項內容、副標題顯示建立時間。
Identifier 填入
TodoCell:這是儲存格的「重用識別碼」,程式碼中會用這個字串向 Table View 索取儲存格,兩邊字串必須完全一致。
如果程式執行時當機並出現 unable to dequeue a cell with identifier TodoCell 錯誤,就是 Storyboard 裡的 Identifier 和程式碼裡的字串不一致(大小寫、空格都要相同)。
步驟五:連結 IBOutlet 與 IBAction
Storyboard 上的元件要和程式碼互動,需要建立兩種連結:
IBOutlet:程式碼中的「變數」指向 Storyboard 元件,讓程式可以操作它(例如叫 Table View 重新載入資料)。
IBAction:Storyboard 元件觸發事件時(例如按鈕被點擊),呼叫程式碼中的「方法」。
操作方式:
開啟 Assistant Editor(雙併排編輯器):按住
Option點擊ViewController.swift,讓 Storyboard 與程式碼並排顯示。連 Outlet:按住
Control,從 Table View 拖曳到程式碼 class 內部,放開後選 Outlet,名稱填tableView。連 Action:按住
Control,從「+」按鈕拖曳到程式碼中,Connection 選 Action,名稱填addButtonTapped,Type 選UIBarButtonItem。連 dataSource 與 delegate:按住
Control,從 Table View 拖曳到畫面上方的 黃色 View Controller 圖示,在彈出選單中分別勾選 dataSource 與 delegate(要拖兩次)。
dataSource 與 delegate 也可以用程式碼設定(tableView.dataSource = self),但在 Storyboard 專案中習慣直接在介面上連結,程式碼更乾淨。
完成後的 Storyboard 結構
完成後,你的 Storyboard 應該包含這樣的階層:
Navigation Controller(初始視圖控制器)
└── ViewController(標題:待辦事項)
├── Navigation Item
│ └── Bar Button Item(System Item: Add)→ IBAction: addButtonTapped
└── View
└── Table View(Style: Inset Grouped)→ IBOutlet: tableView
│ → dataSource / delegate: ViewController
└── Prototype Cell(Style: Subtitle, Identifier: TodoCell)
用 Core Data 建立資料模型
為什麼需要 Core Data?
如果只把待辦事項存在陣列(Array)中,App 一關閉資料就消失了。Core Data 是 Apple 官方的資料持久化框架,能把物件存進裝置上的資料庫(底層是 SQLite),並提供查詢、排序、關聯等完整功能。
Core Data 三個核心角色
NSPersistentContainer(持久化容器):Core Data 的總入口,負責載入資料模型、建立底層資料庫。
NSManagedObjectContext(管理物件內容,簡稱 context):像一張「工作草稿紙」——所有新增、修改、刪除都先發生在 context 裡,呼叫
save()才真正寫入資料庫。NSManagedObject(管理物件):資料庫中的一筆資料在程式中的化身。我們的
TodoItem就是它的子類別。
建立 TodoItem 實體(Entity)
在左側導覽區點開
TodoList_Storyboard.xcdatamodeld,進入資料模型編輯器。點左下角 Add Entity,把新實體命名為
TodoItem。在 Attributes 區塊點「+」,新增三個屬性:
| 屬性名稱 | 型別 | 用途 |
|---|---|---|
title |
String | 待辦事項的文字內容 |
isCompleted |
Boolean | 是否已完成(預設 NO) |
createdAt |
Date | 建立時間(用來排序與顯示) |
選取
TodoItem實體,在右側 Data Model Inspector 中確認 Codegen 設為 Class Definition——Xcode 會在建置時自動產生TodoItem類別,我們不需要手寫。
自動產生的類別大致等同於:
public class TodoItem: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var isCompleted: Bool
@NSManaged public var createdAt: Date?
}注意 title 與 createdAt 的型別是「可選型別(Optional)」String? 與 Date?——這是 Core Data 產生程式碼的慣例,表示值有可能是 nil(不存在)。之後在程式中讀取時,需要用 if let 或 ?? 安全地「解包(unwrap)」。
逐行解說:AppDelegate.swift
AppDelegate.swift 大部分由 Xcode 範本產生(因為建立專案時勾選了 Core Data)。理解它是理解 Core Data 運作的第一步。
App 啟動與 Scene 管理
import UIKit
import CoreData
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}import UIKit:匯入 UIKit 框架。UIKit 提供所有介面相關類別(UIViewController、UITableView、UIButton 等),幾乎每個 iOS 介面檔案的第一行都是它。
import CoreData:匯入 Core Data 框架,才能使用 NSPersistentContainer 等類別。
@main:屬性標記(attribute),告訴編譯器「App 從這個類別開始執行」。整個專案只能有一個 @main。
宣告 AppDelegate 類別,繼承 UIResponder(能回應系統事件)並遵循 UIApplicationDelegate 協定(定義了 App 生命週期的各個回呼方法)。
didFinishLaunchingWithOptions:App 啟動完成後系統呼叫的第一個方法,適合放「全 App 只需做一次」的初始化。回傳 true 表示啟動成功。本專案不需要額外初始化,維持範本原樣。
Core Data 堆疊(Stack)
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "TodoList_Storyboard")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()儲存輔助方法 saveContext()
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}取得容器的 viewContext——主執行緒專用的 context,所有和畫面相關的資料操作都用它。
hasChanges:只有 context 上真的有未儲存的變更時才執行儲存,避免不必要的寫入。
context.save() 可能拋出錯誤(throws),所以要放在 do { try ... } catch 區塊中。try 標記「這行可能失敗」,失敗時跳到 catch。
儲存失敗時印出錯誤並終止。這個方法會在 App 進入背景時被 SceneDelegate 呼叫,做「最後一次保險儲存」。
逐行解說:SceneDelegate.swift
自 iOS 13 起,App 的「視窗」由 Scene 管理(一個 App 可以有多個視窗,例如 iPad 分割畫面)。SceneDelegate.swift 幾乎全部維持範本原樣,只需理解兩個重點:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
}window:這個 Scene 對應的視窗物件。因為我們使用 Storyboard,系統會自動建立視窗並載入 Main.storyboard,不需要手動寫程式。
willConnectTo:Scene 即將連接(顯示)時呼叫。guard let 是 Swift 的「提前退出」語法:嘗試把 scene 轉型成 UIWindowScene,失敗就直接 return。範本在此不做額外設定。
func sceneDidEnterBackground(_ scene: UIScene) {
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
}sceneDidEnterBackground:使用者切到別的 App 或回主畫面時呼叫。
透過 UIApplication.shared.delegate 取得 AppDelegate,呼叫上一章的 saveContext(),確保進背景前所有未儲存的變更都寫入資料庫。as? 是安全轉型(失敗回傳 nil),配合 ?. 可選鏈:如果轉型失敗,整行安靜地不執行、不會當機。
逐行解說:ViewController.swift(本教材核心)
這是我們自己撰寫的主要檔案,包含 App 的所有功能邏輯。以下依照程式碼順序,分段逐行解說。
匯入框架與類別宣告
import UIKit
import CoreData
class ViewController: UIViewController {匯入 UIKit(介面)與 CoreData(資料儲存)兩個框架。
宣告 ViewController 類別,繼承自 UIViewController——所有「管理一個畫面」的類別都繼承它。Storyboard 中的畫面已透過 Identity Inspector 的 Custom Class 設定指向這個類別。
IBOutlet 與屬性宣告
// MARK: - IBOutlet
/// 與 Storyboard 中的 Table View 連結的 Outlet
@IBOutlet weak var tableView: UITableView!
// MARK: - 屬性
/// 存放所有待辦事項的陣列(資料來源)
var todoItems: [TodoItem] = []
/// Core Data 的管理物件內容(Managed Object Context),
/// 透過 AppDelegate 的 persistentContainer 取得
var context: NSManagedObjectContext {
(UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
}// MARK: - 是給 Xcode 看的特殊註解:會在編輯器上方的跳轉選單中產生分節標記,方便在長檔案中快速導覽。
@IBOutlet:標記這個屬性可以連到 Storyboard 元件(IB = Interface Builder)。weak:弱參照,避免「循環參照」造成記憶體無法釋放(視圖層級已強持有這個 Table View,Outlet 只需弱參照即可)。結尾的 ! 是「隱式解包可選型別」:宣告時還沒有值,但我們保證 Storyboard 載入後它一定存在。
todoItems:型別為 [TodoItem] 的陣列,初始為空陣列 []。這是畫面顯示的資料來源——Table View 顯示幾列、每列顯示什麼,都以這個陣列為準。
context 是「計算屬性(computed property)」:沒有自己儲存值,每次讀取時執行大括號中的程式來取值。這裡透過 AppDelegate 拿到全 App 共用的 viewContext。as! 是強制轉型——因為我們確定 delegate 一定是 AppDelegate,可以放心使用。
空狀態標籤與日期格式器
/// 當清單為空時顯示的提示標籤
let emptyStateLabel: UILabel = {
let label = UILabel()
label.text = "目前沒有待辦事項\n點右上角「+」新增一筆吧!"
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .secondaryLabel
label.font = .preferredFont(forTextStyle: .body)
return label
}()
/// 用來把 Date 轉成易讀字串的日期格式器
let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.locale = Locale(identifier: "zh_Hant_TW")
return formatter
}()viewDidLoad:畫面初始化
override func viewDidLoad() {
super.viewDidLoad()
// 讓導覽列顯示大標題
navigationController?.navigationBar.prefersLargeTitles = true
// 設定空狀態提示為 Table View 的背景視圖
tableView.backgroundView = emptyStateLabel
// 從 Core Data 讀取已儲存的待辦事項
fetchTodoItems()
}override:覆寫父類別 UIViewController 的方法。viewDidLoad() 在視圖載入完成後被系統呼叫一次,是做畫面初始化的標準位置。
先呼叫 super.viewDidLoad(),讓父類別完成它自己的初始化——覆寫生命週期方法時務必記得這行。
navigationController? 取得包住自己的導覽控制器(可選鏈:若不存在則整行跳過),開啟大標題模式。
把空狀態標籤設為 Table View 的 backgroundView——它會顯示在儲存格「後面」,之後我們用顯示/隱藏它來呈現空狀態。
呼叫自訂方法讀取資料(下一節解說)。
fetchTodoItems:從 Core Data 讀取資料
/// 從 Core Data 讀取所有待辦事項,並依建立時間排序(新的在前)
func fetchTodoItems() {
let request: NSFetchRequest<TodoItem> = TodoItem.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
do {
todoItems = try context.fetch(request)
} catch {
print("讀取資料失敗:\(error)")
}
// 依資料是否為空,決定要不要顯示空狀態提示
emptyStateLabel.isHidden = !todoItems.isEmpty
tableView.reloadData()
}建立「擷取請求(Fetch Request)」,泛型 <TodoItem> 指明「我要查詢 TodoItem 這種實體」。TodoItem.fetchRequest() 是 Xcode 自動產生的便利方法。
設定排序描述器:依 createdAt 欄位排序,ascending: false 表示遞減——最新建立的排在最上面。注意 sortDescriptors 是陣列,可以指定多重排序條件。
context.fetch(request) 執行查詢並回傳結果陣列,指派給 todoItems。查詢可能失敗(throws),因此包在 do-catch 中;失敗時用 print 印出錯誤方便除錯,\\(error) 是「字串插值」——把變數值嵌入字串。
一行完成空狀態切換:todoItems.isEmpty 為真(沒有資料)時,! 取反得 false,即 isHidden = false——顯示提示。有資料時則隱藏。
reloadData():命令 Table View 重新向 dataSource 詢問所有資料並重繪畫面。修改 todoItems 後一定要呼叫這行,畫面才會更新。
saveAndReload:儲存並更新畫面
/// 將 context 中的變更寫入永久儲存區,並重新載入畫面
func saveAndReload() {
do {
try context.save()
} catch {
print("儲存資料失敗:\(error)")
}
fetchTodoItems()
}context.save() 把 context 裡累積的所有變更(新增、修改、刪除)一次寫入資料庫。同樣用 do-catch 處理可能的錯誤。
儲存後重新查詢並刷新畫面。把「儲存+刷新」包成一個方法,之後新增、完成、編輯、刪除四個功能都能重複使用——這就是 DRY(Don’t Repeat Yourself)原則。
addButtonTapped:新增待辦事項
/// 使用者點擊右上角「+」按鈕時呼叫,跳出輸入視窗新增待辦事項
@IBAction func addButtonTapped(_ sender: UIBarButtonItem) {
let alert = UIAlertController(title: "新增待辦事項",
message: "請輸入事項內容",
preferredStyle: .alert)
// 在 Alert 中加入一個文字輸入框
alert.addTextField { textField in
textField.placeholder = "例如:買牛奶"
}
// 「新增」按鈕:取得輸入文字並建立新的 TodoItem
let addAction = UIAlertAction(title: "新增", style: .default) { [weak self] _ in
guard let self = self,
let text = alert.textFields?.first?.text?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else { return }
// 在 Core Data 的 context 中建立一筆新資料
let newItem = TodoItem(context: self.context)
newItem.title = text
newItem.isCompleted = false
newItem.createdAt = Date()
self.saveAndReload()
}
// 「取消」按鈕:不做任何事
let cancelAction = UIAlertAction(title: "取消", style: .cancel)
alert.addAction(addAction)
alert.addAction(cancelAction)
present(alert, animated: true)
}@IBAction:標記這個方法可連到 Storyboard 事件——「+」按鈕被點擊時系統會呼叫它。參數 sender 是觸發事件的元件;參數名前的底線 _ 表示呼叫時不需寫參數標籤。
建立 UIAlertController(系統彈窗),preferredStyle: .alert 是置中對話框樣式(另一種 .actionSheet 是從底部升起的選單)。
addTextField 在彈窗中加入文字輸入框。大括號是「尾隨閉包(trailing closure)」——當閉包是最後一個參數時,可以寫在括號外面。閉包參數 textField 是系統建立好的輸入框,我們設定它的佔位提示文字。
建立「新增」按鈕。第三個參數(尾隨閉包)是按鈕被點擊時要執行的程式。[weak self] 是「捕捉列表」:閉包以弱參照捕捉 self,避免閉包與控制器互相強持有造成記憶體洩漏——在逃逸閉包中使用 self 時的標準防護寫法。閉包參數用 _ 忽略(我們不需要用到該 action 物件本身)。
guard let 多條件安全檢查,任何一項失敗就 return:(1) self 還存在;(2) 成功取得輸入框文字並用 trimmingCharacters 去除前後空白與換行;(3) 處理後的文字不是空字串。這確保使用者輸入純空白時不會新增空事項。
Core Data 新增資料的關鍵語法:TodoItem(context:) 在指定的 context 中建立一筆新的管理物件。注意——此時資料只存在於「草稿」中,尚未寫入資料庫。
設定三個屬性:標題、未完成、建立時間為現在(Date() 產生當下時間)。
呼叫 saveAndReload()——這時才真正寫入資料庫並刷新畫面。
「取消」按鈕使用 .cancel 樣式(粗體顯示),不傳入閉包表示點了不做任何事、只關閉彈窗。
把兩個按鈕加入彈窗,最後用 present(_:animated:) 以動畫顯示彈窗。
presentEditAlert:編輯既有事項
/// 跳出輸入視窗,讓使用者修改既有事項的標題
func presentEditAlert(for item: TodoItem) {
let alert = UIAlertController(title: "編輯待辦事項",
message: "請修改事項內容",
preferredStyle: .alert)
alert.addTextField { textField in
textField.text = item.title
}
let saveAction = UIAlertAction(title: "儲存", style: .default) { [weak self] _ in
guard let self = self,
let text = alert.textFields?.first?.text?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else { return }
item.title = text
self.saveAndReload()
}
alert.addAction(saveAction)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}參數標籤 for item: TodoItem:外部呼叫時寫 presentEditAlert(for: 某事項),讀起來像自然語言——這是 Swift 的 API 命名慣例。
與新增不同:把輸入框的初始文字設為現有的標題,讓使用者在原文字上修改。
Core Data 修改資料就是這麼簡單:直接改物件的屬性即可,context 會自動追蹤這筆物件「已被修改」,下次 save() 時寫入。
extension 與 UITableViewDataSource
// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
/// 回傳列表的列數 = 待辦事項的數量
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return todoItems.count
}extension(擴展):在類別本體之外為它「加掛」功能。慣例上,每個協定的實作用一個獨立的 extension 包起來,讓程式碼分區清楚。這行表示「ViewController 遵循 UITableViewDataSource 協定,實作寫在這個區塊」。
DataSource 必答題之一:「這個表格有幾列?」我們回答:todoItems 陣列有幾個元素就有幾列。section 是分區編號——本 App 只有一個分區,不需要理會它。
/// 設定每一列儲存格的內容
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// 從重用佇列取出識別碼為 "TodoCell" 的儲存格
let cell = tableView.dequeueReusableCell(withIdentifier: "TodoCell",
for: indexPath)
let item = todoItems[indexPath.row]
// 已完成的事項:加上刪除線並變灰
let title = item.title ?? ""
if item.isCompleted {
let attributed = NSAttributedString(
string: title,
attributes: [
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: UIColor.secondaryLabel
])
cell.textLabel?.attributedText = attributed
} else {
cell.textLabel?.attributedText = nil
cell.textLabel?.text = title
cell.textLabel?.textColor = .label
}
// 副標題顯示建立時間
if let createdAt = item.createdAt {
cell.detailTextLabel?.text = dateFormatter.string(from: createdAt)
} else {
cell.detailTextLabel?.text = nil
}
cell.detailTextLabel?.textColor = .secondaryLabel
// 已完成的事項在右側顯示勾勾
cell.accessoryType = item.isCompleted ? .checkmark : .none
return cell
}
}DataSource 必答題之二:「第 N 列的儲存格長什麼樣?」Table View 每次要顯示某一列時就呼叫這個方法。indexPath 包含 section(分區)與 row(列號)。
dequeueReusableCell:從「重用佇列」取出識別碼為 TodoCell 的儲存格(就是我們在 Storyboard 設計的 Prototype Cell)。如果佇列裡有被回收的舊儲存格就直接重用,沒有才建立新的——這就是第三章提到的儲存格重用機制。
用列號從陣列取出對應的事項:第 0 列對應 todoItems[0],以此類推。
?? 是「nil 聚合運算子」:item.title 是 String?,若為 nil 則改用空字串,確保 title 一定有值。
若事項已完成,建立 NSAttributedString(帶樣式的字串):加上單線刪除線(.strikethroughStyle)並把文字設為次要灰色,指派給主標題的 attributedText。
重用機制的重要細節:若事項未完成,必須把 attributedText 明確設回 nil、重設文字與顏色。因為這個儲存格可能是「回收再利用」的——若不清除,上一筆已完成事項的刪除線樣式會殘留在這筆未完成的事項上,造成經典的「儲存格內容錯亂」bug。
用 if let 解包 createdAt,有值就交給第八章宣告的 dateFormatter 轉成「2026年7月15日 下午2:38」格式,顯示在副標題。
三元運算子 條件 ? A : B:已完成顯示系統勾勾(.checkmark),否則不顯示配件。
把設定完成的儲存格回傳給 Table View 顯示。
UITableViewDelegate:點擊與滑動操作
// MARK: - UITableViewDelegate
extension ViewController: UITableViewDelegate {
/// 點擊某一列:切換「已完成 / 未完成」狀態
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let item = todoItems[indexPath.row]
item.isCompleted.toggle()
saveAndReload()
}didSelectRowAt:使用者點擊某一列時,Table View 透過 delegate 通知我們。
deselectRow:取消該列的「選取反白」狀態並附帶淡出動畫——不做這行,該列會一直維持灰底。
取出被點擊的事項,toggle() 把布林值反轉(true 變 false、false 變 true)——一行完成「切換完成狀態」。
儲存並刷新,畫面上立即出現(或移除)刪除線與勾勾。
/// 向左滑動:顯示「刪除」按鈕
func tableView(_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath)
-> UISwipeActionsConfiguration? {
let deleteAction = UIContextualAction(style: .destructive,
title: "刪除") { [weak self] _, _, completion in
guard let self = self else { return }
// 從 Core Data 中刪除該筆資料
let item = self.todoItems[indexPath.row]
self.context.delete(item)
self.saveAndReload()
completion(true)
}
return UISwipeActionsConfiguration(actions: [deleteAction])
}
/// 向右滑動:顯示「編輯」按鈕
func tableView(_ tableView: UITableView,
leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath)
-> UISwipeActionsConfiguration? {
let editAction = UIContextualAction(style: .normal,
title: "編輯") { [weak self] _, _, completion in
guard let self = self else { return }
self.presentEditAlert(for: self.todoItems[indexPath.row])
completion(true)
}
editAction.backgroundColor = .systemBlue
return UISwipeActionsConfiguration(actions: [editAction])
}
}trailingSwipeActions...:設定「從尾端滑入」(在由左至右的語言環境即向左滑)出現的按鈕。回傳型別 UISwipeActionsConfiguration? 可為 nil(表示該列不提供滑動操作)。
建立情境操作按鈕:.destructive 樣式自動顯示為紅色,代表破壞性操作。閉包的三個參數依序是 action 本身、按鈕視圖、完成回呼——前兩個用 _ 忽略。
Core Data 刪除資料:context.delete(item) 把該物件標記為刪除,save() 時真正從資料庫移除。之後 saveAndReload() 會重新查詢,畫面上該列即消失。
呼叫 completion(true) 告訴系統「操作已完成」,讓滑動選單收合。
把按鈕包進 UISwipeActionsConfiguration 回傳,陣列可放多個按鈕。
leadingSwipeActions...:「從前端滑入」(即向右滑)的按鈕,用來放「編輯」。
.normal 樣式預設灰色,第 245 行手動改為系統藍色。點擊後呼叫前面寫好的 presentEditAlert(for:) 彈出編輯視窗。
你可能注意到一個模式:所有資料操作都遵循同一個流程——修改 context(新增/改屬性/刪除)→ saveAndReload() → 畫面自動更新。掌握這個「單向資料流」心智模型,Core Data 就不再神祕。
建置、執行與自動化測試
在模擬器上執行
在 Xcode 上方工具列的裝置選單中選擇一台模擬器(例如 iPhone 17)。
按 Run(
Cmd + R)。Xcode 會編譯程式、啟動模擬器並安裝執行 App。測試所有功能:新增幾筆事項、點擊切換完成、左滑刪除、右滑編輯。
按模擬器的 Home(
Cmd + Shift + H)把 App 收到背景,再從 Xcode 重新執行——確認資料仍然存在,驗證 Core Data 儲存成功。
也可以使用終端機以指令建置(進階;適合自動化):
xcodebuild -project TodoList-Storyboard.xcodeproj \
-scheme TodoList-Storyboard \
-destination 'platform=iOS Simulator,name=iPhone 17' \
build撰寫 UI 自動化測試
專案的 TodoList-StoryboardUITests target 可以撰寫「模擬真人操作」的自動化測試。以下測試會自動點擊「+」、輸入文字、驗證清單內容,並點擊標記完成:
import XCTest
final class TodoList_StoryboardUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
/// 完整流程測試:新增兩筆待辦事項,並將其中一筆標記為完成
@MainActor
func testAddAndCompleteTodoItems() throws {
let app = XCUIApplication()
app.launch()
// 新增第一筆:買牛奶
app.navigationBars.buttons["Add"].tap()
let textField = app.alerts.textFields.firstMatch
XCTAssertTrue(textField.waitForExistence(timeout: 3))
textField.typeText("買牛奶")
app.alerts.buttons["新增"].tap()
// 新增第二筆:寫 iOS 作業
app.navigationBars.buttons["Add"].tap()
XCTAssertTrue(textField.waitForExistence(timeout: 3))
textField.typeText("寫 iOS 作業")
app.alerts.buttons["新增"].tap()
// 確認兩筆都出現在列表上
XCTAssertTrue(app.staticTexts["買牛奶"].waitForExistence(timeout: 3))
XCTAssertTrue(app.staticTexts["寫 iOS 作業"].exists)
// 點擊「買牛奶」把它標記為已完成
app.staticTexts["買牛奶"].tap()
sleep(1)
}
}重點語法說明:
XCUIApplication().launch():啟動被測試的 App。app.navigationBars.buttons["Add"]:以「無障礙識別」查詢介面元素——系統的 Add 按鈕識別名稱是"Add"。waitForExistence(timeout:):等待元素出現(最多等 3 秒),處理動畫等非同步狀況。XCTAssertTrue(...):斷言——條件不成立則測試失敗。typeText(...)與tap():模擬打字與點擊。
在 Xcode 中打開測試檔案,點方法左側的菱形按鈕即可執行;本專案實測測試通過(TEST SUCCEEDED)。
常見錯誤排查
| 症狀 | 原因與解法 |
|---|---|
啟動即當機:this class is not key value coding-compliant for the key ... |
Storyboard 的 Outlet 連到已改名或刪除的屬性。在 Storyboard 中右鍵檢查元件的連結,刪除黃色驚嘆號的失效連結後重連。 |
當機:unable to dequeue a cell with identifier |
程式碼中的識別碼字串與 Prototype Cell 的 Identifier 不一致。 |
| 點「+」新增後畫面沒反應 | 忘記呼叫 reloadData(),或 dataSource 沒有連到 ViewController。 |
| 重開 App 資料消失 | 忘記呼叫 context.save()(本專案封裝在 saveAndReload() 中)。 |
| 畫面上儲存格樣式錯亂(刪除線出現在錯的列) | 重用儲存格時沒有重設樣式,參考 cellForRowAt 中 else 分支的解說。 |
總結與延伸練習
你學會了什麼
用 Xcode 建立 Storyboard + Core Data 的 iOS 專案。
Storyboard 介面設計:Navigation Controller、Bar Button Item、Table View、Prototype Cell、Auto Layout 約束。
IBOutlet/IBAction 的連結,以及 dataSource/delegate 的委任模式。
Core Data 完整 CRUD:
TodoItem(context:)新增、直接改屬性修改、context.delete()刪除、NSFetchRequest查詢與排序、context.save()儲存。Swift 核心語法:可選型別與解包(
?、!、if let、guard let、??)、閉包與[weak self]、計算屬性、lazy、extension、三元運算子、字串插值。UI 細節:NSAttributedString 刪除線、滑動操作按鈕、空狀態設計、動態字型。
XCUITest 自動化 UI 測試。
延伸練習(由易到難)
到期日:為
TodoItem增加dueDate屬性,新增時用UIDatePicker選日期,過期事項顯示紅字。分區顯示:把清單分成「未完成」與「已完成」兩個 section(提示:實作
numberOfSections與titleForHeaderInSection)。搜尋:加入
UISearchController,用NSPredicate過濾 Core Data 查詢結果。拖曳排序:實作
moveRowAt讓使用者長按拖曳調整順序(需要增加sortIndex屬性)。NSFetchedResultsController:用它取代手動的
fetchTodoItems(),讓 Core Data 變更自動驅動 Table View 的插入/刪除動畫。iCloud 同步:改用
NSPersistentCloudKitContainer作為持久化容器,在多台裝置間同步資料。
恭喜完成你的第一個 iOS App!