Untuk menjadikan label dropdown yang berubah mengalir semula dengan lancar, ukur setiap kawalan pada saiz idealnya, letakkannya menggunakan algoritma pembalutan yang dikongsi, dan animasikan perubahan pilihan pada bekas. Tutorial ini membina panel kawalan macOS kecil menggunakan pilihan sintetik.

Asingkan pembalutan daripada animasi

Tugas pembaca adalah khusus: bina panel desktop yang membolehkan pemilihan label dropdown yang lebih panjang memindahkan kawalan bersebelahan ke baris lain. Kawalan harus mengekalkan identitinya apabila kedudukannya berubah, dan panel harus melaporkan ketinggian baharunya kepada antara muka di sekelilingnya.

Terdapat dua tanggungjawab yang berasingan. Layout tersuai mengira saiz dan kedudukan destinasi. Animasi memberitahu SwiftUI cara bergerak antara geometri lama dengan yang baharu. Apple mendokumentasikan Layout sebagai titik sambungan untuk susunan yang memerlukan tingkah laku lebih kompleks daripada yang disediakan bekas terbina dalam. Dua kaedah wajibnya melaporkan saiz bekas dan meletakkan subpaparannya. Layout

Untuk contoh ini, setiap dropdown ialah Menu yang tindakannya mengemas kini pilihan. Apple menyokong menu dengan pembina label tersuai, jadi label yang dipaparkan boleh merangkumi kategori serta pilihan yang dipilih. Susun atur ini menyusun kawalan tertutup di dalam tetingkap; contoh ini tidak cuba mengawal geometri kandungan menu yang dipaparkan. Menu

Reka bentuk ini menggunakan tiga dasar yang jelas:

  • Kawalan mengekalkan saiz idealnya dan tidak memampatkan label demi mengekalkan satu baris.
  • Kawalan dibalut mengikut susunan sedia ada apabila kawalan seterusnya akan melebihi lebar yang dicadangkan.
  • Kawalan yang lebih lebar daripada lebar tersedia memenuhi barisnya sendiri dan menyebabkan susun atur melaporkan saiz yang lebih lebar itu.

Dasar terakhir itu penting. Membalut koleksi tidak dapat menjadikan satu kawalan yang tidak boleh dibahagikan lebih sempit. Jika aplikasi anda perlu memuatkan label sepanjang mana pun ke dalam panel sempit, anda juga memerlukan dasar label yang berasingan, seperti reka bentuk label dengan had lebar yang disengajakan. Apple menyatakan bahawa fixedSize() mengekalkan saiz ideal, maka paparan boleh melebihi sempadan induknya. fixedSize()

Gunakan satu pengiraan untuk pengukuran dan peletakan

Sistem susun atur SwiftUI menghantar cadangan saiz ke bawah hierarki. Cadangan dengan dimensi yang tidak ditentukan meminta saiz ideal sesuatu paparan; ia berbeza daripada cadangan yang mengandungi sifar atau infiniti. Di sini, ukuran ideal menentukan ruang yang diperlukan setiap kawalan, manakala lebar yang dicadangkan oleh induk menentukan pemisah baris. ProposedViewSize

Kontrak utamanya ialah konsistensi: bekas mesti menyediakan ruang yang sama seperti yang digunakan oleh algoritma peletakannya. Apple secara jelas menghendaki pengiraan yang konsisten antara penentuan saiz dengan peletakan, termasuk jarak dan tindak balas terhadap cadangan. Apple juga menetapkan bahawa sempadan peletakan berada dalam ruang koordinat induk, jadi peletakan mesti mengambil kira bounds.minX dan bounds.minY. placeSubviews(in:proposal:subviews:cache:)

Cipta aplikasi SwiftUI macOS dalam Xcode dan tetapkan sasaran penggunaan kepada macOS 14 atau lebih baharu. Contoh ini menggunakan Layout, yang tersedia mulai macOS 13, dan geometryGroup(), yang tersedia mulai macOS 14. Letakkan pengisytiharan susun atur berikut dan kod aplikasi dalam bahagian seterusnya ke dalam satu fail Swift, menggantikan titik masuk aplikasi yang dijana. Kedua-dua blok itu 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-dua kaedah susun atur memanggil plan menggunakan cadangan yang diberikan oleh SwiftUI. Pengukuran dan peletakan juga menggunakan cadangan anak yang sama, .unspecified. API pengukuran subpaparan Apple mengembalikan saiz yang dipilih oleh subpaparan untuk cadangan yang diberikan; contoh ini menggunakan respons itu dan bukannya menganggarkan lebar berdasarkan bilangan aksara. sizeThatFits(_:)

Bagi lebar cadangan yang terhingga, susun atur memenuhi sekurang-kurangnya lebar itu dan melaporkan sebarang anak yang terlalu besar dengan tepat. Bagi lebar yang tidak ditentukan atau infiniti, susun atur mengembalikan saiz ideal satu baris yang terhingga untuk kawalan bersaiz terhingga ini. Ketinggian ditentukan oleh kandungan; susun atur ini sengaja mengabaikan ketinggian yang dicadangkan dan melaporkan ruang yang diperlukan oleh barisnya.

Pelaksanaan ini menggunakan cache Void dan mengira semula pelan kecilnya. Apple mengesyorkan supaya cache hanya ditambah apabila pemprofilan menunjukkan manfaatnya. Jika anda menyimpan ukuran dalam cache kemudian, pastikan penciptaan semula cache tidak mengubah hasil susun atur. makeCache(subviews:)

Hubungkan pilihan yang berubah dengan kawalan yang stabil

Panel sintetik ini mempunyai tiga kategori: Shape, Pattern dan Arrangement. Setiap kategori menyimpan rentetan yang dipilih, dan setiap pilihan ditakrifkan dalam contoh ini. Label berubah di dalam Menu sedia ada; program ini tidak menggantikan menu setiap kali pilihan berubah.

Tambahkan kod ini selepas pengisytiharan susun atur. Pastikan projek hanya mempunyai satu pengisytiharan aplikasi @main.

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

Pengubah suai animasi memerhatikan tatasusunan rentetan yang dipilih. Apple menghendaki nilai yang diperhatikan mematuhi Equatable; tatasusunan ini memenuhi kontrak tersebut. Memerhatikan nilai pilihan juga bermakna pengubah suai ini tidak meminta animasi baharu hanya kerana lebar tetingkap berubah. Susun atur tetap mengira semula pembalutan apabila cadangannya berubah. animation(_:value:)

ID kategori yang stabil menghubungkan setiap menu merentas kemas kini. ForEach membina paparan daripada data yang dikenal pasti, manakala perubahan .id eksplisit bagi sesuatu paparan menetapkan semula identiti dan keadaan berkaitannya. Oleh itu, menambahkan .id(choice.selected) akan menjejaskan kesinambungan yang diperlukan oleh contoh ini. Rentetan pilihan adalah unik dalam setiap kategori sintetik, menjadikan id: \.self sesuai untuk tindakan menu tersebut. ForEach, id(_:)

Contoh susun atur tersuai Apple menunjukkan bahawa mengekalkan identiti struktur membolehkan animation(_:value:) menganimasikan perubahan susun atur. Contoh ini menerapkan prinsip itu pada ukuran yang berubah di dalam satu jenis susun atur. Rentetan pilihan berubah serta-merta; animasi yang dimaksudkan ialah pergerakan dan perubahan saiz yang terhasil, bukannya interpolasi antara aksara dalam dua label. Menggubah susun atur tersuai dengan SwiftUI

Pengubah suai geometryGroup() memberikan setiap menu lengkap sempadan geometri. Apple menerangkan bahawa sempadan ini menyelesaikan dan menganimasikan geometri induk sebelum menyampaikannya kepada turunan. Penggunaannya di sini mengikut mekanisme yang didokumenkan itu untuk memastikan kandungan dalaman setiap kawalan bergerak bersama; ia tidak menentukan pemisah baris atau membetulkan pengiraan pembalutan yang salah. geometryGroup()

Semak pemisah baris sebelum melaraskan animasi

Contoh ringkas memudahkan dasar pembalutan diperiksa. Dimensi ini hanya ilustrasi, bukannya ukuran kawalan macOS: anggap tiga kawalan masing-masing selebar 80, 100 dan 90 mata, semuanya setinggi 24 mata, dengan jarak 8 mata dan ruang tersedia 300 mata.

Baris awal memerlukan 286 mata: 80 + 8 + 100 + 8 + 90. Jika kawalan tengah membesar kepada 150 mata, keseluruhan baris memerlukan 336 mata. Dua kawalan pertama masih muat dengan jumlah 238 mata, jadi kawalan ketiga berpindah ke baris seterusnya. Ketinggian kandungan baharu ialah 56 mata: 24 + 8 + 24.

Keadaan Hasil yang dijangka daripada algoritma ini
Lebar gabungan tepat menyamai had Kekalkan kawalan pada baris tersebut.
Kawalan seterusnya akan melebihi had Mulakan baris baharu sebelum kawalan itu.
Ketinggian kawalan berbeza Tambah ketinggian kawalan tertinggi dalam baris yang lengkap.
Satu kawalan melebihi lebar yang dicadangkan Letakkan kawalan itu bersendirian dan laporkan saiz yang lebih lebar diperlukan.
Lebar tidak ditentukan atau infiniti Kembalikan satu baris pada jumlah lebar ideal kawalan.
Tiada kawalan Kembalikan saiz sifar.

Aritmetik pembungkusan disemak secara berasingan menggunakan dimensi sintetik yang deterministik, termasuk label lebih panjang, kawalan terlalu besar dan lebar yang tidak ditentukan. Aplikasi SwiftUI itu sendiri tidak dikompil atau diuji secara visual dalam persekitaran ini. Bina dan jalankannya pada macOS untuk mengesahkan rupa kawalan natif dan animasinya.

Dalam aplikasi yang sedang berjalan, mula-mula pilih pilihan yang lebih panjang sambil mengekalkan lebar tetingkap. Laraskan tetingkap sehingga perubahan pilihan merentasi sempadan baris, kemudian berselang-seli antara pilihan pendek dengan yang panjang. Pastikan teks penerangan di bawah kawalan bergerak untuk memberi ruang kepada ketinggian baris baharu dan ruang menegak yang tidak digunakan hilang apabila kembali kepada label pendek.

Seterusnya, ubah saiz tetingkap tanpa mengubah pilihan. Ini membezakan kecacatan pembalutan daripada kecacatan animasi: kedudukan akhir yang salah menunjukkan masalah pengukuran atau peletakan, manakala kedudukan akhir yang betul dengan pergerakan yang tidak diingini memerlukan pemeriksaan skop animasi dan pengelompokan geometri.

Kenal pasti keadaan kegagalan yang masih berbaki

Jika label berubah tetapi pemisah baris tidak berubah, periksa perkara yang sebenarnya diukur oleh susun atur. Pembalut lebar tetap di sekeliling setiap menu boleh menyebabkan label pendek dan panjang melaporkan lebar luar yang sama. Dalam pelaksanaan ini, setiap menu sengaja mengekalkan saiz idealnya, dan susun atur mengukur kawalan penuh itu, bukannya anggaran teks yang berasingan. fixedSize(), sizeThatFits(_:)

Jika kawalan bertindih atau bahagian seterusnya menutupi baris kedua, bandingkan penentuan saiz dengan peletakan terlebih dahulu. Gunakan jarak, cadangan anak, syarat pembalutan dan pengiraan ketinggian baris yang sama dalam kedua-dua kaedah. Jangan kira penentuan saiz berdasarkan satu lebar dan peletakan berdasarkan lebar lain. Fungsi plan yang dikongsi bertujuan menyukarkan berlakunya percanggahan tersebut. placeSubviews(in:proposal:subviews:cache:)

Jika kawalan hilang dan muncul semula semasa pilihan berubah, periksa identiti sebelum menambahkan lagi pengubah suai animasi. Kekalkan ID kategori dan kemas kini pilihannya sahaja. Apple mendokumentasikan bahawa perubahan ID paparan yang eksplisit menetapkan semula identiti paparan, jadi label yang berubah ialah kunci identiti yang kurang sesuai untuk kawalan kekal. id(_:)

Untuk penggunaan pada macOS 13, buang .geometryGroup() dan kekalkan pelaksanaan Layout serta animasi yang dipacu oleh pilihan. Pendekatan pembalutan itu kekal, tetapi sempadan geometri yang didokumenkan dan tersedia mulai macOS 14 akan tiada; nilai pergerakan yang terhasil pada sistem yang anda sokong. Layout, geometryGroup()

Tingkah laku akhir bergantung pada tiga keputusan konkrit: ukur kawalan sebenar, gunakan pelan baris yang sama untuk saiz yang dilaporkan dan peletakan, serta kekalkan identiti kawalan apabila label berubah. Setelah keputusan tersebut menghasilkan geometri akhir yang betul, animasi yang dipacu oleh pilihan dapat menjadikan pembalutan semula lebih mudah diikuti.

Sumber