從UIKit到SwiftUI心得
雖然請Claude做了不少次教學,但就沒有真正的融會貫通起來,這篇就是整理以往的教學內容,讓自己有點學習紀錄。
環境準備
因為時常以「完全都不會的新手」開始請教,感覺這樣的教學比較完整。
需要什麼
- Mac — 開發 iOS APP 需要 Mac
- Xcode 15+ — 從 Mac App Store 免費下載
- 不需要 iPhone(Simulator 就夠了)
建立新專案
- 打開 Xcode → Create New Project
- 選 iOS → App
- 填寫:
- Product Name:
ProjectName - Interface:
SwiftUI← 這個很重要! - Language:
Swift
- Product Name:
- 選擇存放位置,點 Create
認識 Xcode 畫面
┌─────────────────────────────────────────────────────┐
│ Navigator │ Editor (程式碼) │ Canvas │
│ (檔案列表) │ │ (即時預覽) │
│ │ struct ContentView │ │
│ 📁 ThreeToday │ : View { │ ┌───────────┐ │
│ ├ App.swift │ var body: some │ │ Hello, │ │
│ └ ContentV… │ View { ... } │ │ World! │ │
│ │ } │ └───────────┘ │
└─────────────────────────────────────────────────────┘
小技巧:按
Option + Command + Return可以開啟/關閉右側的 Canvas 預覽。Canvas 會即時反映你的程式碼變化,非常好用!
和UIKit時代不同的地方是,SwiftUI能夠即時預覽當下UI的佈局,並且做部分的即時互動,不需要運行在模擬器或手機上就能看見UI效果。
SwiftUI 的思考方式
宣告式(SwiftUI) vs 命令式(UIKit)
SwiftUI 採用宣告式(Declarative)寫法,這是學習 SwiftUI 最重要的心態轉換。
命令式(舊思維):你告訴電腦「一步一步怎麼做」
// UIKit 的寫法(命令式)
let button = UIButton()
button.setTitle("完成", for: .normal)
button.backgroundColor = .blue
button.layer.cornerRadius = 8
view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
// ...還要寫一堆 constraints...
宣告式(SwiftUI 思維):你告訴電腦「畫面應該長什麼樣子」
// SwiftUI 的寫法(宣告式)
Button("完成") {
// 點擊後做什麼
}
.background(.blue)
.cornerRadius(8)
View 協議
SwiftUI 中,「所有東西都是 View」。一個 View 就是一個 struct,遵從 View 協議,必須有一個 body 屬性:
struct MyFirstView: View {
var body: some View {
Text("Hello, SwiftUI!")
}
}
struct— View 是值類型(不是 class): View— 遵從 View 協議some View— 回傳「某種 View」(編譯器自動推斷)body— 描述這個 View 長什麼樣子
Modifier(修飾符)
Modifier 是用來調整 View 外觀的方法,可以像鍊子一樣串接:
Text("顯示文字")
.font(.system(size: 28, weight: .bold)) // 字體
.foregroundStyle(.primary) // 顏色
.padding(.bottom, 8) // 內距
重要觀念:每個
.modifier()都會包裝原來的 View,產生一個新的 View。順序有差。
// 這兩個結果不同!
Text("A").padding().background(.blue) // 藍底包住有 padding 的文字
Text("A").background(.blue).padding() // 有 padding 的藍底文字
執行順序為由上到下、由左至右,部分介面會因為先後進行而有差異,可以多嘗試。
foregroundStyle:顏色 Modifier 的現代寫法
'foregroundColor' is deprecated: Use foregroundStyle(_:) instead.
這是 iOS 17 做的 API 升級。foregroundStyle 是 foregroundColor 的直接替換,用法一樣,但能力更強:
// ❌ foregroundColor(iOS 17 起標示為 deprecated,仍能編譯但會顯示警告)
Text("Hello").foregroundColor(.blue)
// ✅ foregroundStyle(推薦寫法)
Text("Hello").foregroundStyle(.blue)
兩者的差異在於 foregroundStyle 接受任何 ShapeStyle,不只是 Color:
.foregroundStyle(.blue) // Color,跟以前一樣
.foregroundStyle(.secondary) // 語意色(自動適配深色模式)
.foregroundStyle(.linearGradient( // 漸層文字
colors: [.blue, .purple],
startPoint: .leading,
endPoint: .trailing
))
.foregroundStyle(.ultraThinMaterial) // 毛玻璃材質
.foregroundStyle(.red, .blue) // SF Symbols 雙色(兩個參數)
大多數情況一字替換即可,但有一個型別推斷的小陷阱需要注意。
注意:型別需要明確的三種情況
foregroundColor 的參數型別是 Color?,所以 .blue、.secondary 等可以被直接推斷為 Color。foregroundStyle 的參數型別更廣(some ShapeStyle),某些情況下 Swift 編譯器無法自動推斷,需要你明確寫出型別:
// 1. .accentColor — 需加上 Color.
// ❌ .foregroundStyle(.accentColor) // 編譯錯誤
// ✅
.foregroundStyle(Color.accentColor)
// 2. 三元運算子中的顏色 — 兩邊都要明確
// ❌ .foregroundStyle(isDone ? .secondary : .primary) // 可能推斷失敗
// ✅
.foregroundStyle(isDone ? Color.secondary : Color.primary)
// 3. 混合 UIColor 與 Color 的三元運算子 — 同上
// ❌ .foregroundStyle(isDone ? .green : Color(.tertiaryLabel))
// ✅
.foregroundStyle(isDone ? Color.green : Color(.tertiaryLabel))
相容性:
foregroundStyle在 iOS 15+ 就已存在。foregroundColor是到 iOS 17 才正式標示 deprecated,所以換過去後,舊裝置也完全沒問題。
佈局三兄弟:VStack / HStack / ZStack
SwiftUI 的佈局靠三個 Stack 容器完成,幾乎所有複雜佈局都是它們的組合。
VStack — 垂直排列
VStack(spacing: 16) {
Text("第一件事")
Text("第二件事")
Text("第三件事")
}
spacing 控制子 View 之間的間距(預設是系統值)。
HStack — 水平排列
HStack {
Circle()
.frame(width: 28, height: 28)
Text("買咖啡")
Spacer() // 彈性空白,把後面的內容推到右邊
Image(systemName: "checkmark")
}
Spacer() 是 HStack 裡的好朋友,它會盡可能撐大,把其他元素推到兩端。
ZStack — 層疊排列
ZStack {
Color.blue.opacity(0.1) // 底層背景
Text("我在上面") // 上層內容
}
讓畫面動起來:@State
到目前為止,畫面是靜態的。要讓 APP 能互動,需要「狀態」(State)。
@State 是什麼?
@State 是一個 Property Wrapper,告訴 SwiftUI:
- 這個變數是這個 View 的「私有狀態」
- 當它的值改變時,自動重新渲染畫面
struct CounterView: View {
@State private var count = 0 // ← @State 變數
var body: some View {
VStack {
Text("點了 \(count) 次")
Button("點我") {
count += 1 // ← 改變狀態,畫面自動更新
}
}
}
}
記憶口訣:
@State改變 → View 重新計算body→ 畫面更新
@State 的規則
// ✅ 正確
@State private var isDone = false
// ❌ 不要省略 private(@State 是 View 私有的)
@State var isDone = false
// ❌ 不要用 let(@State 需要能被改變)
@State private let isDone = false
在任務裡套用(範例)
struct TaskRowView: View {
@State private var isDone = false // 勾選狀態
var body: some View {
HStack {
Text("買咖啡")
.strikethrough(isDone) // isDone 為 true 時劃線
.foregroundStyle(isDone ? Color.secondary : Color.primary)
Spacer()
Button {
isDone.toggle() // 切換 true/false
} label: {
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isDone ? .green : .gray)
}
}
}
}
因為SwiftUI的邏輯是每一次展現的View都是獨立存在,因此不用
@State標籤的話新畫的View就無法追蹤到操作改變的值
@Binding — 「借用」狀態
當你要把狀態從父 View 傳給子 View,讓子 View 也能修改它,就需要 @Binding:
// 父 View:持有狀態
struct ContentView: View {
@State private var isDone = false // 狀態在這裡
var body: some View {
TaskRowView(isDone: $isDone) // 用 $ 傳遞 Binding
}
}
// 子 View:借用狀態
struct TaskRowView: View {
@Binding var isDone: Bool // 不是 @State,是 @Binding
var body: some View {
Button {
isDone.toggle() // 修改的是父 View 的狀態
} label: {
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
}
}
}
$isDone 與 isDone 的區別:
| 寫法 | 類型 | 用途 |
|---|---|---|
isDone |
Bool |
讀取值 |
$isDone |
Binding<Bool> |
傳遞「雙向連結」 |
@State是在追蹤自己的View中產生的變化,@Binding則是不同View中同步變化。
@FocusState — 控制鍵盤
用 @FocusState 可以程式控制鍵盤的顯示與隱藏:
struct TaskRowView: View {
@State private var text = ""
@FocusState private var isFocused: Bool // 追蹤是否聚焦
var body: some View {
HStack {
TextField("輸入任務", text: $text)
.focused($isFocused) // 連結 FocusState
Button("完成") {
isFocused = false // 設為 false → 收起鍵盤
}
}
}
}
使用者輸入:TextField
TextField 是單行文字輸入框,需要一個 Binding<String> 來連結文字:
struct InputDemo: View {
@State private var taskText = ""
var body: some View {
TextField("輸入今天第 1 件事…", text: $taskText)
.font(.system(size: 17))
.padding()
.background(Color(.systemBackground))
.cornerRadius(10)
}
}
"輸入今天第 1 件事…"— Placeholder,欄位空白時顯示text: $taskText— 雙向綁定,輸入內容即時更新taskText
因為輸入文字後需要頻繁更新View,這就是
@State會用到的場合之一
Button 的兩種寫法
// 寫法 A:簡短(標籤是純文字時)
Button("完成") {
print("按了!")
}
// 寫法 B:自訂 label(標籤是自訂 View 時)
Button {
isDone.toggle()
} label: {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 26))
.foregroundStyle(.green)
}
.buttonStyle(.plain) // 防止 Button 套用預設藍色樣式
為什麼要
.buttonStyle(.plain)?
預設情況下,Button 裡面的任何 View 都會變成藍色。如果你自訂了顏色,加上.buttonStyle(.plain)可以阻止這個行為。
Preview 的妙用
每個 View 都可以加上 #Preview,讓 Canvas 獨立預覽這個元件:
#Preview {
// 用 .constant() 建立固定值的 Binding,方便預覽
TaskRowView(
number: 1,
text: .constant("完成 SwiftUI 教學"),
isDone: .constant(true)
)
.padding()
.background(Color(.systemGroupedBackground))
}
#Preview這個寫法是蠻新的,記得很久之前剛接觸SwiftUI的時候是別的形式- 裡面的畫面呈現和APP運作時無關,所以要另外設定環境來讓預覽能夠運行
記住資料:@AppStorage
APP 功能正常,但有一個問題:關掉 APP 再開,所有任務都消失了。
@State 只存在於記憶體中,APP 關閉就清空。我們需要 @AppStorage。
@AppStorage 是什麼?
@AppStorage 是 UserDefaults 的 SwiftUI 包裝,用法幾乎跟 @State 一模一樣,但資料會持久寫入裝置:
// @State:關 APP 消失
@State private var task1 = ""
// @AppStorage:永久保留
@AppStorage("task1") private var task1 = ""
// ↑ key 是 UserDefaults 的鍵名,要唯一
支援的型別
@AppStorage 支援這些型別:
StringInt,DoubleBoolDataURL
簡單記憶就是「支援基礎型別」
範例:ContentView上的Task列表
把所有 @State 換成 @AppStorage:
struct ContentView: View {
@AppStorage("task1") private var task1 = ""
@AppStorage("task2") private var task2 = ""
@AppStorage("task3") private var task3 = ""
@AppStorage("done1") private var done1 = false
@AppStorage("done2") private var done2 = false
@AppStorage("done3") private var done3 = false
// 以下程式碼不變...
}
就這樣!換完之後,APP 關閉再開,資料都會保留。
Key 的命名建議
// ❌ 不好:太簡短,容易撞名
@AppStorage("task") private var task1 = ""
// ✅ 建議:加上 APP 前綴或有意義的命名
@AppStorage("threetoday.task.1") private var task1 = ""
長期資料儲存:SwiftData
是針對SwiftUI樣式而生的資料顧系統,可以用SwiftUI的概念去建立與套用。
SwiftData vs CoreData
SwiftData 是 Apple 在 iOS 17 / macOS 14 推出的全新資料持久化框架,概念上與 CoreData 相同,但語法更簡潔:
CoreData(舊)
class Note: NSManagedObject {
@NSManaged var title: String?
@NSManaged var content: String?
}
// 需要 .xcdatamodel 檔案
// 需要 NSFetchedResultsController
SwiftData(新)
@Model class Note {
var title: String
var content: String
}
// 不需要額外檔案
// 直接用 @Query
SwiftData 要求 iOS 17+。如果你的 App 需要支援舊版 iOS,仍需使用 CoreData。
@Model:定義資料模型
Note模型
import Foundation
import SwiftData
@Model // ← 告訴 SwiftData 這是一個資料模型
final class Note {
@Attribute(.unique) var id: UUID // ← .unique 確保 id 不重複
var title: String
var content: String
var createdAt: Date
var updatedAt: Date
var colorHex: String // ← 存莫蘭迪色 (#F2F2F7 格式)
// 多對多關係:inverse 告訴 SwiftData Tag.notes 是反向關聯
@Relationship(inverse: \Tag.notes)
var tags: [Tag] = []
// 計算屬性(不儲存到資料庫)
var displayTitle: String {
let t = title.trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty ? "未命名筆記" : t
}
init(title: String = "", content: String = "", colorHex: String = "#F2F2F7") {
self.id = UUID()
self.title = title
self.content = content
self.createdAt = Date()
self.updatedAt = Date()
self.colorHex = colorHex
}
}
Tag模型
import Foundation
import SwiftData
@Model
final class Tag {
@Attribute(.unique) var id: UUID
var name: String
var colorHex: String
// 反向關聯,SwiftData 自動維護
var notes: [Note] = []
init(name: String, colorHex: String = "#007AFF") {
self.id = UUID()
self.name = name
self.colorHex = colorHex
}
}
@Relationship(inverse:) 只需在其中一個 Model 寫,另一個留空陣列即可。兩邊都寫會報錯。
其他
對照表
| 概念 | UIKit 做法 | SwiftUI 做法 |
|---|---|---|
| 進入點 | AppDelegate + SceneDelegate | @main struct App: App |
| 畫面單位 | UIViewController (class) | View struct (struct) |
| UI 描述 | Storyboard / XIB / addSubview() | body: some View { ... } |
| 局部狀態 | var isLoading = false → 手動更新 | @State var isLoading = false |
| 父子資料傳遞 | initializer / delegate / closure | @Binding (雙向綁定) |
| 全域注入 | Singleton / Dependency Injection | @Environment |
| 資料庫查詢 | NSFetchedResultsController | @Query (自動同步) |
| 資料庫物件 | NSManagedObject (CoreData) | @Model class (SwiftData) |
| 導航 | UINavigationController.push() | NavigationStack + path |
| 彈出視窗 | present(_, animated:) | .sheet(isPresented:) { } |
| 清單 | UITableView | List { } |
| 網格 | UICollectionView | LazyVGrid { } |
| 搜尋列 | UISearchController | .searchable(text:) |
結語
已經把提及範例APP建構的部分給省略掉,只提取SwiftUI本身要了解的知識,和UIkit的關係。因為主要著重在SwfitUI的標籤,對熟悉UIKit的來說只是需要時間轉換一下概念。
做久了應該都會記得這些基礎概念,但偶爾忘記的時候還是會回來複習一下。