Agar label dropdown yang berubah mengalir ulang dengan mulus, ukur setiap kontrol pada ukuran idealnya, tempatkan dengan algoritme pembungkusan bersama, lalu animasikan perubahan pilihan pada kontainer. Tutorial ini membuat panel kontrol macOS kecil menggunakan opsi sintetis.
Pisahkan pembungkusan dari animasi
Tugas pembaca cukup spesifik: buat panel desktop tempat pemilihan label dropdown yang lebih panjang dapat memindahkan kontrol di sebelahnya ke baris lain. Kontrol harus mempertahankan identitasnya saat posisinya berubah, dan panel harus melaporkan tinggi barunya kepada antarmuka di sekitarnya.
Ada dua tanggung jawab yang terpisah. Layout khusus menghitung ukuran dan posisi tujuan. Animasi memberi tahu SwiftUI cara berpindah dari geometri lama ke yang baru. Apple mendokumentasikan Layout sebagai titik ekstensi untuk susunan yang memerlukan perilaku lebih kompleks daripada yang disediakan kontainer bawaan. Dua metode wajibnya melaporkan ukuran kontainer dan menempatkan subview-nya. Layout
Untuk contoh ini, setiap dropdown adalah Menu dengan tindakan yang memperbarui pilihan. Apple mendukung menu dengan pembangun label khusus, sehingga label yang ditampilkan dapat menyertakan kategori dan opsi yang dipilih. Layout menyusun kontrol tertutup di dalam jendela; contoh ini tidak berupaya mengendalikan geometri konten menu yang ditampilkan. Menu
Desain ini menggunakan tiga kebijakan eksplisit:
- Kontrol mempertahankan ukuran idealnya alih-alih memampatkan label demi mempertahankan satu baris.
- Kontrol mengalir ke baris baru dalam urutan yang ada ketika kontrol berikutnya akan melampaui lebar yang diusulkan.
- Kontrol yang lebih lebar daripada ruang yang tersedia menempati barisnya sendiri dan membuat layout melaporkan ukuran yang lebih lebar tersebut.
Kebijakan terakhir itu penting. Membungkus sekumpulan kontrol tidak dapat membuat satu kontrol yang tak dapat dibagi menjadi lebih sempit. Jika aplikasi Anda harus memuat label yang panjangnya tidak terbatas ke dalam panel sempit, Anda juga memerlukan kebijakan label tersendiri, seperti desain label dengan batas lebar yang disengaja. Apple mencatat bahwa fixedSize() mempertahankan ukuran ideal sehingga sebuah tampilan dapat melampaui batas induknya. fixedSize()
Gunakan satu perhitungan untuk pengukuran dan penempatan
Sistem layout SwiftUI meneruskan usulan ukuran ke bawah hierarki. Usulan dengan dimensi yang tidak ditentukan meminta ukuran ideal sebuah tampilan; ini berbeda dari usulan yang berisi nol atau tak terhingga. Di sini, pengukuran ideal menentukan jejak setiap kontrol, sedangkan lebar yang diusulkan oleh induk menentukan pemisah baris. ProposedViewSize
Kontrak utamanya adalah konsistensi: kontainer harus menyediakan ruang yang sama dengan yang digunakan oleh algoritme penempatannya. Apple secara eksplisit mewajibkan perhitungan yang konsisten antara pengukuran dan penempatan, termasuk jarak dan respons terhadap usulan ukuran. Apple juga menetapkan bahwa batas penempatan berada dalam sistem koordinat induk, sehingga penempatan harus memperhitungkan bounds.minX dan bounds.minY. placeSubviews(in:proposal:subviews:cache:)
Buat app SwiftUI macOS di Xcode dan tetapkan target deployment ke macOS 14 atau lebih baru. Contoh ini menggunakan Layout, yang tersedia mulai macOS 13, dan geometryGroup(), yang tersedia mulai macOS 14. Letakkan deklarasi layout berikut dan kode app di bagian selanjutnya dalam satu file Swift, dengan mengganti titik masuk app yang dihasilkan. Kedua blok ini membentuk contoh lengkap. Ketersediaan Layout, Ketersediaan geometryGroup()
import SwiftUI
struct WrappingControls: Layout {
var spacing: CGFloat = 8
struct Plan {
var size: CGSize
var origins: [CGPoint]
}
func plan(
proposal: ProposedViewSize,
subviews: Subviews
) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, origins: [])
}
let gap = max(0, spacing)
let limit: CGFloat
if let width = proposal.width, width.isFinite {
limit = max(0, width)
} else {
limit = .infinity
}
var origins: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var usedWidth: CGFloat = 0
var rowHasItems = false
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let nextX = rowHasItems ? x + gap : 0
if rowHasItems && nextX + size.width > limit {
y += rowHeight + gap
x = 0
rowHeight = 0
rowHasItems = false
}
if rowHasItems {
x += gap
}
origins.append(CGPoint(x: x, y: y))
x += size.width
rowHeight = max(rowHeight, size.height)
usedWidth = max(usedWidth, x)
rowHasItems = true
}
let width = limit.isFinite ? max(limit, usedWidth) : usedWidth
return Plan(
size: CGSize(width: width, height: y + rowHeight),
origins: origins
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
plan(proposal: proposal, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = plan(proposal: proposal, subviews: subviews)
for (index, subview) in subviews.enumerated() {
let origin = result.origins[index]
subview.place(
at: CGPoint(
x: bounds.minX + origin.x,
y: bounds.minY + origin.y
),
anchor: .topLeading,
proposal: .unspecified
)
}
}
}
Kedua metode layout memanggil plan menggunakan usulan yang diberikan SwiftUI. Pengukuran dan penempatan juga menggunakan usulan anak yang sama, yaitu .unspecified. API pengukuran subview Apple mengembalikan ukuran yang dipilih subview untuk usulan yang diberikan; contoh ini menggunakan respons tersebut alih-alih memperkirakan lebar berdasarkan jumlah karakter. sizeThatFits(_:)
Untuk lebar usulan yang terbatas, layout mengisi setidaknya lebar tersebut dan melaporkan secara akurat jika ada anak yang terlalu besar. Untuk lebar yang tidak ditentukan atau tak terhingga, layout mengembalikan ukuran ideal satu baris yang terbatas untuk kontrol dengan ukuran terbatas ini. Tinggi ditentukan oleh konten; layout sengaja mengabaikan tinggi yang diusulkan dan melaporkan ruang yang dibutuhkan oleh baris-barisnya.
Implementasi ini menggunakan cache Void dan menghitung ulang rencana kecilnya. Apple menyarankan penambahan cache hanya jika profiling menunjukkan manfaatnya. Jika kelak Anda menyimpan pengukuran di cache, pastikan bahwa pembuatan ulang cache tidak mengubah hasil layout. makeCache(subviews:)
Hubungkan perubahan pilihan dengan kontrol yang stabil
Panel sintetis ini memiliki tiga kategori: Shape, Pattern, dan Arrangement. Setiap kategori memiliki string pilihan tersendiri, dan setiap opsi didefinisikan di dalam contoh. Label berubah di dalam Menu yang sama; program tidak mengganti menu setiap kali pilihan berubah.
Tambahkan kode ini setelah deklarasi layout. Pastikan hanya ada satu deklarasi app @main di proyek.
struct ControlChoice: Identifiable, Equatable {
let id: Int
let title: String
let options: [String]
var selected: String
}
struct DropdownPanel: View {
@State var choices: [ControlChoice] = [
ControlChoice(
id: 1,
title: "Shape",
options: ["Circle", "Rounded rectangle"],
selected: "Circle"
),
ControlChoice(
id: 2,
title: "Pattern",
options: ["Solid", "Alternating diagonal lines"],
selected: "Solid"
),
ControlChoice(
id: 3,
title: "Arrangement",
options: ["Rows", "Evenly spaced columns"],
selected: "Rows"
)
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Layout options")
.font(.headline)
WrappingControls(spacing: 8) {
ForEach(choices) { choice in
Menu {
ForEach(choice.options, id: \.self) { option in
Button(option) {
select(option, for: choice.id)
}
}
} label: {
Text("\(choice.title): \(choice.selected)")
.lineLimit(1)
}
.fixedSize()
.geometryGroup()
}
}
.animation(
.easeInOut(duration: 0.25),
value: choices.map(\.selected)
)
Text("Choose a longer option, then narrow the window.")
.foregroundStyle(.secondary)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 320, minHeight: 240)
}
func select(_ option: String, for id: Int) {
guard let index = choices.firstIndex(where: { $0.id == id }),
choices[index].options.contains(option) else {
return
}
choices[index].selected = option
}
}
@main
struct RewrappingMenusApp: App {
var body: some Scene {
WindowGroup {
DropdownPanel()
}
}
}
Modifier animasi mengamati larik string pilihan. Apple mewajibkan nilai yang diamati agar memenuhi konformansi Equatable; larik ini memenuhi ketentuan tersebut. Mengamati nilai pilihan juga berarti modifier ini tidak meminta animasi baru hanya karena lebar jendela berubah. Layout tetap menghitung ulang pembungkusan ketika usulannya berubah. animation(_:value:)
ID kategori yang stabil menghubungkan setiap menu di antara pembaruan. ForEach membuat tampilan dari data yang memiliki ID, sedangkan mengubah .id eksplisit sebuah tampilan akan mengatur ulang identitas dan state terkaitnya. Karena itu, menambahkan .id(choice.selected) akan menghambat kesinambungan yang dibutuhkan contoh ini. String opsi unik di dalam setiap kategori sintetis, sehingga id: \.self cocok untuk tindakan menu tersebut. ForEach, id(_:)
Contoh layout khusus Apple menunjukkan bahwa mempertahankan identitas struktural memungkinkan animation(_:value:) menganimasikan perubahan layout. Contoh ini menerapkan prinsip tersebut pada perubahan pengukuran di dalam satu jenis layout. String pilihan berubah seketika; animasi yang diharapkan adalah pergerakan dan perubahan ukuran yang dihasilkan, bukan interpolasi antara karakter dari dua label. Menyusun layout khusus dengan SwiftUI
Modifier geometryGroup() memberikan batas geometri pada setiap menu utuh. Apple menjelaskan bahwa batas ini menyelesaikan dan menganimasikan geometri induk sebelum meneruskannya kepada turunannya. Penggunaannya di sini mengikuti mekanisme terdokumentasi tersebut agar konten internal setiap kontrol bergerak bersama; modifier ini tidak menentukan pemisah baris atau memperbaiki perhitungan pembungkusan yang keliru. geometryGroup()
Periksa pemisah baris sebelum menyetel animasi
Contoh singkat berikut membuat kebijakan pembungkusan mudah diperiksa. Ini adalah dimensi ilustratif, bukan pengukuran kontrol macOS: anggap tiga kontrol lebarnya 80, 100, dan 90 poin, semuanya setinggi 24 poin, dengan jarak 8 poin dan lebar yang tersedia 300 poin.
Baris awal memerlukan 286 poin: 80 + 8 + 100 + 8 + 90. Jika kontrol tengah melebar menjadi 150 poin, keseluruhan baris akan memerlukan 336 poin. Dua kontrol pertama masih muat dengan total 238 poin, sehingga kontrol ketiga pindah ke baris berikutnya. Tinggi konten baru adalah 56 poin: 24 + 8 + 24.
| Kondisi | Hasil yang diharapkan dari algoritme ini |
|---|---|
| Lebar gabungan tepat sama dengan batas | Pertahankan kontrol di baris tersebut. |
| Kontrol berikutnya akan melampaui batas | Mulai baris baru sebelum kontrol tersebut. |
| Tinggi kontrol berbeda-beda | Geser ke bawah sebesar tinggi kontrol tertinggi pada baris yang selesai. |
| Lebar satu kontrol melampaui lebar usulan | Tempatkan kontrol itu sendirian dan laporkan ukuran yang lebih lebar yang dibutuhkan. |
| Lebar tidak ditentukan atau tak terhingga | Kembalikan satu baris dengan lebar ideal gabungan kontrol. |
| Tidak ada kontrol | Kembalikan ukuran nol. |
Aritmetika penempatan diperiksa secara terpisah dengan dimensi sintetis deterministik, termasuk label yang lebih panjang, kontrol yang terlalu besar, dan lebar yang tidak ditentukan. App SwiftUI itu sendiri tidak dikompilasi atau diuji secara visual di lingkungan ini. Build dan jalankan di macOS untuk memverifikasi tampilan kontrol native dan animasinya.
Di app yang berjalan, pertama-tama pilih opsi yang lebih panjang saat lebar jendela tetap. Atur lebar jendela hingga perubahan pilihan melewati batas baris, lalu bergantian pilih opsi pendek dan panjang. Pastikan teks penjelasan di bawah kontrol berpindah untuk menyesuaikan tinggi baris yang baru dan bahwa kembali ke label pendek menghilangkan ruang vertikal yang tidak terpakai.
Berikutnya, ubah ukuran jendela tanpa mengubah pilihan. Ini membantu membedakan cacat pembungkusan dari cacat animasi: posisi akhir yang keliru menunjukkan masalah pengukuran atau penempatan, sedangkan posisi akhir yang benar dengan gerakan yang tidak diinginkan mengharuskan pemeriksaan cakupan animasi dan pengelompokan geometri.
Diagnosis kondisi kegagalan yang tersisa
Jika label berubah tetapi pemisah baris tidak, periksa apa yang benar-benar diukur oleh layout. Pembungkus dengan lebar tetap di sekeliling setiap menu dapat membuat label pendek dan panjang melaporkan lebar luar yang sama. Dalam implementasi ini, setiap menu sengaja mempertahankan ukuran idealnya, dan layout mengukur seluruh kontrol tersebut alih-alih perkiraan teks terpisah. fixedSize(), sizeThatFits(_:)
Jika kontrol saling bertumpuk atau bagian berikutnya menutupi baris kedua, periksa pengukuran dan penempatan terlebih dahulu. Gunakan jarak, usulan anak, kondisi pembungkusan, dan perhitungan tinggi baris yang sama di kedua metode. Jangan menghitung ukuran berdasarkan satu lebar lalu menempatkan berdasarkan lebar yang lain. Fungsi bersama plan dibuat agar perbedaan semacam itu lebih sulit terjadi. placeSubviews(in:proposal:subviews:cache:)
Jika kontrol menghilang lalu muncul kembali saat pilihan berubah, periksa identitas sebelum menambahkan modifier animasi lainnya. Pertahankan ID kategori dan perbarui hanya pilihannya. Apple mendokumentasikan bahwa perubahan ID eksplisit tampilan akan mengatur ulang identitas tampilan, sehingga label yang berubah merupakan kunci identitas yang buruk untuk kontrol persisten. id(_:)
Untuk target deployment macOS 13, hapus .geometryGroup() dan pertahankan implementasi Layout serta animasi berbasis pilihan. Pendekatan pembungkusan tetap terjaga, tetapi batas geometri terdokumentasi yang tersedia mulai macOS 14 tidak lagi digunakan; evaluasi gerakan yang dihasilkan pada sistem yang Anda dukung. Layout, geometryGroup()
Perilaku akhir bertumpu pada tiga keputusan konkret: ukur kontrol yang sebenarnya, gunakan rencana baris yang sama untuk ukuran yang dilaporkan dan penempatan, serta pertahankan identitas kontrol saat label berubah. Setelah keputusan tersebut menghasilkan geometri akhir yang benar, animasi berbasis pilihan dapat membuat perubahan baris lebih mudah diikuti.
Sumber
- Layout — Dokumentasi Developer Apple.
- ProposedViewSize — Dokumentasi Developer Apple.
- placeSubviews(in:proposal:subviews:cache:) — Dokumentasi Developer Apple.
- sizeThatFits(_:) — Dokumentasi Developer Apple.
- makeCache(subviews:) — Dokumentasi Developer Apple.
- Menu — Dokumentasi Developer Apple.
- fixedSize() — Dokumentasi Developer Apple.
- animation(_:value:) — Dokumentasi Developer Apple.
- ForEach — Dokumentasi Developer Apple.
- id(_:) — Dokumentasi Developer Apple.
- Menyusun layout khusus dengan SwiftUI — Dokumentasi Developer Apple.
- geometryGroup() — Dokumentasi Developer Apple.