To animate nested expansion in your Mac app, start with DisclosureGroup and an animated expansion binding. If you need precise control over the expanding height, use a custom layout in a scroll view; a content transition alone does not define that geometry.

This tutorial addresses a specific task: expanding a small hierarchy of items so that its contents are revealed and the items below move with the changing height. The examples use synthetic labels and operate entirely within the app’s own window.

Why does the content fade without visibly expanding?

Separate three things when diagnosing the result: the expansion state, the appearance of inserted content, and the space the container allocates. Apple documents withAnimation, value-driven animation modifiers, and animated bindings as ways to animate state changes. It documents transitions separately as the effects used when views enter or leave the hierarchy. Animations

An opacity transition changes transparency. A movement transition changes the entering or exiting view’s position relative to an edge. Neither API, by itself, specifies how a surrounding list must interpolate its row heights. Adding a movement transition can therefore change the visible entrance without resolving the particular layout behavior you want. move(edge:)

Treat this as a diagnostic distinction, rather than proof of a particular internal implementation: if the details fade while the following row jumps, investigate the container’s geometry separately from the details’ transition. The documented List API describes a row container with optional selection; it does not promise a particular expansion curve for every nested arrangement and list style. List

Start with native nested disclosure groups

For a conventional Mac hierarchy, use DisclosureGroup before implementing your own disclosure control. It exposes expansion through a Boolean binding, and Apple’s documentation explicitly demonstrates nesting one disclosure group inside another. The API is available on macOS 11 and later. DisclosureGroup

Create a macOS app project in Xcode using SwiftUI. Replace the generated app declaration with the following complete program, keeping exactly one @main declaration in the target. It includes all sample content and both expansion states.

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

The binding’s animation method requests animation when its value changes. Click Collection, expand Shapes, then collapse and reopen Collection. The independently stored state lets the sample remember whether Shapes was expanded while its parent was closed. This is in-memory state for the current view, not persistence across app launches. Animated bindings are one of Apple’s documented animation entry points. Animations

The example uses a quarter-second easing curve as an illustrative design choice. It passes nil when the system’s Reduce Motion preference is enabled. Apple exposes that preference through accessibilityReduceMotion and advises avoiding large animations when it is true. accessibilityReduceMotion

Evaluate the native version on each macOS release and list style your app supports. If its behavior meets the task, keep it. If only the disclosure appearance needs adjustment, DisclosureGroupStyle provides a supported customization point on macOS 13 and later. A custom style still composes views; its documentation does not promise arbitrary control of the surrounding list’s row animation. DisclosureGroupStyle

Choose the container according to the required outcome

Use this decision aid before adding more modifiers. It separates a native row interaction from an explicitly controlled reveal.

Required outcome Starting point What to verify
Conventional expandable rows with list selection List and DisclosureGroup Expansion behavior in the actual macOS version and list style
Different disclosure label or control appearance DisclosureGroupStyle Whether the surrounding container still produces the desired geometry
A precisely defined reveal height for a small hierarchy ScrollView, VStack, and an animatable Layout Nested resizing, clipping, and control behavior
An existing AppKit outline with columns and delegates NSOutlineView Its own expansion APIs and data-source behavior

The custom approach below deliberately uses a stack inside a scroll view. It supplies its own disclosure buttons and geometry, so it also requires you to implement any list selection or row commands your interface needs. Apple documents selection as a capability of List; switching containers changes that starting point. List

Animate the revealed height with a custom layout

For macOS 13 and later, the Layout protocol lets a container calculate its size and place its children. It also inherits from Animatable. These are the documented building blocks for making the expansion amount an interpolated number instead of relying solely on insertion effects. Layout

The following original implementation uses one child stack per reveal. It measures that stack at the available width with an unspecified height, then reports a fraction of the measured height. The child remains positioned at the top while the visible region changes. Apple documents both measuring a subview with a proposal and placing it with a matching proposal. sizeThatFits(_:), place(at:anchor:proposal:)

Use this as an alternative complete program, replacing the previous example. Set the deployment target to macOS 13 or later. The code was inspected against the documented APIs; it was not compiled or visually tested here because this environment has no Swift executable or macOS runtime.

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

The crucial value is progress. SwiftUI interpolates the old and new animatableData values during an animation. Here, that changes the reported height continuously between zero and the child’s measured height. For illustration, a measured height of 160 points produces an 80-point reveal at progress 0.5; those numbers are arithmetic examples, not measured app results. Animatable

The unspecified height asks the child for its ideal height at the proposed width, rather than asking it to compress into the current reveal height. This matters for wrapping labels: the reveal should expose their layout, not progressively squeeze it. Apple describes unspecified dimensions as a way to obtain ideal sizing. ProposedViewSize

Clipping then hides the part outside the reported bounds. Without it, content can draw beyond the region reserved by layout. Keep the bottom padding inside the reveal, as shown, so it contributes to the height being collapsed. clipped(antialiased:)

This implementation intentionally retains the child hierarchy when collapsed. It disables interaction and removes the collapsed region from the accessibility representation; the visible button reports its expansion state. Hit testing and accessibility visibility are separate controls, so clipping alone is insufficient. allowsHitTesting(_:), accessibilityHidden(_:)

Check nested behavior before adopting the custom version

Use the following sequence to distinguish a working reveal from an animation that only looks correct in the simplest state.

  1. Expand Collection, then Shapes. Watch Following item while both levels change.
  2. Collapse Collection while Shapes is open, then reopen it. Confirm the intended child expansion state remains.
  3. Click a disclosure button again before its animation finishes. Check that the reveal reverses without leaving a gap.
  4. Narrow the window and replace a sample label with a longer synthetic label. Check wrapping and the final revealed height.
  5. Enable Reduce Motion and confirm expansion remains usable without the animated movement.
  6. Check keyboard activation and the accessibility representation of both expanded and collapsed groups.

The custom layout is designed for finite-height content such as these labels and nested stacks. Avoid putting a vertically unbounded scrolling region inside the reveal and expecting it to supply a useful full content height. The implementation explicitly requests an ideal height, so the child must have a meaningful answer to that proposal. This is a consequence of the chosen sizing design. ProposedViewSize

Retaining collapsed content also retains its construction and layout costs. Start with an ordinary VStack for a small hierarchy; evaluate another architecture before applying this eager example to a large tree. For data-driven rows, keep model identifiers stable across expansion and reordering. Swift’s Identifiable protocol represents stable entity identity, and ForEach uses identified data to produce views. Identifiable, ForEach

Where do AppKit and TextKit fit?

If the interface already uses AppKit, NSOutlineView is the documented hierarchical row-and-column control. Its expandItem(_:) method expands an item and does nothing when the item is already expanded or cannot expand. That method does not expose a SwiftUI animation curve parameter, so do not assume it provides the custom timing demonstrated above. NSOutlineView, expandItem(_:)

TextKit manages text storage, text layout, and rendering, including the machinery used by NSTextView. For a hierarchy of ordinary labels, that makes it a separate concern from the expanding container. Use it when the content itself needs text editing or custom text layout; it is not a documented switch for animating SwiftUI list expansion. TextKit

Begin with native disclosure groups and verify their behavior in your target Mac interface. When the task requires a controlled height reveal, the custom layout gives that height an explicit animatable value. Keep the choice tied to the interaction you need: native list behavior, custom reveal geometry, or an existing AppKit outline.

Sources