swiftui-patterns

swiftui-patterns

熱門

使用現代 MV 架構、狀態管理、組合、隔離預覽與遷移指引,建構與審查 SwiftUI 畫面。涵蓋 @Observable 所有權、@State/@Bindable/@Environment 接線、畫面分解、ViewModifier、環境值、.task 載入、iOS 26+ 交接、Writing Tools、剪貼簿可用性與效能。適用於結構化 SwiftUI 狀態、管理 @Observable、組合畫面、預覽有意義的 UI 狀態,或修正 SwiftUI 模式。

922星標
46分支
更新於 2026/7/15
SKILL.md
readonlyread-only
name
swiftui-patterns
description

使用現代 MV 架構、狀態管理、組合、隔離預覽與遷移指引,建構與審查 SwiftUI 畫面。涵蓋 @Observable 所有權、@State/@Bindable/@Environment 接線、畫面分解、ViewModifier、環境值、.task 載入、iOS 26+ 交接、Writing Tools、剪貼簿可用性與效能。適用於結構化 SwiftUI 狀態、管理 @Observable、組合畫面、預覽有意義的 UI 狀態,或修正 SwiftUI 模式。

SwiftUI Patterns

針對 iOS 26+ 與 Swift 6.3 的現代 SwiftUI 模式。涵蓋架構、狀態管理、畫面組合、環境接線、非同步載入、設計打磨與平台/分享整合。導覽、佈局、動畫與 Liquid Glass 模式位於專屬的兄弟技能中。除非特別標註,否則模式向下相容至 iOS 17。

目錄

範圍邊界: 本技能涵蓋架構、狀態所有權、組合、環境接線、非同步載入及相關的 SwiftUI 應用程式結構模式。詳細的導覽模式(包括 NavigationStack、NavigationSplitView、sheet、分頁與深度連結模式)由 swiftui-navigation 技能負責。詳細的佈局、容器與元件模式(包括 stacks、grids、lists、scroll view 模式、forms、controls、使用 .searchable 的搜尋 UI、overlays 及相關佈局元件)由 swiftui-layout-components 技能負責。詳細的動畫編排由 swiftui-animation 技能負責。Liquid Glass 採用、自訂玻璃控制項、scroll edge effects、.scrollEdgeEffectStyle 與 .backgroundExtensionEffect 由 swiftui-liquid-glass 技能負責。

工作流程

  1. 記錄目前的狀態所有權、動作、副作用、導覽與生命週期行為。
  2. 選擇能維持該合約的最小 MV/狀態/組合變更。
  3. 每個結構步驟後建置;在繼續前修正編譯器與隔離錯誤。
  4. 針對已載入、載入中、空白與錯誤狀態(視情況)呈現確定性預覽,包含所需的環境依賴。
  5. 演練重要的互動與副作用。若行為改變,還原測試環境,修正最小的邊界,然後重新執行相同的建置、預覽與互動檢查。

載入 行為保留的畫面重構 以重構現有畫面,以及 隔離預覽建構 以了解測試環境與依賴模式。

架構:Model-View (MV) 模式

預設採用 MV——畫面是輕量的狀態表達;模型與服務擁有商業邏輯。除非現有程式碼已使用 ViewModel,否則不要引入。

核心原則:

  • 偏好使用 @State@Environment@Query.task.onChange 進行編排
  • 透過 @Environment 注入服務與共享模型;保持畫面小巧且可組合
  • 將大型畫面拆分為較小的子畫面,而非引入 ViewModel
  • 測試模型、服務與商業邏輯;保持畫面簡單且宣告式
struct FeedView: View {
    @Environment(FeedClient.self) private var client

    enum ViewState {
        case loading, error(String), loaded([Post])
    }

    @State private var viewState: ViewState = .loading

    var body: some View {
        List {
            switch viewState {
            case .loading:
                ProgressView()
            case .error(let message):
                ContentUnavailableView("錯誤", systemImage: "exclamationmark.triangle",
                                       description: Text(message))
            case .loaded(let posts):
                ForEach(posts) { post in
                    PostRow(post: post)
                }
            }
        }
        .task { await loadFeed() }
        .refreshable { await loadFeed() }
    }

    private func loadFeed() async {
        do {
            let posts = try await client.getFeed()
            viewState = .loaded(posts)
        } catch {
            viewState = .error(error.localizedDescription)
        }
    }
}

關於 MV 模式的原理、應用程式接線與輕量客戶端範例,請參閱 references/architecture-patterns.md

狀態管理

@Observable 所有權規則

重要: 當 SwiftUI 畫面擁有、修改或綁定 @Observable 儲存體與 ViewModel 的屬性時,請將其隔離在 @MainActor 上。Observation 會追蹤變更,但不會讓共享的可變狀態變成執行緒安全。不接觸 UI 狀態的領域模型可以使用自己的隔離策略。

包裝器 使用時機
@State 畫面擁有該物件或值。建立並管理生命週期。
let 畫面接收一個 @Observable 物件。唯讀觀察——不需要包裝器。
@Bindable 畫面接收一個 @Observable 物件,且需要雙向綁定($property)。
@Environment(Type.self) 從環境存取共享的 @Observable 物件。
@State(值類型) 畫面本機的簡單狀態:開關、計數器、文字欄位值。永遠設為 private
@Binding 與父畫面的 @State@Bindable 屬性進行雙向連接。

所有權模式

// 綁定 UI 的 @Observable 儲存體——主執行緒隔離
@MainActor
@Observable final class ItemStore {
    var title = ""
    var items: [Item] = []
}

// 擁有該模型的畫面
struct ParentView: View {
    @State private var viewModel = ItemStore()

    var body: some View {
        ChildView(store: viewModel)
            .environment(viewModel)
    }
}

// 讀取該模型的畫面(@Observable 不需要包裝器)
struct ChildView: View {
    let store: ItemStore

    var body: some View { Text(store.title) }
}

// 需要雙向存取的畫面
struct EditView: View {
    @Bindable var store: ItemStore

    var body: some View {
        TextField("標題", text: $store.title)
    }
}

// 從環境讀取的畫面
struct DeepView: View {
    @Environment(ItemStore.self) private var store

    var body: some View {
        @Bindable var s = store
        TextField("標題", text: $s.title)
    }
}

細粒度追蹤: SwiftUI 只會重新渲染讀取到已變更屬性的畫面。如果畫面讀取 items 但未讀取 isLoading,則變更 isLoading 不會觸發重新渲染。這是相較於 ObservableObject 的一大效能優勢。

舊版 ObservableObject

僅在支援 iOS 16 或更早版本時使用。@StateObject@State@ObservedObjectlet@EnvironmentObject@Environment(Type.self)

畫面成員排序慣例

由上到下排序成員:1) @Environment 2) let 屬性 3) @State / 儲存屬性 4) 計算屬性 var 5) init 6) body 7) 畫面建構器 / 輔助方法 8) 非同步函式

畫面組合

提取子畫面

將畫面拆分為專注的子畫面。每個子畫面應有單一職責。
重構現有畫面時,請載入 行為保留的畫面重構
以了解動作/副作用邊界與建置/預覽驗證。

var body: some View {
    VStack {
        HeaderSection(title: title, isPinned: isPinned)
        DetailsSection(details: details)
        ActionsSection(onSave: onSave, onCancel: onCancel)
    }
}

計算畫面屬性

對於小型、無狀態的片段,保留計算屬性的 some View。當片段出現以下任何訊號時,將其提取為專屬的 View 類型:

  • 有意義的分支或大量佈局
  • 擁有自己的狀態或非同步生命週期
  • 比父畫面更窄的 Observation 依賴
  • 有用的獨立預覽
  • 複雜度足以遮蔽父畫面的資料流

縮小依賴範圍時,只傳遞子畫面需要的值、綁定與動作。如果這些形成一個龐大但內聚的介面,則傳遞功能範圍的 @Observable 模型。Observation 會將失效範圍限制在子畫面讀取的屬性,但應用程式範圍的儲存體仍會建立一個寬廣的介面;請保留給真正需要該內聚狀態的子畫面。

重複使用是分解的有用結果,而非必要條件。

Extensions 與 // MARK: - 用於組織大型檔案;它們不會建立畫面邊界或取代提取。

ViewBuilder 函式

用於不值得建立獨立結構的條件邏輯:

@ViewBuilder
private func statusBadge(for status: Status) -> some View {
    switch status {
    case .active: Text("啟用").foregroundStyle(.green)
    case .inactive: Text("停用").foregroundStyle(.secondary)
    }
}

自訂 View Modifier

將重複的樣式提取為 ViewModifier

struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.background)
            .clipShape(.rect(cornerRadius: 12))
            .shadow(radius: 2)
    }
}
extension View { func cardStyle() -> some View { modifier(CardStyle()) } }

穩定的畫面樹

避免頂層的條件式畫面切換。偏好使用單一穩定的基礎畫面,並在區段或修飾詞內部加入條件。

當提取的畫面需要獨立的狀態覆蓋、確定性測試環境或環境設定時,請載入 隔離預覽建構

環境

自訂環境值

使用 @Entry 建立自訂環境值與動作。它會為 EnvironmentValues 產生樣板程式碼。

extension EnvironmentValues {
    @Entry var theme: Theme = .default
    @Entry var refreshFeed: @Sendable () async -> Void = {}
}

// 使用方式
.environment(\.theme, customTheme)
.environment(\.refreshFeed) { await feedStore.refresh() }

@Environment(\.theme) private var theme
@Environment(\.refreshFeed) private var refreshFeed

對於相容 iOS 17 的程式碼或舊版相容性墊片,請改用手動的 EnvironmentKey 類型。

常見的內建環境值

@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) var dynamicTypeSize
@Environment(\.horizontalSizeClass) var sizeClass
@Environment(\.isSearching) var isSearching
@Environment(\.openURL) var openURL
@Environment(\.modelContext) var modelContext

非同步資料載入

一律使用 .task——它會在畫面消失時自動取消:

struct ItemListView: View {
    @State var store = ItemStore()

    var body: some View {
        List(store.items) { item in
            ItemRow(item: item)
        }
        .task { await store.load() }
        .refreshable { await store.refresh() }
    }
}

使用 .task(id:) 在依賴變更時重新執行:

.task(id: searchText) {
    guard !searchText.isEmpty else { return }
    await search(query: searchText)
}

除非需要儲存參考以供取消,否則切勿在 onAppear 中手動建立 Task。例外情況:在同步動作閉包(例如 Button 動作)中,Task {} 可用於在非同步工作前立即更新狀態。

使用 swift-concurrency 處理取消處理器、debounce 與時鐘、AsyncSequence 或 actor 隔離。

iOS 26+ 新 API

.scrollEdgeEffectStyle.backgroundExtensionEffect 與玻璃控制項導向 swiftui-liquid-glass;將 @Animatable 導向 swiftui-animationTextEditor(text: Binding<AttributedString>) 是 iOS 26 的富文字編輯路徑。請在採用這些 API 的程式碼旁保留可用性檢查。

剪貼簿指令修飾詞並非 iOS 26 的預設值:.copyable.cuttable 與基於指令的 .pasteDestination(for:action:validator:) 在目前的 Apple 文件中是 macOS 13+ 與 iOS/iPadOS/Mac Catalyst 27 beta。對於 iOS 26 目標,請使用 UIPasteboard 處理自訂剪貼簿指令,或使用拖放與 ShareLink 處理 Transferable 流程。請參閱 references/platform-and-sharing.md

效能指南

  • Lazy stacks/grids: 對於大型集合,使用 LazyVStackLazyHStackLazyVGridLazyHGrid。一般 stacks 會立即渲染所有子項目。
  • 穩定的 ID: List/ForEach 中的所有項目必須符合 Identifiable 並使用穩定的 ID。絕不要使用陣列索引。
  • 避免 body 重複計算: 將過濾與排序移至計算屬性或模型,而非內嵌在 body 中。
  • Equatable 畫面: 對於不必要地重新渲染的複雜畫面,使其符合 Equatable

HIG 對齊

遵循 Apple 人機介面指南的佈局、排版、色彩與無障礙設計。關鍵規則:

  • 使用語意色彩(Color.primary.secondaryColor(uiColor: .systemBackground))以自動支援淺色/深色模式
  • 使用系統字型樣式(.title.headline.body.caption)以支援 Dynamic Type
  • 使用 ContentUnavailableView 處理空白與錯誤狀態
  • 除非需要特定值,否則省略 stack 的 spacing:——nil(預設值)會使用平台適配的自適應間距
  • 透過 horizontalSizeClass 支援自適應佈局
  • 提供 VoiceOver 標籤(.accessibilityLabel),並透過切換佈局方向來支援 Dynamic Type 無障礙尺寸

請參閱 references/design-polish.md 了解 HIG、主題、觸覺回饋、焦點、轉場與載入模式。

Writing Tools (iOS 18+)

使用 .writingToolsBehavior(_:) 控制文字畫面上的 Apple Intelligence Writing Tools 體驗。

層級 效果 使用時機
.complete 完整的內嵌改寫(校對、改寫、轉換) 筆記、電子郵件、文件
.limited 簡化的覆蓋面板體驗 程式碼編輯器、驗證表單
.disabled 完全隱藏 Writing Tools 密碼、搜尋列
.automatic 系統根據上下文選擇(預設) 大多數畫面
TextEditor(text: $body)
    .writingToolsBehavior(.complete)
TextField("搜尋…", text: $query)
    .writingToolsBehavior(.disabled)

偵測活躍工作階段: 讀取 UITextView(UIKit)上的 isWritingToolsActive,以延遲驗證或暫停復原分組,直到改寫完成。

文件: WritingToolsBehavior · writingToolsBehavior(_:)

常見錯誤

  1. 使用 @ObservedObject 建立物件——請使用 @StateObject(舊版)或 @State(現代)

  2. 在畫面 body 中進行大量計算——移至模型或計算屬性

  3. 未使用 .task 處理非同步工作——在 onAppear 中手動建立 Task 若未取消會造成洩漏

  4. 使用陣列索引作為 ForEach 的 ID——導致錯誤的差異比對與 UI 錯誤

  5. 忘記 @Bindable——@Observable 上的 $property 語法需要 @Bindable

  6. 過度使用 @State——僅用於畫面本機狀態;共享狀態應屬於 @Observable

  7. 將複雜或可獨立預覽的區段保留為計算屬性——請提取為 View 類型;extensions 與 // MARK: 僅用於組織

  8. 使用 NavigationView——已棄用;請使用 NavigationStack

  9. foregroundStyle(_:) 更符合語意樣式時使用 foregroundColor(_:)

  10. 在 body 中使用內嵌閉包——將複雜閉包提取為方法

  11. 當狀態代表模型時使用 .sheet(isPresented:)——請改用 .sheet(item:)

  12. 在例行分支中使用 AnyView——型別抹消會隱藏結構,可能損害效能或身份敏感的轉場。請使用 @ViewBuilderGroup 或泛型,除非 API 確實需要異質畫面儲存。請參閱 references/deprecated-migration.md

  13. @AppStorage 放在 @Observable 類別中。 @AppStorage 是畫面的 DynamicProperty;請將其保留在 View 中,或在模型中公開一個由 UserDefaults 支援的一般觀察屬性。

  14. 在每個 stack 上硬編碼 spacing:——省略它以獲得自適應平台間距;僅在值有特定意圖時指定

  15. .copyable.cuttable 或基於指令的 .pasteDestination(for:action:validator:) 視為 iOS 16/iOS 26 API——它們在目前的 Apple 文件中是 macOS 13+ 與 iOS/iPadOS/Mac Catalyst 27 beta。對於 iOS 26 目標,請使用 UIPasteboard、拖放或 ShareLink

  16. 將現代預設值視為正式棄用——#Preview 是現代預覽預設值,但 PreviewProvider 是舊版而非編譯器棄用。EditButton.onDelete.onMove 在編輯模式列表工作流程中仍然有效;請使用 .swipeActions 處理上下文列動作。

  17. 為了停止預覽崩潰而將必要的依賴設為可選——請改為安裝確定性預覽依賴,避免使用即時網路、認證、生產資料庫或全域單例

審查清單

  • [ ] 共享狀態模型使用 @Observable(iOS 17+ 不使用 ObservableObject
  • [ ] @State 擁有物件;let/@Bindable 接收它們
  • [ ] 已檢查遷移與可用性聲明是否符合當前平台支援,特別是剪貼簿與分享 API
  • [ ] 使用 NavigationStack(非 NavigationView
  • [ ] 使用 .task 修飾詞處理非同步資料載入
  • [ ] 大型集合使用 LazyVStack/LazyHStack
  • [ ] 使用穩定的 Identifiable ID(非陣列索引)
  • [ ] 提取使用分支/佈局、生命週期、依賴、預覽或父流程訊號;小型無狀態片段保留為計算屬性
  • [ ] Extensions 與 // MARK: 僅用於組織檔案
  • [ ] 僅結構重構保留行為;使用精簡的動作/生命週期方法,將可重複使用的邏輯保留在服務/模型中,然後建置並呈現有用的預覽
  • [ ] 預覽涵蓋有意義的已載入/載入中/空白/錯誤狀態,使用確定性測試環境與每個必要的環境依賴
  • [ ] 畫面 body 中無大量計算
  • [ ] 使用環境處理深度共享的狀態
  • [ ] 當語意樣式優於固定色彩時,使用 foregroundStyle(_:)
  • [ ] 重複樣式使用自訂 ViewModifier
  • [ ] 偏好使用 .sheet(item:) 而非 .sheet(isPresented:)
  • [ ] Sheet 擁有自己的動作並在內部呼叫 dismiss()
  • [ ] 遵循 MV 模式——無不必要的 ViewModel
  • [ ] 綁定 UI 的 @Observable 儲存體與 ViewModel 已隔離在 @MainActor
  • [ ] 跨並發邊界傳遞的模型類型為 Sendable
  • [ ] 除非需要特定值,否則省略 stack 的 spacing:(偏好自適應預設值)

參考資料