Para animar a expansão aninhada no seu app para Mac, comece com DisclosureGroup e um binding de expansão animado. Se precisar de controle preciso sobre a altura da expansão, use um layout personalizado em uma visualização de rolagem; uma transição de conteúdo, por si só, não define essa geometria.

Este tutorial aborda uma tarefa específica: expandir uma pequena hierarquia de itens para revelar seu conteúdo e fazer com que os itens abaixo se movam conforme a altura muda. Os exemplos usam rótulos sintéticos e funcionam inteiramente dentro da própria janela do app.

Por que o conteúdo desaparece sem se expandir visivelmente?

Ao diagnosticar o resultado, separe três aspectos: o estado da expansão, a aparência do conteúdo inserido e o espaço alocado pelo contêiner. A Apple documenta withAnimation, modificadores de animação orientados a valores e bindings animados como formas de animar mudanças de estado. As transições são documentadas separadamente como efeitos usados quando as visualizações entram ou saem da hierarquia. Animações

Uma transição de opacidade altera a transparência. Uma transição de movimento altera a posição da visualização que entra ou sai em relação a uma borda. Nenhuma dessas APIs, por si só, especifica como uma lista ao redor deve interpolar as alturas de suas linhas. Assim, adicionar uma transição de movimento pode alterar a entrada visível sem resolver o comportamento de layout específico que você quer. move(edge:)

Trate isso como uma distinção para diagnóstico, não como prova de uma implementação interna específica: se os detalhes desaparecem enquanto a linha seguinte salta, investigue a geometria do contêiner separadamente da transição dos detalhes. A API List documentada descreve um contêiner de linhas com seleção opcional; ela não promete uma curva de expansão específica para todos os arranjos aninhados e estilos de lista. List

Comece com grupos de divulgação nativos aninhados

Para uma hierarquia convencional no Mac, use DisclosureGroup antes de implementar seu próprio controle de divulgação. Ele expõe a expansão por meio de um binding booleano, e a documentação da Apple demonstra explicitamente o aninhamento de um grupo de divulgação dentro de outro. A API está disponível no macOS 11 e versões posteriores. DisclosureGroup

Crie um projeto de app para macOS no Xcode usando SwiftUI. Substitua a declaração de app gerada pelo programa completo a seguir, mantendo exatamente uma declaração @main no destino. Ele inclui todo o conteúdo de exemplo e os dois estados de expansão.

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

O método animation do binding solicita uma animação quando seu valor muda. Clique em Collection, expanda Shapes e, em seguida, recolha e reabra Collection. O estado armazenado de forma independente permite que o exemplo lembre se Shapes estava expandido enquanto o elemento pai estava fechado. Esse estado fica na memória da visualização atual; não persiste entre inicializações do app. Bindings animados são um dos pontos de entrada para animações documentados pela Apple. Animações

O exemplo usa uma curva de suavização de um quarto de segundo como escolha ilustrativa de design. Ele passa nil quando a preferência do sistema Reduce Motion está ativada. A Apple disponibiliza essa preferência por meio de accessibilityReduceMotion e recomenda evitar animações amplas quando ela está ativada. accessibilityReduceMotion

Avalie a versão nativa em cada versão do macOS e estilo de lista compatível com seu app. Se o comportamento atender à tarefa, mantenha-o. Se apenas a aparência da divulgação precisar de ajustes, DisclosureGroupStyle oferece um ponto de personalização compatível no macOS 13 e versões posteriores. Um estilo personalizado ainda compõe visualizações; sua documentação não promete controle arbitrário sobre a animação das linhas da lista ao redor. DisclosureGroupStyle

Escolha o contêiner conforme o resultado necessário

Use este guia de decisão antes de adicionar mais modificadores. Ele distingue uma interação nativa de linha de uma revelação controlada explicitamente.

Resultado necessário Ponto de partida O que verificar
Linhas expansíveis convencionais com seleção na lista List e DisclosureGroup Comportamento da expansão na versão real do macOS e no estilo de lista
Aparência diferente para o rótulo ou controle de divulgação DisclosureGroupStyle Se o contêiner ao redor ainda produz a geometria desejada
Altura de revelação definida com precisão para uma hierarquia pequena ScrollView, VStack e um Layout animável Redimensionamento aninhado, recorte e comportamento dos controles
Estrutura de tópicos AppKit existente, com colunas e delegados NSOutlineView APIs próprias de expansão e comportamento da fonte de dados

A abordagem personalizada abaixo usa deliberadamente uma pilha dentro de uma visualização de rolagem. Ela fornece seus próprios botões de divulgação e geometria, então também exige que você implemente qualquer seleção de lista ou comando de linha de que sua interface precise. A Apple documenta a seleção como um recurso de List; trocar de contêiner muda esse ponto de partida. List

Anime a altura revelada com um layout personalizado

No macOS 13 e versões posteriores, o protocolo Layout permite que um contêiner calcule seu tamanho e posicione os elementos filhos. Ele também herda de Animatable. Esses são os componentes documentados para tornar a quantidade de expansão um número interpolado, em vez de depender apenas dos efeitos de inserção. Layout

A implementação original a seguir usa uma pilha filha para cada revelação. Ela mede essa pilha na largura disponível e com altura não especificada, e então informa uma fração da altura medida. O elemento filho permanece posicionado no topo enquanto a região visível muda. A Apple documenta tanto a medição de uma subvisualização com uma proposta quanto seu posicionamento com uma proposta correspondente. sizeThatFits(_:), place(at:anchor:proposal:)

Use este programa completo como alternativa, substituindo o exemplo anterior. Defina o destino de implantação como macOS 13 ou posterior. O código foi conferido com base nas APIs documentadas; ele não foi compilado nem testado visualmente aqui porque este ambiente não tem executável Swift nem ambiente de execução do 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)
        )
    }
}

O valor crucial é progress. O SwiftUI interpola os valores antigos e novos de animatableData durante uma animação. Aqui, isso altera continuamente a altura informada, de zero até a altura medida do elemento filho. Por exemplo, uma altura medida de 160 pontos produz uma revelação de 80 pontos no progresso de 0,5; esses valores são exemplos aritméticos, não resultados medidos no app. Animatable

A altura não especificada solicita ao elemento filho sua altura ideal na largura proposta, em vez de pedir que ele seja comprimido na altura de revelação atual. Isso importa para rótulos com quebra de linha: a revelação deve exibir o layout deles, sem comprimi-los progressivamente. A Apple descreve dimensões não especificadas como uma forma de obter o dimensionamento ideal. ProposedViewSize

O recorte então oculta a parte que fica fora dos limites informados. Sem ele, o conteúdo pode ser desenhado além da região reservada pelo layout. Mantenha o espaçamento inferior dentro da revelação, como mostrado, para que ele contribua para a altura recolhida. clipped(antialiased:)

Esta implementação mantém intencionalmente a hierarquia filha quando recolhida. Ela desativa a interação e remove a região recolhida da representação de acessibilidade; o botão visível informa seu estado de expansão. A detecção de toques e a visibilidade para acessibilidade são controles separados, portanto o recorte, por si só, não é suficiente. allowsHitTesting(_:), accessibilityHidden(_:)

Verifique o comportamento aninhado antes de adotar a versão personalizada

Use a sequência a seguir para distinguir uma revelação funcional de uma animação que só parece correta no estado mais simples.

  1. Expanda Collection e depois Shapes. Observe Following item enquanto os dois níveis mudam.
  2. Recolha Collection enquanto Shapes estiver aberto e, depois, reabra-a. Confirme que o estado de expansão pretendido do elemento filho permanece.
  3. Clique em um botão de divulgação novamente antes que a animação termine. Verifique se a revelação se inverte sem deixar um espaço vazio.
  4. Reduza a largura da janela e substitua um rótulo de exemplo por um rótulo sintético mais longo. Verifique a quebra de linha e a altura final revelada.
  5. Ative Reduce Motion e confirme que a expansão continua utilizável sem o movimento animado.
  6. Verifique a ativação pelo teclado e a representação de acessibilidade dos grupos expandidos e recolhidos.

O layout personalizado foi projetado para conteúdo de altura finita, como esses rótulos e pilhas aninhadas. Evite colocar uma região de rolagem verticalmente ilimitada dentro da revelação esperando que ela forneça uma altura total útil do conteúdo. A implementação solicita explicitamente uma altura ideal, então o elemento filho precisa ter uma resposta significativa para essa proposta. Isso decorre do design de dimensionamento escolhido. ProposedViewSize

Manter o conteúdo recolhido também mantém os custos de criação e layout. Comece com um VStack comum para uma hierarquia pequena; avalie outra arquitetura antes de aplicar este exemplo que cria tudo antecipadamente a uma árvore grande. Para linhas orientadas por dados, mantenha estáveis os identificadores do modelo durante a expansão e a reordenação. O protocolo Identifiable do Swift representa a identidade estável de uma entidade, e ForEach usa dados identificados para produzir visualizações. Identifiable, ForEach

Quando usar AppKit e TextKit?

Se a interface já usa AppKit, NSOutlineView é o controle documentado para linhas e colunas hierárquicas. Seu método expandItem(_:) expande um item e não faz nada quando o item já está expandido ou não pode ser expandido. Esse método não expõe um parâmetro de curva de animação do SwiftUI, então não presuma que ele oferece o tempo personalizado demonstrado acima. NSOutlineView, expandItem(_:)

O TextKit gerencia o armazenamento, o layout e a renderização de texto, incluindo os mecanismos usados por NSTextView. Para uma hierarquia de rótulos comuns, isso é uma questão separada do contêiner expansível. Use-o quando o próprio conteúdo precisar de edição de texto ou layout de texto personalizado; ele não é um recurso documentado para animar a expansão de listas SwiftUI. TextKit

Comece com grupos de divulgação nativos e verifique o comportamento deles na interface para Mac de destino. Quando a tarefa exigir uma revelação com altura controlada, o layout personalizado atribui um valor animável explícito a essa altura. Escolha conforme a interação necessária: comportamento nativo de lista, geometria de revelação personalizada ou uma estrutura de tópicos AppKit existente.

Fontes