Om du vill att ändrade etiketter i rullgardinsmenyer ska radbrytas smidigt mäter du varje kontroll i sin ideala storlek, placerar den med en gemensam radbrytningsalgoritm och animerar markeringsändringar på behållaren. Den här handledningen bygger en liten macOS-kontrollpanel med syntetiska alternativ.
Skilj radbrytning från animering
Uppgiften är specifik: bygg en skrivbordspanel där ett val av en längre etikett i en rullgardinsmeny kan flytta intilliggande kontroller till en annan rad. Kontrollerna ska behålla sin identitet medan deras positioner ändras, och panelen ska rapportera sin nya höjd till det omgivande gränssnittet.
Det handlar om två separata ansvarsområden. En anpassad Layout beräknar målens storlekar och positioner. En animering talar om för SwiftUI hur övergången mellan den gamla och den nya geometrin ska ske. Apple beskriver Layout som utökningspunkten för arrangemang som kräver mer avancerat beteende än de inbyggda behållarna erbjuder. Dess två obligatoriska metoder rapporterar behållarens storlek och placerar dess underordnade vyer. Layout
I det här exemplet är varje rullgardinsmeny en Menu vars åtgärder uppdaterar ett val. Apple stöder menyer med anpassade etikettbyggare, så den visade etiketten kan innehålla både en kategori och det valda alternativet. Layouten arrangerar de stängda kontrollerna i fönstret; exemplet försöker inte styra geometrin för menyns visade innehåll. Menu
Designen använder tre uttryckliga principer:
- Kontrollerna behåller sin ideala storlek i stället för att komprimera etiketterna för att få plats på en rad.
- Kontrollerna radbryts i sin befintliga ordning när nästa kontroll skulle överskrida den föreslagna bredden.
- En kontroll som är bredare än det tillgängliga utrymmet placeras ensam på en rad, och layouten rapporterar den större bredden.
Den sista principen är viktig. Radbrytning av en samling kan inte göra en odelbar kontroll smalare. Om programmet måste få plats med godtyckligt långa etiketter i en smal panel behöver du också en separat etikettprincip, till exempel en etikettutformning med en avsiktlig maxbredd. Apple påpekar att fixedSize() bevarar den ideala storleken och därför kan göra att en vy överskrider sin överordnade vygrepp. fixedSize()
Använd samma beräkning för mätning och placering
SwiftUIs layoutsystem skickar storleksförslag nedåt i hierarkin. Ett förslag med ospecificerade mått begär en vy:s ideala storlek; det skiljer sig från ett förslag som innehåller noll eller oändlighet. Här avgör de ideala mätningarna varje kontrolls utrymmesbehov, medan den överordnade vyns föreslagna bredd avgör radbrytningarna. ProposedViewSize
Det viktiga kravet är att beräkningarna stämmer överens: behållaren måste reservera samma utrymme som placeringsalgoritmen använder. Apple kräver uttryckligen konsekventa beräkningar mellan storleksbestämning och placering, inklusive avstånd och hur förslag hanteras. Apple anger också att placeringsgränserna är i den överordnade vyns koordinatsystem, så placeringen måste ta hänsyn till bounds.minX och bounds.minY. placeSubviews(in:proposal:subviews:cache:)
Skapa en macOS-app med SwiftUI i Xcode och ange macOS 14 eller senare som distributionsmål. Exemplet använder Layout, som är tillgängligt från macOS 13, och geometryGroup(), som är tillgängligt från macOS 14. Lägg layoutdeklarationen nedan och appkoden i nästa avsnitt i en enda Swift-fil och ersätt den genererade appens startpunkt. Tillsammans utgör de två kodblocken hela exemplet. Layout availability, geometryGroup() availability
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
)
}
}
}
Båda layoutmetoderna anropar plan med förslaget som SwiftUI skickar. Mätningen och placeringen använder också samma förslag till underordnade vyer: .unspecified. Apples API för mätning av underordnade vyer returnerar den storlek som en underordnad vy väljer för det angivna förslaget; exemplet använder det svaret i stället för att uppskatta bredden utifrån antalet tecken. sizeThatFits(_:)
För en ändlig föreslagen bredd fyller layouten minst den bredden och rapporterar alla barn som är större än utrymmet. För en ospecificerad eller oändlig bredd returnerar den en ändlig idealstorlek på en enda rad för dessa kontroller med ändlig storlek. Höjden styrs av innehållet; layouten ignorerar avsiktligt den föreslagna höjden och rapporterar det utrymme som raderna kräver.
Den här implementationen använder en Void-cache och beräknar om sin lilla plan. Apple rekommenderar att en cache läggs till endast när profilering visar att det lönar sig. Om du senare cachelagrar mätningar ska du bevara kravet att återskapande av cachen inte får ändra layoutresultatet. makeCache(subviews:)
Koppla ändrade val till stabila kontroller
Den syntetiska panelen har tre kategorier: Shape, Pattern och Arrangement. Varje kategori har sin egen valda sträng, och alla alternativ definieras i exemplet. Etiketten ändras i den befintliga Menu-menyn; programmet ersätter inte en meny varje gång ett val ändras.
Lägg till den här koden efter layoutdeklarationen. Ha endast en appdeklaration med @main i projektet.
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()
}
}
}
Animeringsmodifieraren övervakar en array med valda strängar. Apple kräver att det övervakade värdet följer Equatable; den här arrayen uppfyller det kravet. Genom att övervaka valens värden begär modifieraren inte heller en ny animering enbart för att fönstrets bredd ändras. Layouten beräknar ändå om radbrytningen när förslaget ändras. animation(_:value:)
Stabila kategori-ID:n kopplar ihop varje meny mellan uppdateringar. ForEach bygger vyer från identifierade data, medan ett ändrat, uttryckligt .id för en vy återställer dess identitet och tillhörande tillstånd. Att lägga till .id(choice.selected) skulle därför motverka den kontinuitet som exemplet behöver. Alternativsträngarna är unika inom varje syntetisk kategori, vilket gör id: \.self lämpligt för menyåtgärderna. ForEach, id(_:)
Apples exempel på anpassad layout visar att en bevarad strukturell identitet låter animation(_:value:) animera layoutändringar. Det här exemplet tillämpar den principen på ändrade mätningar i en enda layouttyp. Urvalssträngen ändras omedelbart; den avsedda animeringen gäller den efterföljande förflyttningen och storleksändringen, inte en interpolation mellan tecknen i två etiketter. Composing custom layouts with SwiftUI
Modifieraren geometryGroup() ger varje fullständig meny en geometrisk gräns. Apple beskriver gränsen som ett sätt att beräkna och animera den överordnade geometrin innan den skickas vidare till underordnade vyer. Användningen här följer den dokumenterade mekanismen så att varje kontrolls interna innehåll rör sig tillsammans; den avgör inte radbrytningarna och rättar inte till en felaktig radbrytningsberäkning. geometryGroup()
Kontrollera radbrytningarna innan du finjusterar animeringen
Ett litet räkneexempel gör radbrytningsprincipen lätt att granska. Det här är illustrativa mått, inte mätningar av macOS-kontroller: anta att tre kontroller är 80, 100 respektive 90 punkter breda, att alla är 24 punkter höga, att avståndet är 8 punkter och att 300 punkter finns tillgängliga.
Den första raden kräver 286 punkter: 80 + 8 + 100 + 8 + 90. Om den mittersta kontrollen växer till 150 punkter skulle hela raden kräva 336 punkter. De två första kontrollerna ryms fortfarande och använder 238 punkter, så den tredje flyttas till nästa rad. Den nya innehållshöjden är 56 punkter: 24 + 8 + 24.
| Villkor | Förväntat resultat med den här algoritmen |
|---|---|
| Den sammanlagda bredden är exakt lika med gränsen | Behåll kontrollerna på samma rad. |
| Nästa kontroll skulle överskrida gränsen | Börja en ny rad före den kontrollen. |
| Kontrollerna har olika höjd | Gå vidare med höjden för den högsta kontrollen på den färdiga raden. |
| En kontroll överskrider den föreslagna bredden | Placera den ensam och rapportera den större bredd som krävs. |
| Bredden är ospecificerad eller oändlig | Returnera en rad med kontrollernas sammanlagda idealbredd. |
| Det finns inga kontroller | Returnera storleken noll. |
Packningsberäkningen kontrollerades separat med deterministiska syntetiska mått, inklusive längre etiketter, en överdimensionerad kontroll och ospecificerad bredd. Själva SwiftUI-appen kompilerades eller testades inte visuellt i den här miljön. Bygg och kör den i macOS för att verifiera de inbyggda kontrollernas utseende och animeringen.
I appen som körs väljer du först längre alternativ medan fönstrets bredd är oförändrad. Justera fönstret tills ett ändrat val passerar en radgräns och växla sedan mellan de korta och långa alternativen. Kontrollera att den förklarande texten under kontrollerna flyttas för att ge plats åt den nya radhöjden och att oanvänt vertikalt utrymme försvinner när du återgår till de korta etiketterna.
Ändra sedan fönstrets storlek utan att ändra valen. Då kan du skilja ett problem med radbrytningen från ett animeringsproblem: felaktiga slutpositioner tyder på problem med mätning eller placering, medan korrekta slutpositioner med oönskad rörelse talar för att animeringens omfattning och den geometriska grupperingen bör kontrolleras.
Diagnostisera kvarstående fel
Om etiketterna ändras men radbrytningarna inte gör det ska du undersöka vad layouten faktiskt mäter. En behållare med fast bredd runt varje meny kan göra att korta och långa etiketter rapporterar samma yttre bredd. I den här implementationen bevarar varje meny avsiktligt sin ideala storlek, och layouten mäter hela kontrollen i stället för en separat uppskattning av texten. fixedSize(), sizeThatFits(_:)
Om kontroller överlappar varandra eller om nästa avsnitt täcker den andra raden ska du först jämföra storleksbestämningen och placeringen. Använd samma mellanrum, förslag till underordnade vyer, radbrytningsvillkor och beräkning av radhöjd i båda metoderna. Beräkna inte storleken utifrån en bredd och placera sedan utifrån en annan. Den gemensamma funktionen plan gör det svårare att introducera sådana avvikelser. placeSubviews(in:proposal:subviews:cache:)
Om kontroller försvinner och dyker upp igen när ett val ändras ska du kontrollera identiteten innan du lägger till fler animeringsmodifierare. Behåll kategori-ID:t stabilt och uppdatera endast dess val. Apple dokumenterar att en ändring av ett uttryckligt vy-ID återställer vyns identitet, så en ändrad etikett är en olämplig identitetsnyckel för en beständig kontroll. id(_:)
För en driftsättning till macOS 13 tar du bort .geometryGroup() och behåller Layout-implementeringen och den urvalsstyrda animeringen. Då bevaras radbrytningsmetoden, men den dokumenterade geometriska gräns som finns från macOS 14 tas bort; utvärdera den resulterande rörelsen på de system du stöder. Layout, geometryGroup()
Det färdiga beteendet bygger på tre konkreta beslut: mät de faktiska kontrollerna, använd samma radplan för den rapporterade storleken och placeringen, och bevara kontrollernas identitet när etiketterna ändras. När dessa beslut ger rätt slutgeometri kan den urvalsstyrda animeringen göra radbrytningen lättare att följa.
Källor
- Layout — Apples utvecklardokumentation.
- ProposedViewSize — Apples utvecklardokumentation.
- placeSubviews(in:proposal:subviews:cache:) — Apples utvecklardokumentation.
- sizeThatFits(_:) — Apples utvecklardokumentation.
- makeCache(subviews:) — Apples utvecklardokumentation.
- Menu — Apples utvecklardokumentation.
- fixedSize() — Apples utvecklardokumentation.
- animation(_:value:) — Apples utvecklardokumentation.
- ForEach — Apples utvecklardokumentation.
- id(_:) — Apples utvecklardokumentation.
- Composing custom layouts with SwiftUI — Apples utvecklardokumentation.
- geometryGroup() — Apples utvecklardokumentation.