To animate items entering and leaving a filtered SwiftUI collection, keep each item’s identity stable, attach a transition to each row, and animate changes to the visible IDs. This tutorial builds a small Mac window with local synthetic labels and a working filter.

The reader task is specific: type into a filter field or change a category toggle, then see matching rows enter, nonmatching rows leave, and remaining rows settle into their new positions. Apple documents insertion and removal transitions as part of an animated state change. The implementation below applies that model to a collection. Animations

What makes filtering animate correctly?

Three separate responsibilities work together. The filter determines which records belong in the displayed collection. Identity tells SwiftUI which records continue across updates. The transition describes how a row appears or disappears, while the animation supplies timing. Adding a transition alone does not establish an animated state change. ForEach, Animations

Give every record a stored identifier that remains unchanged when filtering. Swift’s Identifiable protocol represents stable entity identity; ForEach accepts identifiable elements or an explicit identity key path. For this example, fixed integers identify four synthetic records. Identifiable, ForEach

Avoid identifying filtered rows by their current array positions. Removing an earlier record changes later positions, even though those records are still the same items. Likewise, a computed identifier that generates a fresh UUID on every access destroys continuity. Apple demonstrates both problems in its explanation of SwiftUI identity. Demystify SwiftUI

Use a regular VStack inside a ScrollView for this small example. Apple recommends a regular stack for a small number of subviews; it renders them together, whereas a lazy stack creates views as needed. That makes a four-row example easier to inspect before introducing deferred rendering. VStack, ScrollView

Build the complete Mac example

Create a macOS app project in Xcode using SwiftUI and Swift, and set its deployment target to macOS 13 or later for this tutorial. Replace the generated app entry file with the complete program below. Keep exactly one @main declaration in the target; Apple’s App documentation specifies a single entry point. App

The program uses only its own window and four local labels. It contains the model, sample data, filter controls, row presentation, animation trigger, and empty state. The code was inspected against the retrieved documentation but was not compiled or run here because the available environment has no Swift toolchain.

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

The app entry point presents the collection through WindowGroup, Apple’s scene container for identically structured windows. If another window is opened from that group, SwiftUI gives its view hierarchy independent state, including this example’s filter settings. WindowGroup

The filter performs a simple case-insensitive substring comparison by lowercasing both strings. That is an intentional choice for these synthetic English labels. The two conditions are combined: a record must match the text and, when enabled, the category toggle. The original array remains intact, so clearing the controls restores all four records.

Why watch the visible IDs instead of the result count?

The animation modifier watches displayedIDs, an ordered array of stable identifiers. Apple’s animation(_:value:) applies the supplied animation when its monitored, equatable value changes. Here that value represents the membership and order of the displayed collection. animation(_:value:)

Watching only the count would miss a useful case: changing the query from a to b replaces two rows with two different rows. The count remains two, but the IDs change from [1, 2] to [3, 4]. Watching the IDs also detects reordered records if sorting is added later. Conversely, a query change that leaves the same ordered IDs visible does not trigger this modifier.

The modifier sits on the persistent inner stack that contains both the rows and the empty-state message. The text field, toggle, and result count sit outside that scope. This arrangement keeps the filter controls separate from the collection animation and gives insertion and removal changes a common animated ancestor.

Each complete row receives its transition after padding and background styling. The insertion combines a short vertical offset with opacity; removal uses opacity alone. Apple documents combined(with:) as applying both transitions and asymmetric(insertion:removal:) as choosing different transitions for the two directions. AnyTransition, asymmetric(insertion:removal:)

The illustrative duration is 0.22 seconds. It is a tuning choice, not a documented optimum. Specifying easeInOut(duration:) makes the intended timing explicit; Apple defines its duration parameter in seconds. easeInOut(duration:)

For a button-driven filter, another supported approach is to change filter state inside withAnimation. Choose that when the action itself should define the animated update. The value-based modifier is convenient here because both controls feed the same derived collection. Apple documents both approaches. Animations

Check the behavior with a predictable filter sequence

Use the following sequence in the running app. The expected ID arrays are derived from the sample data and filter predicate; they are not reported execution results.

Action Expected visible IDs What to inspect
Start with an empty field and toggle off [1, 2, 3, 4] All four labels are present.
Enter a [1, 2] The two labels ending in B leave.
Replace a with b [3, 4] Membership changes even though the count stays two.
Clear the field, then enable Tiles only [1, 3] Dot rows leave; tile rows remain.
Enter z [] Remaining rows leave and No matching labels appears.
Clear the field and disable the toggle [1, 2, 3, 4] All records return in their original order.

The example reads accessibilityReduceMotion and supplies nil instead of an animation when that preference is enabled. Apple says interfaces should avoid large animations when this value is true; disabling this collection’s animation is the example’s implementation choice. The animation modifier documents that a nil animation does not animate the view. accessibilityReduceMotion, animation(_:value:)

Diagnose missing or unexpected transitions

Check these layers in order so that each observation narrows the cause:

  1. Inspect the filtered IDs. Verify that the predicate produces the expected records before adjusting animation settings.
  2. Inspect identity stability and uniqueness. A retained record must keep its identifier, and different records need different identifiers. Remove computed random IDs and position-based IDs from a changing collection.
  3. Inspect the animation trigger. Confirm that the monitored value changes for the update being tested. Equal counts do not imply equal membership.
  4. Inspect transition placement. Attach the transition to the complete row created by ForEach, rather than only to its inner text.
  5. Inspect the animated scope. Keep the parent stack present across filter changes. Avoid making its identity depend on the query.
  6. Inspect overrides. Check the Reduce Motion setting and any surrounding transaction or animation configuration before concluding that the transition is failing.

The identity checks follow Apple’s documented requirements for stable, unique identifiers. The remaining checks apply Apple’s separation between animated state updates and insertion or removal transitions to this example’s view structure. Demystify SwiftUI, Animations

Stable record IDs do not preserve a removed row’s local view state indefinitely. Apple explains that removing a view ends its lifetime. If an editable row must retain a draft after being filtered out, store that draft outside the row, associated with the record ID. This example avoids that issue by displaying immutable labels. Demystify SwiftUI

When should you use lazy stacks, AppKit, or TextKit?

For larger collections, consider a LazyVStack, which creates children only as needed for display. Keep filtering the data before passing it to ForEach; this yields one row per supplied element. Apple’s ForEach documentation explains that lazy containers benefit when each element produces a constant number of views. Recheck scrolling and transitions after changing containers. LazyVStack, ForEach

An existing AppKit application can embed this SwiftUI interface using NSHostingView, documented as an AppKit view that hosts a SwiftUI hierarchy. The collection’s identity, filtering, and transitions can remain in its SwiftUI root view. Hosting integrates the interface into the app’s own view hierarchy. NSHostingView

TextKit serves a different task: managing text storage and custom text layout, including through AppKit’s NSTextView. Nothing in this label-filtering example requires a custom text layout engine. Introduce TextKit when the row’s text editing or layout requirements call for it; keep collection membership and row transitions in the surrounding SwiftUI interface. TextKit

Start with the four-record sequence and confirm identity and membership before tuning motion. Once the same-count replacement and empty-result cases behave as intended, adapt the row design and data source while keeping identifiers stable and the animation trigger tied to the displayed collection.

Sources