Per fare in modo che le etichette dei menu a discesa cambino e si riadattino fluidamente, misura ogni controllo alla sua dimensione ideale, disponilo con un algoritmo di a capo condiviso e anima le modifiche alla selezione sul contenitore. Questo tutorial crea un piccolo pannello di controllo per macOS usando opzioni sintetiche.
Separa il riadattamento dall’animazione
L’obiettivo per chi legge è specifico: creare un pannello desktop in cui la scelta di un’etichetta più lunga in un menu a discesa possa spostare i controlli vicini su un’altra riga. I controlli devono mantenere la propria identità mentre cambiano posizione e il pannello deve comunicare la nuova altezza all’interfaccia circostante.
Ci sono due responsabilità distinte. Un Layout personalizzato calcola le dimensioni e le posizioni di destinazione. Un’animazione indica a SwiftUI come passare dalla geometria precedente a quella nuova. Apple documenta Layout come punto di estensione per le disposizioni che richiedono comportamenti più complessi di quelli offerti dai contenitori integrati. I suoi due metodi obbligatori restituiscono le dimensioni del contenitore e posizionano le sue viste secondarie. Layout
In questo esempio, ogni menu a discesa è un Menu le cui azioni aggiornano una selezione. Apple supporta i menu con generatori di etichette personalizzati, così l’etichetta visualizzata può includere sia una categoria sia l’opzione selezionata. Il layout dispone i controlli chiusi nella finestra; questo esempio non cerca di controllare la geometria dei contenuti visualizzati dal menu. Menu
Il progetto segue tre criteri espliciti:
- I controlli mantengono la loro dimensione ideale, invece di comprimere le etichette per restare su una riga.
- I controlli vanno a capo nell’ordine esistente quando il controllo successivo supererebbe la larghezza proposta.
- Un controllo più largo dello spazio disponibile occupa una riga da solo e fa sì che il layout restituisca quella larghezza maggiore.
Quest’ultimo criterio è importante. Mandare a capo una raccolta non può rendere più stretto un singolo controllo indivisibile. Se l’applicazione deve adattare etichette di lunghezza arbitraria a un pannello stretto, serve anche un criterio separato per le etichette, come un progetto che ne limiti intenzionalmente la larghezza. Apple osserva che fixedSize() preserva la dimensione ideale e può quindi far sì che una vista superi i limiti del genitore. fixedSize()
Usa lo stesso calcolo per la misurazione e il posizionamento
Il sistema di layout di SwiftUI trasmette proposte di dimensione lungo la gerarchia. Una proposta con dimensioni non specificate richiede la dimensione ideale di una vista; è diversa da una proposta che contiene zero o infinito. Qui, le misure ideali determinano l’ingombro di ogni controllo, mentre la larghezza proposta dal genitore determina i punti in cui le righe vanno a capo. ProposedViewSize
Il requisito fondamentale è la coerenza: il contenitore deve riservare lo stesso spazio usato dall’algoritmo di posizionamento. Apple richiede esplicitamente calcoli coerenti tra dimensionamento e posizionamento, inclusi spaziatura e risposte alle proposte. Specifica inoltre che i limiti di posizionamento si trovano nello spazio di coordinate del genitore, quindi il posizionamento deve tenere conto di bounds.minX e bounds.minY. placeSubviews(in:proposal:subviews:cache:)
Crea in Xcode un’app SwiftUI per macOS e imposta come destinazione di distribuzione macOS 14 o versione successiva. L’esempio usa Layout, disponibile da macOS 13, e geometryGroup(), disponibile da macOS 14. Inserisci la dichiarazione del layout seguente e il codice dell’app nella sezione successiva in un unico file Swift, sostituendo il punto di ingresso dell’app generato. Insieme, i due blocchi costituiscono l’esempio completo. Disponibilità di Layout, Disponibilità di 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
)
}
}
}
Entrambi i metodi del layout chiamano plan usando la proposta fornita da SwiftUI. Anche la misurazione e il posizionamento usano la stessa proposta per i componenti secondari: .unspecified. L’API di misurazione delle viste secondarie restituisce la dimensione scelta da una vista secondaria per la proposta fornita; l’esempio usa quel risultato invece di stimare la larghezza contando i caratteri. sizeThatFits(_:)
Con una larghezza proposta finita, il layout occupa almeno tale larghezza e restituisce correttamente le dimensioni di eventuali componenti secondari troppo grandi. Con una larghezza non specificata o infinita, restituisce una dimensione ideale finita su una sola riga per questi controlli di dimensioni finite. L’altezza dipende dal contenuto: il layout ignora intenzionalmente l’altezza proposta e restituisce lo spazio richiesto dalle righe.
Questa implementazione usa una cache Void e ricalcola il suo piccolo schema. Apple consiglia di aggiungere una cache solo quando la profilazione dimostra che è utile. Se in seguito memorizzi nella cache le misure, assicurati che ricreare la cache non modifichi il risultato del layout. makeCache(subviews:)
Collega le selezioni che cambiano ai controlli stabili
Il pannello sintetico ha tre categorie: Shape, Pattern e Arrangement. Ogni categoria gestisce la propria stringa selezionata e tutte le opzioni sono definite nell’esempio. L’etichetta cambia all’interno del Menu esistente; il programma non sostituisce un menu ogni volta che cambia una selezione.
Aggiungi questo codice dopo la dichiarazione del layout. Nel progetto deve esserci una sola dichiarazione dell’app @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()
}
}
}
Il modificatore di animazione osserva un array di stringhe selezionate. Apple richiede che il valore osservato sia conforme a Equatable; questo array soddisfa il requisito. Osservare i valori selezionati significa anche che questo modificatore non richiede una nuova animazione solo perché cambia la larghezza della finestra. Il layout ricalcola comunque il ritorno a capo quando cambia la proposta. animation(_:value:)
Gli ID stabili delle categorie collegano ciascun menu tra un aggiornamento e l’altro. ForEach crea viste a partire da dati identificati, mentre modificare l’.id esplicito di una vista ne reimposta l’identità e lo stato associato. Di conseguenza, aggiungere .id(choice.selected) sarebbe in contrasto con la continuità richiesta da questo esempio. Le stringhe delle opzioni sono univoche all’interno di ogni categoria sintetica, quindi id: \.self è adatto alle azioni di quei menu. ForEach, id(_:)
L’esempio di layout personalizzato di Apple dimostra che preservare l’identità strutturale consente a animation(_:value:) di animare le modifiche al layout. Questo esempio applica lo stesso principio a misure che cambiano all’interno di un unico tipo di layout. La stringa della selezione cambia subito; l’animazione prevista riguarda lo spostamento e il ridimensionamento risultanti, non l’interpolazione tra i caratteri di due etichette. Composizione di layout personalizzati con SwiftUI
Il modificatore geometryGroup() crea un confine geometrico attorno a ciascun menu completo. Apple descrive questo confine come un meccanismo che risolve e anima la geometria del genitore prima di trasmetterla ai discendenti. Il suo utilizzo qui segue il meccanismo documentato per far muovere insieme il contenuto interno di ciascun controllo; non determina i punti in cui le righe vanno a capo e non corregge un calcolo errato del ritorno a capo. geometryGroup()
Verifica i punti di interruzione delle righe prima di perfezionare l’animazione
Un piccolo esempio numerico rende verificabile il criterio di disposizione. Si tratta di dimensioni illustrative, non di misure dei controlli di macOS: supponiamo che tre controlli siano larghi rispettivamente 80, 100 e 90 punti, tutti alti 24 punti, con una spaziatura di 8 punti e 300 punti disponibili.
La riga iniziale richiede 286 punti: 80 + 8 + 100 + 8 + 90. Se il controllo centrale cresce fino a 150 punti, la riga completa richiederebbe 336 punti. I primi due controlli entrano ancora, occupando 238 punti, quindi il terzo passa alla riga successiva. La nuova altezza del contenuto è di 56 punti: 24 + 8 + 24.
| Condizione | Risultato previsto da questo algoritmo |
|---|---|
| La larghezza complessiva è esattamente uguale al limite | Mantieni i controlli su quella riga. |
| Il controllo successivo supererebbe il limite | Inizia una nuova riga prima di quel controllo. |
| I controlli hanno altezze diverse | Procedi in base al controllo più alto della riga completata. |
| Un controllo supera la larghezza proposta | Mettilo da solo e restituisci la larghezza maggiore necessaria. |
| La larghezza non è specificata o è infinita | Restituisci una riga alla larghezza ideale complessiva dei controlli. |
| Non ci sono controlli | Restituisci dimensioni pari a zero. |
I calcoli di disposizione sono stati verificati separatamente con dimensioni sintetiche deterministiche, incluse etichette più lunghe, un controllo sovradimensionato e una larghezza non specificata. In questo ambiente l’app SwiftUI non è stata compilata né verificata visivamente. Compilala ed eseguila su macOS per verificare l’aspetto nativo dei controlli e l’animazione.
Nell’app in esecuzione, scegli prima opzioni più lunghe mantenendo fissa la larghezza della finestra. Regola la finestra finché una modifica della selezione non attraversa il limite di una riga, quindi alterna tra le opzioni brevi e quelle lunghe. Verifica che il testo esplicativo sotto i controlli si sposti per adattarsi alla nuova altezza della riga e che tornando alle etichette brevi si elimini lo spazio verticale inutilizzato.
Poi ridimensiona la finestra senza modificare le selezioni. Questo permette di distinguere un difetto nel ritorno a capo da un difetto nell’animazione: posizioni finali errate indicano un problema di misurazione o posizionamento, mentre posizioni finali corrette accompagnate da un movimento indesiderato suggeriscono di controllare l’ambito dell’animazione e i gruppi geometrici.
Diagnostica le condizioni di errore rimanenti
Se le etichette cambiano ma i punti di interruzione delle righe restano invariati, verifica che cosa misura effettivamente il layout. Un contenitore di larghezza fissa attorno a ogni menu può far sì che le etichette brevi e quelle lunghe restituiscano la stessa larghezza esterna. In questa implementazione, ogni menu mantiene intenzionalmente la propria dimensione ideale e il layout misura l’intero controllo invece di stimare separatamente il testo. fixedSize(), sizeThatFits(_:)
Se i controlli si sovrappongono o la sezione successiva copre la seconda riga, confronta prima il dimensionamento e il posizionamento. Usa lo stesso spazio, la stessa proposta per i componenti secondari, la stessa condizione di ritorno a capo e lo stesso calcolo dell’altezza della riga in entrambi i metodi. Non calcolare il dimensionamento con una larghezza e il posizionamento con un’altra. La funzione condivisa plan serve a rendere più difficile introdurre queste discrepanze. placeSubviews(in:proposal:subviews:cache:)
Se i controlli scompaiono e ricompaiono quando cambia una selezione, verifica prima l’identità e solo dopo aggiungi altri modificatori di animazione. Mantieni stabile l’ID della categoria e aggiorna solo la sua selezione. Apple documenta che modificare l’ID esplicito di una vista ne reimposta l’identità, quindi un’etichetta che cambia è una chiave d’identità inadatta per un controllo persistente. id(_:)
Per distribuire l’app su macOS 13, rimuovi .geometryGroup() e mantieni l’implementazione Layout e l’animazione guidata dalla selezione. Il metodo di ritorno a capo resta invariato, ma viene rimosso il confine geometrico documentato disponibile da macOS 14; valuta il movimento risultante sui sistemi supportati. Layout, geometryGroup()
Il comportamento finale si basa su tre decisioni concrete: misurare i controlli effettivi, usare lo stesso schema di righe per le dimensioni restituite e il posizionamento, e preservare l’identità dei controlli quando cambiano le etichette. Quando queste decisioni producono una geometria finale corretta, l’animazione guidata dalla selezione può rendere più chiaro il riadattamento.
Fonti
- Layout — Documentazione per sviluppatori Apple.
- ProposedViewSize — Documentazione per sviluppatori Apple.
- placeSubviews(in:proposal:subviews:cache:) — Documentazione per sviluppatori Apple.
- sizeThatFits(_:) — Documentazione per sviluppatori Apple.
- makeCache(subviews:) — Documentazione per sviluppatori Apple.
- Menu — Documentazione per sviluppatori Apple.
- fixedSize() — Documentazione per sviluppatori Apple.
- animation(_:value:) — Documentazione per sviluppatori Apple.
- ForEach — Documentazione per sviluppatori Apple.
- id(_:) — Documentazione per sviluppatori Apple.
- Composizione di layout personalizzati con SwiftUI — Documentazione per sviluppatori Apple.
- geometryGroup() — Documentazione per sviluppatori Apple.