Чтобы анимировать появление и исчезновение элементов в отфильтрованной коллекции SwiftUI, сохраняйте неизменной идентичность каждого элемента, назначьте переход каждой строке и анимируйте изменения видимых идентификаторов. В этом руководстве создаётся небольшое окно Mac с локальными условными метками и работающим фильтром.

Задача пользователя конкретна: ввести текст в поле фильтра или изменить переключатель категории, а затем увидеть, как подходящие строки появляются, неподходящие исчезают, а оставшиеся занимают новые позиции. Apple описывает переходы вставки и удаления как часть анимированного изменения состояния. Приведённая ниже реализация применяет эту модель к коллекции. Animations

Что нужно для правильной анимации фильтрации?

Вместе работают три отдельные составляющие. Фильтр определяет, какие записи входят в отображаемую коллекцию. Идентичность сообщает SwiftUI, какие записи остаются при обновлениях. Переход описывает появление или исчезновение строки, а анимация задаёт время выполнения. Одного перехода недостаточно, чтобы изменение состояния стало анимированным. ForEach, Animations

Назначьте каждой записи сохранённый идентификатор, который не меняется при фильтрации. Протокол Swift Identifiable позволяет представить стабильную идентичность сущности; ForEach принимает элементы с таким протоколом или явный путь к свойству идентификатора. В этом примере четыре условные записи обозначены фиксированными целыми числами. Identifiable, ForEach

Не используйте текущие позиции элементов массива в качестве идентификаторов отфильтрованных строк. Удаление более ранней записи меняет позиции последующих, хотя сами записи остаются теми же. Вычисляемый идентификатор, который при каждом обращении создаёт новый UUID, тоже нарушает непрерывность. Apple демонстрирует обе проблемы в объяснении идентичности в SwiftUI. Demystify SwiftUI

Для этого небольшого примера используйте обычный VStack внутри ScrollView. Apple рекомендует обычный стек для небольшого числа вложенных представлений: он отображает их все сразу, тогда как ленивый стек создаёт представления по мере необходимости. Поэтому пример с четырьмя строками проще изучить до перехода к отложенному отображению. VStack, ScrollView

Создание полного примера для Mac

Создайте в Xcode проект приложения для macOS на SwiftUI и Swift и задайте для этого руководства целевую версию macOS 13 или новее. Замените сгенерированный файл точки входа приложения полной программой ниже. В целевом объекте должна быть ровно одна декларация @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)
    }
}

Точка входа приложения отображает коллекцию с помощью WindowGroup — контейнера сцен Apple для окон с одинаковой структурой. Если из этой группы открыть другое окно, SwiftUI создаст для его иерархии представлений независимое состояние, включая настройки фильтра из этого примера. WindowGroup

Фильтр выполняет простое сравнение подстрок без учёта регистра: для этого обе строки переводятся в нижний регистр. Это сделанный намеренно выбор для условных английских меток. Два условия объединяются: запись должна соответствовать тексту, а при включённом переключателе — ещё и категории. Исходный массив остаётся неизменным, поэтому после сброса элементов управления восстанавливаются все четыре записи.

Почему нужно отслеживать видимые идентификаторы, а не число результатов?

Модификатор анимации отслеживает displayedIDs — упорядоченный массив стабильных идентификаторов. Apple описывает, что animation(_:value:) применяет указанную анимацию при изменении отслеживаемого эквивалентного значения. Здесь это значение отражает состав и порядок отображаемой коллекции. animation(_:value:)

Отслеживание только количества пропустило бы полезный случай: при смене запроса с a на b две строки заменяются двумя другими. Количество остаётся равным двум, но идентификаторы меняются с [1, 2] на [3, 4]. Отслеживание идентификаторов также обнаружит перестановку записей, если позже добавить сортировку. И наоборот, если после смены запроса видимый упорядоченный список идентификаторов остаётся прежним, этот модификатор не запускается.

Модификатор установлен на постоянном внутреннем стеке, содержащем и строки, и сообщение о пустом результате. Текстовое поле, переключатель и счётчик результатов находятся за его пределами. Такая структура отделяет элементы управления фильтром от анимации коллекции и задаёт общий анимируемый родитель для изменений вставки и удаления.

Переход назначен каждой строке целиком после настройки отступов и фона. При вставке сочетаются небольшое смещение по вертикали и прозрачность; при удалении меняется только прозрачность. Apple описывает combined(with:) как применение обоих переходов, а asymmetric(insertion:removal:) — как выбор разных переходов для двух направлений. AnyTransition, asymmetric(insertion:removal:)

В примере используется длительность 0.22 секунды. Это выбранное значение, а не документально подтверждённый оптимум. Указание easeInOut(duration:) явно задаёт нужный темп; Apple определяет параметр длительности в секундах. easeInOut(duration:)

Для фильтра, управляемого кнопкой, подойдёт и другой поддерживаемый подход: менять состояние фильтра внутри withAnimation. Выберите его, если анимированное обновление должно определяться самим действием. Здесь удобен модификатор на основе значения, поскольку оба элемента управления влияют на одну и ту же вычисляемую коллекцию. Apple описывает оба подхода. Animations

Проверьте поведение с предсказуемой последовательностью фильтров

В запущенном приложении выполните следующую последовательность. Ожидаемые массивы идентификаторов выведены из образцов данных и предиката фильтрации; это не результаты фактического выполнения.

Действие Ожидаемые видимые идентификаторы Что проверить
В начале оставьте поле пустым, переключатель выключенным [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. Проверьте отфильтрованные идентификаторы. Убедитесь, что предикат возвращает ожидаемые записи, прежде чем менять параметры анимации.
  2. Проверьте стабильность и уникальность идентификаторов. У оставшейся записи идентификатор должен сохраняться, а разные записи должны иметь разные идентификаторы. Удалите случайные вычисляемые идентификаторы и идентификаторы, основанные на позициях в изменяющейся коллекции.
  3. Проверьте запуск анимации. Убедитесь, что отслеживаемое значение меняется при проверяемом обновлении. Равное количество не означает одинаковый состав.
  4. Проверьте расположение перехода. Назначьте его всей строке, созданной ForEach, а не только вложенному в неё тексту.
  5. Проверьте область действия анимации. Родительский стек должен сохраняться при изменениях фильтра. Не ставьте его идентичность в зависимость от запроса.
  6. Проверьте переопределения. Прежде чем считать, что переход не работает, проверьте настройку Reduce Motion и внешние параметры транзакций или анимации.

Проверки идентичности основаны на документированных Apple требованиях к стабильным уникальным идентификаторам. Остальные пункты применяют к структуре представлений этого примера разделение Apple между анимированными изменениями состояния и переходами вставки или удаления. Demystify SwiftUI, Animations

Стабильные идентификаторы записей не сохраняют локальное состояние представления удалённой строки на неопределённый срок. Apple объясняет, что при удалении представления заканчивается срок его жизни. Если редактируемая строка должна сохранять черновик после фильтрации, храните этот черновик вне строки, связав его с идентификатором записи. В этом примере такой проблемы нет, поскольку метки неизменяемы. Demystify SwiftUI

Когда использовать ленивые стеки, AppKit или TextKit?

Для больших коллекций рассмотрите LazyVStack, который создаёт дочерние представления только по мере необходимости для отображения. Продолжайте фильтровать данные до передачи в ForEach: тогда будет создана одна строка для каждого переданного элемента. В документации Apple для ForEach объясняется, что ленивые контейнеры эффективны, когда для каждого элемента создаётся постоянное число представлений. После смены контейнера заново проверьте прокрутку и переходы. LazyVStack, ForEach

Существующее приложение AppKit может встроить этот интерфейс SwiftUI с помощью NSHostingView, который в документации описан как представление AppKit, размещающее иерархию SwiftUI. Идентичность, фильтрация и переходы коллекции могут остаться в её корневом представлении SwiftUI. Встраивание интегрирует интерфейс в собственную иерархию представлений приложения. NSHostingView

TextKit предназначен для другой задачи: управления хранилищем текста и пользовательской компоновки текста, в том числе через NSTextView в AppKit. Для этого примера фильтрации меток собственный движок компоновки текста не нужен. Добавляйте TextKit, если этого требуют редактирование или компоновка текста в строке; состав коллекции и переходы строк оставьте окружающему интерфейсу SwiftUI. TextKit

Начните с последовательности из четырёх записей и проверьте идентичность и состав, прежде чем настраивать движение. Убедившись, что замена при неизменном количестве и пустой результат работают как задумано, адаптируйте оформление строк и источник данных, сохранив стабильные идентификаторы и привязав запуск анимации к отображаемой коллекции.

Источники