若要為篩選後的 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:)
診斷未出現或不如預期的轉場效果
依序檢查以下各層,讓每項觀察都能縮小原因範圍:
- 檢查篩選後的 ID。 調整動畫設定前,先確認條件式產生了預期記錄。
- 檢查識別資訊是否穩定且唯一。 保留的記錄必須維持原有識別碼,不同記錄也必須使用不同識別碼。從會變動的集合中移除計算式隨機 ID 和依位置產生的 ID。
- 檢查動畫觸發條件。 確認受監看的值在測試的更新中確實變更。數量相同不代表成員相同。
- 檢查轉場效果的位置。 將轉場效果附加到
ForEach建立的完整資料列,而不只是其中的文字。 - 檢查動畫範圍。 讓父堆疊在篩選條件變更時保持存在。避免讓其識別資訊取決於查詢。
- 檢查覆寫設定。 判斷轉場效果失效之前,先檢查
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
先使用四筆記錄的流程,在調整動畫效果前確認識別資訊和集合成員。確認相同數量的項目替換和空結果情況都符合預期後,再調整資料列設計和資料來源,同時維持識別碼穩定,並讓動畫觸發條件繫結到顯示的集合。
來源
- 動畫 — Apple
- ForEach — Apple
- Identifiable — Apple
- 解開 SwiftUI 的奧祕 — Apple
- VStack — Apple
- ScrollView — Apple
- App — Apple
- WindowGroup — Apple
- animation(_:value:) — Apple
- AnyTransition — Apple
- asymmetric(insertion:removal:) — Apple
- easeInOut(duration:) — Apple
- accessibilityReduceMotion — Apple
- LazyVStack — Apple
- NSHostingView — Apple
- TextKit — Apple