Per animare l’espansione degli elementi annidati nella tua app per Mac, inizia con DisclosureGroup e un binding di espansione animato. Se hai bisogno di un controllo preciso sull’altezza di espansione, usa un layout personalizzato in una vista di scorrimento; una transizione del contenuto, da sola, non definisce quella geometria.
Questo tutorial affronta un compito specifico: espandere una piccola gerarchia di elementi per mostrarne i contenuti e spostare gli elementi sottostanti man mano che l’altezza cambia. Gli esempi usano etichette sintetiche e operano interamente all’interno della finestra dell’app.
Perché il contenuto sfuma senza espandersi visibilmente?
Quando analizzi il risultato, distingui tre aspetti: lo stato di espansione, l’aspetto del contenuto inserito e lo spazio allocato dal contenitore. Apple documenta withAnimation, i modificatori di animazione basati sui valori e i binding animati come metodi per animare le modifiche di stato. Documenta separatamente le transizioni come effetti usati quando le viste entrano o escono dalla gerarchia. Animazioni
Una transizione di opacità modifica la trasparenza. Una transizione di movimento modifica la posizione della vista in entrata o in uscita rispetto a un bordo. Nessuna delle due API, da sola, specifica in che modo un elenco circostante debba interpolare l’altezza delle righe. Aggiungere una transizione di movimento può quindi modificare l’ingresso visibile senza risolvere il comportamento di layout che desideri. move(edge:)
Considerala una distinzione utile per la diagnosi, non una prova di una specifica implementazione interna: se i dettagli sfumano mentre la riga successiva salta, analizza la geometria del contenitore separatamente dalla transizione dei dettagli. L’API documentata di List descrive un contenitore di righe con selezione facoltativa; non garantisce una particolare curva di espansione per ogni combinazione di elementi annidati e stile dell’elenco. List
Inizia con gruppi di rivelazione annidati nativi
Per una gerarchia convenzionale su Mac, usa DisclosureGroup prima di implementare un controllo di rivelazione personalizzato. Espone l’espansione tramite un binding Booleano e la documentazione di Apple mostra esplicitamente come annidare un gruppo di rivelazione dentro un altro. L’API è disponibile da macOS 11 in poi. DisclosureGroup
Crea in Xcode un progetto di app macOS con SwiftUI. Sostituisci la dichiarazione dell’app generata con il programma completo seguente, mantenendo esattamente una dichiarazione @main nel target. Include tutti i contenuti di esempio e entrambi gli stati di espansione.
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")
}
}
}
Il metodo animation del binding richiede un’animazione quando il suo valore cambia. Fai clic su Collection, espandi Shapes, quindi chiudi e riapri Collection. Lo stato archiviato separatamente consente all’esempio di ricordare se Shapes era espanso mentre il gruppo padre era chiuso. Si tratta di stato in memoria per la vista corrente, non di persistenza tra un avvio e l’altro dell’app. I binding animati sono uno dei metodi di animazione documentati da Apple. Animazioni
L’esempio usa una curva di easing di un quarto di secondo come scelta progettuale illustrativa. Passa nil quando la preferenza di sistema Reduce Motion è attiva. Apple espone questa preferenza tramite accessibilityReduceMotion e consiglia di evitare animazioni ampie quando è attiva. accessibilityReduceMotion
Valuta la versione nativa per ogni versione di macOS e stile dell’elenco supportati dalla tua app. Se il suo comportamento soddisfa le esigenze, mantienila. Se devi modificare soltanto l’aspetto della rivelazione, DisclosureGroupStyle offre un punto di personalizzazione supportato da macOS 13 in poi. Uno stile personalizzato compone comunque delle viste; la relativa documentazione non garantisce un controllo arbitrario sull’animazione delle righe dell’elenco circostante. DisclosureGroupStyle
Scegli il contenitore in base al risultato richiesto
Prima di aggiungere altri modificatori, usa questo schema decisionale. Distingue l’interazione nativa con le righe da una rivelazione controllata esplicitamente.
| Risultato richiesto | Punto di partenza | Cosa verificare |
|---|---|---|
| Righe espandibili convenzionali con selezione nell’elenco | List e DisclosureGroup |
Comportamento dell’espansione nella versione effettiva di macOS e nello stile dell’elenco |
| Etichetta o aspetto del controllo di rivelazione diversi | DisclosureGroupStyle |
Se il contenitore circostante mantiene la geometria desiderata |
| Altezza di rivelazione definita con precisione per una piccola gerarchia | ScrollView, VStack e un Layout animabile |
Ridimensionamento annidato, ritaglio e comportamento dei controlli |
| Struttura AppKit esistente con colonne e delegati | NSOutlineView |
API di espansione e comportamento dell’origine dati |
L’approccio personalizzato seguente usa deliberatamente uno stack all’interno di una vista di scorrimento. Fornisce i propri pulsanti di rivelazione e la propria geometria, quindi richiede anche di implementare eventuali comandi per le righe o funzionalità di selezione che l’interfaccia deve offrire. Apple documenta la selezione come funzionalità di List; passando a un altro contenitore cambia anche il punto di partenza. List
Anima l’altezza del contenuto rivelato con un layout personalizzato
Da macOS 13 in poi, il protocollo Layout consente a un contenitore di calcolare le proprie dimensioni e posizionare i figli. Inoltre eredita da Animatable. Sono i componenti documentati per rendere la quantità di espansione un valore interpolato, anziché affidarsi esclusivamente agli effetti di inserimento. Layout
La seguente implementazione originale usa uno stack figlio per ogni rivelazione. Misura lo stack alla larghezza disponibile con un’altezza non specificata, quindi restituisce una frazione dell’altezza misurata. Il figlio resta posizionato in alto mentre cambia la regione visibile. Apple documenta sia la misurazione di una subview con una proposta sia il suo posizionamento con una proposta corrispondente. sizeThatFits(_:), place(at:anchor:proposal:)
Usa questo programma completo come alternativa, sostituendo l’esempio precedente. Imposta il target di distribuzione su macOS 13 o successivo. Il codice è stato controllato rispetto alle API documentate, ma non è stato compilato né testato visivamente qui, perché questo ambiente non dispone di un eseguibile Swift né di un 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)
)
}
}
Il valore cruciale è progress. SwiftUI interpola i valori vecchi e nuovi di animatableData durante un’animazione. In questo caso, l’altezza restituita cambia continuamente da zero all’altezza misurata del figlio. A titolo illustrativo, un’altezza misurata di 160 punti produce una rivelazione di 80 punti con un avanzamento pari a 0,5; questi numeri sono esempi aritmetici, non risultati misurati nell’app. Animatable
L’altezza non specificata chiede al figlio la sua altezza ideale alla larghezza proposta, anziché chiedergli di comprimersi nell’altezza attualmente rivelata. Questo è importante per le etichette che vanno a capo: la rivelazione dovrebbe mostrare il loro layout, non comprimerle progressivamente. Apple descrive le dimensioni non specificate come un modo per ottenere dimensioni ideali. ProposedViewSize
Il ritaglio nasconde quindi la parte esterna ai limiti dichiarati. Senza di esso, il contenuto può essere disegnato oltre la regione riservata dal layout. Mantieni la spaziatura inferiore all’interno della rivelazione, come mostrato, così contribuirà all’altezza che viene ridotta. clipped(antialiased:)
Questa implementazione mantiene intenzionalmente la gerarchia figlia anche quando è chiusa. Disabilita l’interazione e rimuove la regione chiusa dalla rappresentazione di accessibilità; il pulsante visibile comunica il proprio stato di espansione. Hit testing e visibilità per l’accessibilità sono controlli distinti, quindi il solo ritaglio non basta. allowsHitTesting(_:), accessibilityHidden(_:)
Verifica il comportamento annidato prima di adottare la versione personalizzata
Segui questa sequenza per distinguere una rivelazione funzionante da un’animazione che sembra corretta soltanto nello stato più semplice.
- Espandi
Collection, quindiShapes. OsservaFollowing itemmentre cambiano entrambi i livelli. - Chiudi
CollectionmentreShapesè aperto, quindi riaprilo. Verifica che lo stato di espansione del figlio resti quello previsto. - Fai di nuovo clic su un pulsante di rivelazione prima che la sua animazione termini. Controlla che la rivelazione si inverta senza lasciare spazi vuoti.
- Riduci la larghezza della finestra e sostituisci un’etichetta di esempio con un’etichetta sintetica più lunga. Controlla l’a capo e l’altezza finale rivelata.
- Attiva
Reduce Motione verifica che l’espansione resti utilizzabile senza movimento animato. - Verifica l’attivazione da tastiera e la rappresentazione di accessibilità dei gruppi aperti e chiusi.
Il layout personalizzato è progettato per contenuti di altezza finita, come queste etichette e gli stack annidati. Evita di inserire nella rivelazione una regione di scorrimento verticale senza limiti e aspettarti che fornisca un’altezza utile per l’intero contenuto. L’implementazione richiede esplicitamente un’altezza ideale, quindi il figlio deve poter fornire una risposta significativa a quella proposta. È una conseguenza del modello di dimensionamento scelto. ProposedViewSize
Mantenere il contenuto chiuso significa anche mantenerne i costi di creazione e layout. Per una piccola gerarchia, inizia con un normale VStack; valuta un’altra architettura prima di applicare questo esempio che crea tutti i contenuti a un albero di grandi dimensioni. Per le righe basate sui dati, mantieni stabili gli identificatori del modello durante l’espansione e il riordinamento. Il protocollo Swift Identifiable rappresenta l’identità stabile di un’entità e ForEach usa dati identificati per creare viste. Identifiable, ForEach
Quale ruolo hanno AppKit e TextKit?
Se l’interfaccia usa già AppKit, NSOutlineView è il controllo documentato per righe e colonne gerarchiche. Il suo metodo expandItem(_:) espande un elemento e non fa nulla se è già espanso o non può espandersi. Questo metodo non espone un parametro per la curva di animazione SwiftUI, quindi non dare per scontato che offra la temporizzazione personalizzata mostrata sopra. NSOutlineView, expandItem(_:)
TextKit gestisce l’archiviazione del testo, il layout del testo e il rendering, compresi i meccanismi usati da NSTextView. Per una gerarchia di normali etichette, si tratta quindi di un aspetto distinto dal contenitore che si espande. Usalo quando il contenuto richiede modifica del testo o un layout testuale personalizzato; non è un’opzione documentata per animare l’espansione degli elenchi SwiftUI. TextKit
Inizia con gruppi di rivelazione nativi e verifica il loro comportamento nell’interfaccia Mac di destinazione. Quando il compito richiede una rivelazione con altezza controllata, il layout personalizzato assegna a quell’altezza un valore animabile esplicito. Scegli in base all’interazione necessaria: comportamento nativo dell’elenco, geometria di rivelazione personalizzata o struttura AppKit esistente.
Fonti
- DisclosureGroup — Apple
- Animazioni — 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