希紋的部落格

從UIKit到SwiftUI心得

雖然請Claude做了不少次教學,但就沒有真正的融會貫通起來,這篇就是整理以往的教學內容,讓自己有點學習紀錄。

環境準備

因為時常以「完全都不會的新手」開始請教,感覺這樣的教學比較完整。

需要什麼

建立新專案

  1. 打開 Xcode → Create New Project
  2. iOS → App
  3. 填寫:
    • Product Name: ProjectName
    • Interface: SwiftUI ← 這個很重要!
    • Language: Swift
  4. 選擇存放位置,點 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!")
    }
}

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 升級。foregroundStyleforegroundColor 的直接替換,用法一樣,但能力更強:

// ❌ 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 等可以被直接推斷為 ColorforegroundStyle 的參數型別更廣(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:

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")
        }
    }
}

$isDoneisDone 的區別:

寫法 類型 用途
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)
    }
}

因為輸入文字後需要頻繁更新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))
}
  1. #Preview這個寫法是蠻新的,記得很久之前剛接觸SwiftUI的時候是別的形式
  2. 裡面的畫面呈現和APP運作時無關,所以要另外設定環境來讓預覽能夠運行

記住資料:@AppStorage

APP 功能正常,但有一個問題:關掉 APP 再開,所有任務都消失了

@State 只存在於記憶體中,APP 關閉就清空。我們需要 @AppStorage

@AppStorage 是什麼?

@AppStorageUserDefaults 的 SwiftUI 包裝,用法幾乎跟 @State 一模一樣,但資料會持久寫入裝置:

// @State:關 APP 消失
@State private var task1 = ""

// @AppStorage:永久保留
@AppStorage("task1") private var task1 = ""
//            ↑ key 是 UserDefaults 的鍵名,要唯一

支援的型別

@AppStorage 支援這些型別:

簡單記憶就是「支援基礎型別」

範例: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的來說只是需要時間轉換一下概念。

做久了應該都會記得這些基礎概念,但偶爾忘記的時候還是會回來複習一下。

#swift