Untuk menganimasikan item yang masuk dan keluar dari koleksi SwiftUI yang difilter, pertahankan identitas setiap item, tambahkan transisi pada setiap baris, dan animasikan perubahan pada ID yang terlihat. Tutorial ini membuat jendela Mac kecil dengan label sintetis lokal dan filter yang berfungsi.

Tugas pembaca cukup spesifik: ketik di kolom filter atau ubah tombol kategori, lalu lihat baris yang cocok muncul, baris yang tidak cocok menghilang, dan baris yang tersisa berpindah ke posisi barunya. Apple mendokumentasikan transisi penyisipan dan penghapusan sebagai bagian dari perubahan status yang dianimasikan. Implementasi di bawah menerapkan model tersebut pada sebuah koleksi. Animations

Apa yang membuat pemfilteran dianimasikan dengan benar?

Tiga tanggung jawab terpisah bekerja bersama. Filter menentukan catatan mana yang termasuk dalam koleksi yang ditampilkan. Identitas memberi tahu SwiftUI catatan mana yang tetap ada di antara pembaruan. Transisi menjelaskan bagaimana baris muncul atau menghilang, sedangkan animasi menentukan waktunya. Menambahkan transisi saja tidak membuat perubahan status menjadi teranimasi. ForEach, Animations

Berikan setiap catatan pengenal tersimpan yang tidak berubah saat pemfilteran. Protokol Swift Identifiable mewakili identitas entitas yang stabil; ForEach menerima elemen yang dapat diidentifikasi atau key path identitas eksplisit. Untuk contoh ini, bilangan bulat tetap mengidentifikasi empat catatan sintetis. Identifiable, ForEach

Hindari mengidentifikasi baris yang difilter berdasarkan posisi array saat ini. Menghapus catatan di awal akan mengubah posisi catatan berikutnya, meskipun catatan tersebut tetap merupakan item yang sama. Demikian pula, pengenal komputasi yang menghasilkan UUID baru setiap kali diakses akan merusak kesinambungan. Apple menunjukkan kedua masalah tersebut dalam penjelasannya tentang identitas SwiftUI. Demystify SwiftUI

Gunakan VStack biasa di dalam ScrollView untuk contoh kecil ini. Apple merekomendasikan stack biasa untuk sejumlah kecil subview; stack ini merender semuanya sekaligus, sedangkan stack lazy membuat view sesuai kebutuhan. Dengan begitu, contoh empat baris ini lebih mudah ditinjau sebelum memperkenalkan rendering tertunda. VStack, ScrollView

Buat contoh Mac lengkap

Buat proyek app macOS di Xcode menggunakan SwiftUI dan Swift, lalu tetapkan target deployment ke macOS 13 atau lebih baru untuk tutorial ini. Ganti file entry app yang dibuat otomatis dengan program lengkap di bawah. Pertahankan tepat satu deklarasi @main di target; dokumentasi App Apple menetapkan satu titik masuk. App

Program ini hanya menggunakan jendelanya sendiri dan empat label lokal. Program ini mencakup model, data contoh, kontrol filter, tampilan baris, pemicu animasi, dan status kosong. Kode ini telah ditinjau berdasarkan dokumentasi yang diambil, tetapi tidak dikompilasi atau dijalankan di sini karena lingkungan yang tersedia tidak memiliki toolchain 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)
    }
}

Titik masuk app menampilkan koleksi melalui WindowGroup, kontainer scene Apple untuk jendela-jendela dengan struktur identik. Jika jendela lain dibuka dari grup itu, SwiftUI memberikan status independen pada hierarki view-nya, termasuk pengaturan filter dalam contoh ini. WindowGroup

Filter melakukan pencocokan substring sederhana tanpa membedakan huruf besar dan kecil dengan mengubah kedua string menjadi huruf kecil. Pilihan ini memang disengaja untuk label bahasa Inggris sintetis tersebut. Kedua kondisi digabungkan: catatan harus cocok dengan teks dan, jika diaktifkan, tombol kategori. Array asli tetap utuh, sehingga mengosongkan kontrol akan memulihkan keempat catatan.

Mengapa memantau ID yang terlihat, bukan jumlah hasil?

Modifier animasi memantau displayedIDs, yaitu array terurut berisi pengenal stabil. animation(_:value:) dari Apple menerapkan animasi yang diberikan ketika nilai terpantau yang dapat dibandingkan berubah. Di sini, nilai tersebut mewakili keanggotaan dan urutan koleksi yang ditampilkan. animation(_:value:)

Memantau jumlah saja akan melewatkan kasus yang berguna: mengganti kueri dari a menjadi b mengganti dua baris dengan dua baris lain. Jumlahnya tetap dua, tetapi ID berubah dari [1, 2] menjadi [3, 4]. Memantau ID juga mendeteksi perubahan urutan catatan jika penyortiran ditambahkan nanti. Sebaliknya, perubahan kueri yang mempertahankan ID terlihat dalam urutan yang sama tidak akan memicu modifier ini.

Modifier ditempatkan pada stack dalam yang tetap ada dan memuat baris serta pesan status kosong. Kolom teks, tombol, dan jumlah hasil berada di luar cakupan tersebut. Susunan ini memisahkan kontrol filter dari animasi koleksi serta memberikan perubahan penyisipan dan penghapusan satu leluhur bersama yang dianimasikan.

Setiap baris lengkap menerima transisinya setelah padding dan gaya latar diterapkan. Penyisipan menggabungkan offset vertikal singkat dengan opasitas; penghapusan hanya menggunakan opasitas. Apple mendokumentasikan combined(with:) sebagai penerapan kedua transisi dan asymmetric(insertion:removal:) sebagai pemilihan transisi berbeda untuk kedua arah. AnyTransition, asymmetric(insertion:removal:)

Durasi ilustratifnya adalah 0,22 detik. Ini adalah pilihan penyesuaian, bukan nilai optimal yang didokumentasikan. Menentukan easeInOut(duration:) membuat waktu yang dimaksud menjadi eksplisit; Apple mendefinisikan parameter durasinya dalam satuan detik. easeInOut(duration:)

Untuk filter yang digerakkan tombol, pendekatan lain yang didukung adalah mengubah status filter di dalam withAnimation. Pilih cara ini jika tindakan itu sendiri harus menentukan pembaruan yang dianimasikan. Modifier berbasis nilai praktis di sini karena kedua kontrol memengaruhi koleksi turunan yang sama. Apple mendokumentasikan kedua pendekatan tersebut. Animations

Periksa perilaku dengan urutan filter yang dapat diprediksi

Gunakan urutan berikut di app yang sedang berjalan. Array ID yang diharapkan diturunkan dari data contoh dan predikat filter; ini bukan hasil eksekusi yang dilaporkan.

Tindakan ID terlihat yang diharapkan Hal yang perlu diperiksa
Mulai dengan kolom kosong dan tombol nonaktif [1, 2, 3, 4] Keempat label terlihat.
Masukkan a [1, 2] Dua label yang berakhiran B menghilang.
Ganti a dengan b [3, 4] Keanggotaan berubah meskipun jumlahnya tetap dua.
Kosongkan kolom, lalu aktifkan Tiles only [1, 3] Baris dot menghilang; baris tile tetap ada.
Masukkan z [] Baris yang tersisa menghilang dan No matching labels muncul.
Kosongkan kolom dan nonaktifkan tombol [1, 2, 3, 4] Semua catatan kembali dalam urutan aslinya.

Contoh ini membaca accessibilityReduceMotion dan memberikan nil sebagai pengganti animasi jika preferensi tersebut diaktifkan. Apple menyatakan bahwa antarmuka sebaiknya menghindari animasi besar saat nilai ini bernilai true; menonaktifkan animasi koleksi ini merupakan pilihan implementasi dalam contoh. Modifier animasi mendokumentasikan bahwa animasi nil tidak menganimasikan view. accessibilityReduceMotion, animation(_:value:)

Diagnosis transisi yang hilang atau tidak terduga

Periksa lapisan berikut secara berurutan agar setiap pengamatan mempersempit penyebabnya:

  1. Periksa ID hasil filter. Pastikan predikat menghasilkan catatan yang diharapkan sebelum menyesuaikan pengaturan animasi.
  2. Periksa kestabilan dan keunikan identitas. Catatan yang dipertahankan harus tetap memakai pengenalnya, dan catatan yang berbeda harus memiliki pengenal yang berbeda. Hapus ID acak komputasi dan ID berbasis posisi dari koleksi yang berubah.
  3. Periksa pemicu animasi. Pastikan nilai yang dipantau berubah untuk pembaruan yang diuji. Jumlah yang sama tidak berarti keanggotaannya sama.
  4. Periksa penempatan transisi. Tambahkan transisi ke baris lengkap yang dibuat oleh ForEach, bukan hanya ke teks di dalamnya.
  5. Periksa cakupan animasi. Pertahankan stack induk selama perubahan filter. Hindari membuat identitasnya bergantung pada kueri.
  6. Periksa penggantian konfigurasi. Periksa pengaturan Reduce Motion serta konfigurasi transaksi atau animasi di sekitarnya sebelum menyimpulkan bahwa transisi gagal.

Pemeriksaan identitas mengikuti persyaratan Apple yang terdokumentasi untuk pengenal yang stabil dan unik. Pemeriksaan lainnya menerapkan pemisahan Apple antara pembaruan status yang dianimasikan dan transisi penyisipan atau penghapusan pada struktur view contoh ini. Demystify SwiftUI, Animations

ID catatan yang stabil tidak mempertahankan status view lokal dari baris yang dihapus untuk selamanya. Apple menjelaskan bahwa penghapusan view mengakhiri masa hidupnya. Jika baris yang dapat diedit harus mempertahankan draf setelah difilter, simpan draf tersebut di luar baris dan kaitkan dengan ID catatan. Contoh ini menghindari masalah itu dengan menampilkan label yang tidak dapat diubah. Demystify SwiftUI

Kapan sebaiknya menggunakan stack lazy, AppKit, atau TextKit?

Untuk koleksi yang lebih besar, pertimbangkan LazyVStack, yang hanya membuat anak view saat diperlukan untuk ditampilkan. Tetap lakukan pemfilteran data sebelum meneruskannya ke ForEach; cara ini menghasilkan satu baris untuk setiap elemen yang diberikan. Dokumentasi ForEach Apple menjelaskan bahwa kontainer lazy lebih efektif ketika setiap elemen menghasilkan jumlah view yang konstan. Periksa kembali pengguliran dan transisi setelah mengganti kontainer. LazyVStack, ForEach

App AppKit yang sudah ada dapat menyematkan antarmuka SwiftUI ini menggunakan NSHostingView, yang didokumentasikan sebagai view AppKit yang menampung hierarki SwiftUI. Identitas, pemfilteran, dan transisi koleksi dapat tetap berada di view root SwiftUI-nya. Hosting mengintegrasikan antarmuka ke dalam hierarki view milik app. NSHostingView

TextKit menangani tugas yang berbeda: mengelola penyimpanan teks dan tata letak teks kustom, termasuk melalui NSTextView milik AppKit. Contoh pemfilteran label ini tidak memerlukan mesin tata letak teks kustom. Gunakan TextKit saat kebutuhan pengeditan teks atau tata letak baris memerlukannya; pertahankan keanggotaan koleksi dan transisi baris dalam antarmuka SwiftUI di sekitarnya. TextKit

Mulailah dengan rangkaian empat catatan dan pastikan identitas serta keanggotaannya benar sebelum menyesuaikan gerakan. Setelah penggantian dengan jumlah yang sama dan kasus hasil kosong berperilaku sesuai harapan, sesuaikan desain baris dan sumber data sambil mempertahankan pengenal yang stabil dan pemicu animasi yang terkait dengan koleksi yang ditampilkan.

Sumber