Untuk menganimasikan ekspansi bertingkat di app Mac Anda, mulailah dengan DisclosureGroup dan binding ekspansi yang dianimasikan. Jika Anda memerlukan kendali presisi atas tinggi yang mengembang, gunakan tata letak kustom di dalam tampilan gulir; transisi konten saja tidak menentukan geometri tersebut.
Tutorial ini membahas tugas tertentu: mengembangkan hierarki kecil item agar isinya terlihat dan item di bawahnya bergeser mengikuti perubahan tinggi. Contoh-contohnya menggunakan label sintetis dan beroperasi sepenuhnya di dalam jendela app itu sendiri.
Mengapa konten memudar tanpa terlihat mengembang?
Pisahkan tiga hal saat mendiagnosis hasilnya: status ekspansi, tampilan konten yang disisipkan, dan ruang yang dialokasikan oleh kontainer. Apple mendokumentasikan withAnimation, modifier animasi berbasis nilai, dan binding yang dianimasikan sebagai cara untuk menganimasikan perubahan status. Transisi didokumentasikan secara terpisah sebagai efek yang digunakan saat tampilan masuk atau keluar dari hierarki. Animasi
Transisi opasitas mengubah transparansi. Transisi gerakan mengubah posisi tampilan yang masuk atau keluar relatif terhadap tepi. Tak satu pun API tersebut, dengan sendirinya, menentukan bagaimana daftar di sekitarnya harus menginterpolasi tinggi baris. Karena itu, menambahkan transisi gerakan dapat mengubah tampilan saat masuk tanpa mengatasi perilaku tata letak tertentu yang Anda inginkan. move(edge:)
Anggap ini sebagai pembedaan diagnostik, bukan bukti atas implementasi internal tertentu: jika detail memudar sementara baris berikutnya meloncat, periksa geometri kontainer secara terpisah dari transisi detail. API List yang didokumentasikan menjelaskan kontainer baris dengan pilihan seleksi opsional; API tersebut tidak menjanjikan kurva ekspansi tertentu untuk setiap susunan bertingkat dan gaya daftar. List
Mulailah dengan grup pengungkapan bertingkat bawaan
Untuk hierarki Mac konvensional, gunakan DisclosureGroup sebelum menerapkan kontrol pengungkapan sendiri. Kontrol ini menyediakan ekspansi melalui binding Boolean, dan dokumentasi Apple secara eksplisit menunjukkan satu grup pengungkapan di dalam grup lainnya. API ini tersedia di macOS 11 dan versi lebih baru. DisclosureGroup
Buat proyek app macOS di Xcode menggunakan SwiftUI. Ganti deklarasi app yang dihasilkan dengan program lengkap berikut, dan pastikan target hanya memiliki satu deklarasi @main. Program ini menyertakan semua konten contoh dan kedua status ekspansi.
import SwiftUI
@main
struct NestedDisclosureDemoApp: App {
var body: some Scene {
WindowGroup {
NativeHierarchyView()
.frame(minWidth: 360, minHeight: 280)
}
}
}
struct NativeHierarchyView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var collectionExpanded = false
@State private var shapesExpanded = false
private var expansionAnimation: Animation? {
reduceMotion ? nil : .easeInOut(duration: 0.25)
}
var body: some View {
List {
DisclosureGroup(
"Collection",
isExpanded: $collectionExpanded.animation(expansionAnimation)
) {
DisclosureGroup(
"Shapes",
isExpanded: $shapesExpanded.animation(expansionAnimation)
) {
Text("Circle")
Text("Square")
}
Text("Palette")
}
Text("Following item")
}
}
}
Metode animation pada binding meminta animasi saat nilainya berubah. Klik Collection, buka Shapes, lalu tutup dan buka kembali Collection. Status yang disimpan secara terpisah memungkinkan contoh ini mengingat apakah Shapes terbuka saat induknya ditutup. Ini adalah status dalam memori untuk tampilan saat ini, bukan status yang bertahan setelah app diluncurkan kembali. Binding yang dianimasikan adalah salah satu cara animasi yang didokumentasikan Apple. Animasi
Contoh ini menggunakan kurva easing seperempat detik sebagai pilihan desain ilustratif. Contoh meneruskan nil saat preferensi sistem Reduce Motion diaktifkan. Apple menyediakan preferensi itu melalui accessibilityReduceMotion dan menyarankan untuk menghindari animasi besar saat nilainya true. accessibilityReduceMotion
Evaluasi versi bawaan pada setiap rilis macOS dan gaya daftar yang didukung app Anda. Jika perilakunya memenuhi kebutuhan, gunakan versi itu. Jika hanya tampilan pengungkapan yang perlu disesuaikan, DisclosureGroupStyle menyediakan titik kustomisasi yang didukung di macOS 13 dan versi lebih baru. Gaya kustom tetap menyusun tampilan; dokumentasinya tidak menjanjikan kendali sembarang atas animasi baris daftar di sekitarnya. DisclosureGroupStyle
Pilih kontainer sesuai hasil yang dibutuhkan
Gunakan panduan keputusan ini sebelum menambahkan modifier lain. Panduan ini membedakan interaksi baris bawaan dari pengungkapan yang dikendalikan secara eksplisit.
| Hasil yang dibutuhkan | Titik awal | Hal yang perlu diverifikasi |
|---|---|---|
| Baris yang dapat dibuka secara konvensional dengan seleksi daftar | List dan DisclosureGroup |
Perilaku ekspansi pada versi macOS dan gaya daftar yang sebenarnya |
| Tampilan label atau kontrol pengungkapan yang berbeda | DisclosureGroupStyle |
Apakah kontainer di sekitarnya tetap menghasilkan geometri yang diinginkan |
| Tinggi pengungkapan yang ditentukan secara presisi untuk hierarki kecil | ScrollView, VStack, dan Layout yang dapat dianimasikan |
Pengubahan ukuran bertingkat, pemotongan, dan perilaku kontrol |
| Outline AppKit yang sudah ada dengan kolom dan delegasi | NSOutlineView |
API ekspansi dan perilaku sumber datanya sendiri |
Pendekatan kustom di bawah ini sengaja menggunakan stack di dalam tampilan gulir. Pendekatan ini menyediakan tombol pengungkap dan geometri sendiri, sehingga Anda juga perlu menerapkan seleksi daftar atau perintah baris apa pun yang dibutuhkan antarmuka Anda. Apple mendokumentasikan seleksi sebagai kemampuan List; beralih kontainer mengubah titik awal tersebut. List
Animasikan tinggi konten yang diungkap dengan tata letak kustom
Untuk macOS 13 dan versi lebih baru, protokol Layout memungkinkan kontainer menghitung ukuran dan menempatkan turunannya. Protokol ini juga mewarisi Animatable. Inilah blok pembangun terdokumentasi untuk menjadikan besaran ekspansi sebagai angka yang diinterpolasi, alih-alih hanya mengandalkan efek penyisipan. Layout
Implementasi orisinal berikut menggunakan satu stack turunan untuk setiap pengungkapan. Implementasi ini mengukur stack tersebut pada lebar yang tersedia dengan tinggi yang tidak ditentukan, lalu melaporkan sebagian dari tinggi yang terukur. Turunan tetap diposisikan di bagian atas sementara area yang terlihat berubah. Apple mendokumentasikan pengukuran subview dengan proposal dan penempatannya dengan proposal yang sesuai. sizeThatFits(_:), place(at:anchor:proposal:)
Gunakan ini sebagai program lengkap alternatif, untuk menggantikan contoh sebelumnya. Atur target deployment ke macOS 13 atau versi lebih baru. Kode ini telah diperiksa berdasarkan API yang didokumentasikan; kode ini tidak dikompilasi atau diuji secara visual di sini karena lingkungan ini tidak memiliki executable Swift atau runtime macOS.
import SwiftUI
@main
struct RevealDemoApp: App {
var body: some Scene {
WindowGroup {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
RevealGroup("Collection") {
RevealGroup("Shapes") {
Text("Circle")
Text("Square")
}
Text("Palette")
}
Text("Following item")
.padding(.vertical, 8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.frame(minWidth: 360, minHeight: 280)
}
}
}
struct RevealGroup<Content: View>: View {
let title: String
let content: Content
@State private var expanded = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
init(_ title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(
reduceMotion ? nil : .easeInOut(duration: 0.25)
) {
expanded.toggle()
}
} label: {
HStack {
Image(systemName: "chevron.right")
.rotationEffect(.degrees(expanded ? 90 : 0))
.accessibilityHidden(true)
Text(title)
Spacer(minLength: 0)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityValue(expanded ? "Expanded" : "Collapsed")
RevealLayout(progress: expanded ? 1 : 0) {
VStack(alignment: .leading, spacing: 8) {
content
}
.padding(.leading, 18)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.clipped()
.allowsHitTesting(expanded)
.disabled(!expanded)
.accessibilityHidden(!expanded)
}
}
}
struct RevealLayout: Layout {
var progress: CGFloat
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard let child = subviews.first else { return .zero }
let width = proposal.width.flatMap { $0.isFinite ? $0 : nil }
let size = child.sizeThatFits(
ProposedViewSize(width: width, height: nil)
)
return CGSize(
width: width ?? size.width,
height: size.height * min(max(progress, 0), 1)
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard let child = subviews.first else { return }
child.place(
at: CGPoint(x: bounds.minX, y: bounds.minY),
anchor: .topLeading,
proposal: ProposedViewSize(width: bounds.width, height: nil)
)
}
}
Nilai yang menentukan adalah progress. SwiftUI menginterpolasi nilai animatableData lama dan baru selama animasi. Di sini, hal itu mengubah tinggi yang dilaporkan secara berkelanjutan dari nol hingga tinggi terukur turunannya. Sebagai ilustrasi, tinggi terukur 160 poin menghasilkan pengungkapan setinggi 80 poin pada progres 0,5; angka-angka tersebut adalah contoh aritmetika, bukan hasil pengukuran app. Animatable
Tinggi yang tidak ditentukan meminta tinggi ideal turunan pada lebar yang diusulkan, alih-alih memintanya menyusut agar muat dalam tinggi pengungkapan saat ini. Ini penting untuk label yang membungkus teks: pengungkapan seharusnya menampilkan tata letaknya, bukan secara bertahap menekannya. Apple menjelaskan dimensi yang tidak ditentukan sebagai cara untuk memperoleh ukuran ideal. ProposedViewSize
Pemotongan kemudian menyembunyikan bagian yang berada di luar batas yang dilaporkan. Tanpanya, konten dapat digambar di luar area yang disediakan oleh tata letak. Pertahankan padding bawah di dalam pengungkapan, seperti yang ditunjukkan, agar padding tersebut ikut menambah tinggi yang diciutkan. clipped(antialiased:)
Implementasi ini sengaja mempertahankan hierarki turunan saat diciutkan. Implementasi ini menonaktifkan interaksi dan menghapus area yang diciutkan dari representasi aksesibilitas; tombol yang terlihat melaporkan status ekspansinya. Pengujian hit dan visibilitas aksesibilitas adalah kontrol terpisah, jadi pemotongan saja tidak cukup. allowsHitTesting(_:), accessibilityHidden(_:)
Periksa perilaku bertingkat sebelum menggunakan versi kustom
Gunakan urutan berikut untuk membedakan pengungkapan yang berfungsi dari animasi yang hanya terlihat benar dalam keadaan paling sederhana.
- Buka
Collection, laluShapes. AmatiFollowing itemsaat kedua tingkat berubah. - Tutup
CollectionsaatShapesmasih terbuka, lalu buka kembali. Pastikan status ekspansi turunan tetap seperti yang diinginkan. - Klik lagi tombol pengungkapan sebelum animasinya selesai. Periksa apakah pengungkapan berbalik tanpa menyisakan celah.
- Persempit jendela dan ganti salah satu label contoh dengan label sintetis yang lebih panjang. Periksa pembungkusan teks dan tinggi akhir pengungkapan.
- Aktifkan
Reduce Motiondan pastikan ekspansi tetap dapat digunakan tanpa gerakan animasi. - Periksa aktivasi papan ketik dan representasi aksesibilitas grup yang terbuka maupun tertutup.
Tata letak kustom ini dirancang untuk konten dengan tinggi terbatas seperti label dan stack bertingkat ini. Hindari menempatkan area gulir vertikal tanpa batas di dalam pengungkapan lalu mengharapkannya memberikan tinggi konten penuh yang berguna. Implementasi ini secara eksplisit meminta tinggi ideal, sehingga turunan harus dapat memberikan jawaban yang bermakna untuk proposal tersebut. Ini adalah konsekuensi dari desain ukuran yang dipilih. ProposedViewSize
Konten yang tetap dipertahankan saat diciutkan juga mempertahankan biaya konstruksi dan tata letaknya. Mulailah dengan VStack biasa untuk hierarki kecil; pertimbangkan arsitektur lain sebelum menerapkan contoh eager ini ke pohon besar. Untuk baris berbasis data, pertahankan pengenal model tetap stabil saat ekspansi dan pengurutan ulang. Protokol Swift Identifiable merepresentasikan identitas entitas yang stabil, dan ForEach menggunakan data beridentitas untuk menghasilkan tampilan. Identifiable, ForEach
Di mana AppKit dan TextKit digunakan?
Jika antarmuka sudah menggunakan AppKit, NSOutlineView adalah kontrol baris dan kolom hierarkis yang didokumentasikan. Metode expandItem(_:) memperluas item dan tidak melakukan apa-apa jika item tersebut sudah terbuka atau tidak dapat diperluas. Metode itu tidak menyediakan parameter kurva animasi SwiftUI, jadi jangan berasumsi bahwa metode tersebut menyediakan pengaturan waktu kustom yang ditunjukkan di atas. NSOutlineView, expandItem(_:)
TextKit mengelola penyimpanan teks, tata letak teks, dan perenderan, termasuk mekanisme yang digunakan oleh NSTextView. Untuk hierarki label biasa, hal itu merupakan perhatian terpisah dari kontainer yang mengembang. Gunakan TextKit saat konten itu sendiri memerlukan pengeditan teks atau tata letak teks kustom; TextKit bukan sakelar terdokumentasi untuk menganimasikan ekspansi daftar SwiftUI. TextKit
Mulailah dengan grup pengungkapan bawaan dan verifikasi perilakunya di antarmuka Mac target Anda. Jika tugasnya memerlukan pengungkapan dengan tinggi yang terkendali, tata letak kustom memberikan nilai animasi yang eksplisit untuk tinggi tersebut. Sesuaikan pilihan dengan interaksi yang dibutuhkan: perilaku daftar bawaan, geometri pengungkapan kustom, atau outline AppKit yang sudah ada.
Sumber
- DisclosureGroup — Apple
- Animasi — Apple
- move(edge:) — Apple
- List — Apple
- accessibilityReduceMotion — Apple
- DisclosureGroupStyle — Apple
- Layout — Apple
- sizeThatFits(_:) — Apple
- place(at:anchor:proposal:) — Apple
- Animatable — Apple
- ProposedViewSize — Apple
- clipped(antialiased:) — Apple
- allowsHitTesting(_:) — Apple
- accessibilityHidden(_:) — Apple
- Identifiable — Apple
- ForEach — Apple
- NSOutlineView — Apple
- expandItem(_:) — Apple
- TextKit — Apple