若要為篩選後的 SwiftUI 集合中加入和移除的項目製作動畫,請讓每個項目的識別資訊保持穩定、為每個資料列附加轉場效果,並為可見 ID 的變化加入動畫。本教學會建立一個小型 Mac 視窗,使用本機合成標籤和可運作的篩選器。

讀者要完成的任務很明確:在篩選欄位中輸入文字或變更類別切換鈕,接著觀察符合條件的資料列加入、不符合的資料列移除,而保留的資料列則移至新位置。Apple 將插入和移除轉場效果列為動畫狀態變更的一部分。以下實作會將這個模型套用到集合。動畫

篩選要如何正確觸發動畫?

三項不同的職責會共同運作。篩選器決定哪些記錄屬於顯示的集合。識別資訊會告訴 SwiftUI 哪些記錄在更新前後仍然存在。轉場效果描述資料列如何出現或消失,而動畫則提供時間設定。單獨加入轉場效果並不會建立動畫狀態變更。ForEach、動畫

為每筆記錄指定一個儲存的識別碼,並在篩選時維持不變。Swift 的 Identifiable 協定代表穩定的實體識別資訊;ForEach 可接受符合可識別條件的元素,或明確指定的識別鍵路徑。這個範例使用固定整數識別四筆合成記錄。Identifiable、ForEach

不要用目前陣列中的位置來識別篩選後的資料列。移除較前面的記錄會改變後續記錄的位置,即使那些記錄仍是相同的項目。同樣地,每次存取時都產生新 UUID 的計算式識別碼,也會破壞連續性。Apple 在說明 SwiftUI 識別資訊時展示了這兩個問題。解開 SwiftUI 的奧祕

這個小型範例會在 ScrollView 中使用一般的 VStack。Apple 建議子視圖數量不多時使用一般堆疊;它會一併呈現所有子視圖,而延遲堆疊則會視需要建立視圖。先用四個資料列的範例檢視會更容易,再導入延遲呈現。VStack、ScrollView

建立完整的 Mac 範例

在 Xcode 中使用 SwiftUI 和 Swift 建立 macOS App 專案,並將部署目標設為 macOS 13 或更新版本,以配合本教學。將產生的 App 進入點檔案替換為下方完整程式。目標中只保留一個 @main 宣告;Apple 的 App 文件規定只能有一個進入點。App

程式只使用自己的視窗和四個本機標籤。內容包括模型、範例資料、篩選控制項、資料列呈現、動畫觸發條件和空狀態。程式碼已根據取得的文件檢視,但此處未進行編譯或執行,因為目前環境沒有 Swift 工具鏈。

import SwiftUI

@main
struct FilteredCollectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            FilteredCollectionView()
        }
    }
}

private struct DemoItem: Identifiable {
    let id: Int
    let title: String
    let isTile: Bool
}

private struct FilteredCollectionView: View {
    @Environment(\.accessibilityReduceMotion)
    private var reduceMotion

    @State private var query = ""
    @State private var tilesOnly = false

    private let items: [DemoItem] = [
        DemoItem(id: 1, title: "Tile A", isTile: true),
        DemoItem(id: 2, title: "Dot A", isTile: false),
        DemoItem(id: 3, title: "Tile B", isTile: true),
        DemoItem(id: 4, title: "Dot B", isTile: false)
    ]

    private var visibleItems: [DemoItem] {
        let needle = query.lowercased()
        return items.filter { item in
            let matchesText = needle.isEmpty
                || item.title.lowercased().contains(needle)
            let matchesCategory = !tilesOnly || item.isTile
            return matchesText && matchesCategory
        }
    }

    private var rowTransition: AnyTransition {
        .asymmetric(
            insertion: AnyTransition.offset(x: 0, y: 8)
                .combined(with: .opacity),
            removal: .opacity
        )
    }

    var body: some View {
        let displayedItems = visibleItems
        let displayedIDs = displayedItems.map(\.id)

        VStack(alignment: .leading, spacing: 12) {
            Text("Label Collection")
                .font(.title2)

            TextField("Filter labels", text: $query)
                .textFieldStyle(.roundedBorder)

            Toggle("Tiles only", isOn: $tilesOnly)

            Text("\(displayedItems.count) of \(items.count) labels")
                .font(.caption)

            ScrollView {
                VStack(alignment: .leading, spacing: 8) {
                    ForEach(displayedItems) { item in
                        HStack {
                            Text(item.title)
                            Spacer()
                            Text(item.isTile ? "Tile" : "Dot")
                                .foregroundStyle(.secondary)
                        }
                        .padding(12)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                                .fill(Color.blue.opacity(0.10))
                        )
                        .transition(rowTransition)
                    }

                    if displayedItems.isEmpty {
                        Text("No matching labels")
                            .foregroundStyle(.secondary)
                            .padding(12)
                            .transition(.opacity)
                    }
                }
                .padding(12)
                .animation(
                    reduceMotion
                        ? nil
                        : .easeInOut(duration: 0.22),
                    value: displayedIDs
                )
            }
            .frame(height: 300)
        }
        .padding(20)
        .frame(minWidth: 360)
    }
}

App 進入點會透過 WindowGroup 呈現集合;這是 Apple 提供的場景容器,用於建立結構相同的視窗。若從該群組開啟另一個視窗,SwiftUI 會為其視圖階層提供獨立狀態,包括這個範例的篩選設定。WindowGroup

篩選器會將兩個字串都轉成小寫,進行簡單的不區分大小寫子字串比對。這是針對這些英文合成標籤所做的刻意選擇。兩個條件會合併判斷:記錄必須符合文字條件,若啟用類別切換鈕,也必須符合該條件。原始陣列會保持完整,因此清除控制項後便會還原全部四筆記錄。

為什麼要監看可見 ID,而不是結果數量?

動畫修飾器會監看 displayedIDs,也就是由穩定識別碼組成的有序陣列。Apple 的 animation(_:value:) 會在受監看的可比較值變更時,套用指定的動畫。此處的值代表顯示集合中的成員及其順序。animation(_:value:)

只監看數量會漏掉一種實用情況:將查詢從 a 改為 b 時,兩個資料列會被另外兩個不同資料列取代。數量仍是二,但 ID 會從 [1, 2] 變成 [3, 4]。如果之後加入排序,監看 ID 也能偵測記錄重新排序的情況。反過來說,如果查詢變更後可見 ID 的順序仍然相同,就不會觸發這個修飾器。

修飾器會套用在常駐的內部堆疊上,該堆疊同時包含資料列和空狀態訊息。文字欄位、切換鈕和結果數量則位於其範圍之外。這種安排可讓篩選控制項與集合動畫分開,並讓插入和移除變更共用同一個動畫祖先。

每個完整資料列在套用內距和背景樣式後,會取得自己的轉場效果。插入時會結合短暫的垂直位移和不透明度;移除時則只使用不透明度。Apple 說明 combined(with:) 會同時套用兩種轉場效果,而 asymmetric(insertion:removal:) 可為兩個方向選擇不同的轉場效果。AnyTransition、asymmetric(insertion:removal:)

範例動畫時長為 0.22 秒。這是調整選擇,不是文件所述的最佳值。指定 easeInOut(duration:) 可明確設定預期時長;Apple 將其 duration 參數定義為秒。easeInOut(duration:)

若篩選器由按鈕觸發,另一種支援的做法是在 withAnimation 中變更篩選狀態。若希望由動作本身定義動畫更新,請採用這種方式。此處使用值變更修飾器較方便,因為兩個控制項都會影響同一個衍生集合。Apple 兩種做法都有說明。動畫

使用可預測的篩選流程檢查行為

在執行中的 App 中依照以下流程操作。預期的 ID 陣列是根據範例資料和篩選條件推導而來,並非實際執行結果。

操作 預期可見 ID 檢查項目
欄位留空且關閉切換鈕 [1, 2, 3, 4] 四個標籤都會顯示。
輸入 a [1, 2] 兩個以 B 結尾的標籤會移除。
將 a 替換成 b [3, 4] 即使數量維持二,成員也會變更。
清除欄位,再啟用 Tiles only [1, 3] 點狀列會移除;磚塊列會保留。
輸入 z [] 剩餘資料列會移除,並顯示 No matching labels。
清除欄位並關閉切換鈕 [1, 2, 3, 4] 所有記錄都會依原始順序返回。

此範例會讀取 accessibilityReduceMotion,並在啟用該偏好設定時提供 nil,而不使用動畫。Apple 表示此值為 true 時,介面應避免大型動畫;停用此集合的動畫是本範例的實作選擇。動畫修飾器文件說明,nil 動畫不會讓視圖產生動畫效果。accessibilityReduceMotion、animation(_:value:)

診斷未出現或不如預期的轉場效果

依序檢查以下各層,讓每項觀察都能縮小原因範圍:

  1. 檢查篩選後的 ID。 調整動畫設定前,先確認條件式產生了預期記錄。
  2. 檢查識別資訊是否穩定且唯一。 保留的記錄必須維持原有識別碼,不同記錄也必須使用不同識別碼。從會變動的集合中移除計算式隨機 ID 和依位置產生的 ID。
  3. 檢查動畫觸發條件。 確認受監看的值在測試的更新中確實變更。數量相同不代表成員相同。
  4. 檢查轉場效果的位置。 將轉場效果附加到 ForEach 建立的完整資料列,而不只是其中的文字。
  5. 檢查動畫範圍。 讓父堆疊在篩選條件變更時保持存在。避免讓其識別資訊取決於查詢。
  6. 檢查覆寫設定。 判斷轉場效果失效之前,先檢查 Reduce Motion 設定,以及周圍的交易或動畫設定。

識別資訊檢查遵循 Apple 對穩定且唯一識別碼的文件要求。其餘檢查則將 Apple 對動畫狀態更新與插入或移除轉場效果的區分,套用到此範例的視圖結構。解開 SwiftUI 的奧祕、動畫

穩定的記錄 ID 不會讓已移除資料列的區域視圖狀態永久保留。Apple 說明,移除視圖會結束其生命週期。如果可編輯資料列在篩選移除後仍須保留草稿,請將草稿存放在資料列之外,並以記錄 ID 為索引。此範例顯示不可變標籤,因此不會遇到這個問題。解開 SwiftUI 的奧祕

何時該使用延遲堆疊、AppKit 或 TextKit?

若集合較大,可考慮使用 LazyVStack,它只會在需要顯示時建立子視圖。將資料篩選後再傳給 ForEach;如此每個傳入元素都會對應一個資料列。Apple 的 ForEach 文件說明,當每個元素產生固定數量的視圖時,延遲容器能發揮效益。更換容器後,請重新檢查捲動和轉場效果。LazyVStack、ForEach

既有的 AppKit 應用程式可使用 NSHostingView 嵌入這個 SwiftUI 介面;文件將它描述為承載 SwiftUI 階層的 AppKit 視圖。集合的識別資訊、篩選和轉場效果仍可留在 SwiftUI 根視圖中。承載視圖會將介面整合到 App 自己的視圖階層中。NSHostingView

TextKit 用於不同的任務:管理文字儲存和自訂文字排版,包括透過 AppKit 的 NSTextView 使用。這個標籤篩選範例不需要自訂文字排版引擎。當資料列的文字編輯或排版需求需要時,再導入 TextKit;集合成員及資料列轉場效果則留在外層 SwiftUI 介面中。TextKit

先使用四筆記錄的流程,在調整動畫效果前確認識別資訊和集合成員。確認相同數量的項目替換和空結果情況都符合預期後,再調整資料列設計和資料來源,同時維持識別碼穩定,並讓動畫觸發條件繫結到顯示的集合。

來源