Macアプリでネストされた項目の展開をアニメーション化するには、DisclosureGroupとアニメーション付きの展開バインディングから始めましょう。展開時の高さを正確に制御する必要がある場合は、スクロールビュー内でカスタムレイアウトを使用します。コンテンツトランジションだけでは、そのジオメトリは定義されません。

このチュートリアルでは、項目の小さな階層を展開して内容を表示し、高さの変化に合わせて下の項目を移動させるという特定のタスクを扱います。例では架空のラベルを使い、アプリ自身のウィンドウ内だけで動作します。

コンテンツがフェードするだけで、目に見えて展開しないのはなぜですか?

結果を診断するときは、展開状態、挿入されたコンテンツの見え方、コンテナが割り当てるスペースの3つを分けて考えます。Appleは、withAnimation、値に基づくアニメーション修飾子、アニメーション付きバインディングを、状態変化をアニメーション化する方法として説明しています。トランジションについては、ビューが階層に入るとき、または階層から離れるときに使うエフェクトとして別途説明しています。アニメーション

不透明度トランジションは透明度を変えます。移動トランジションは、出入りするビューの位置を端に対して変えます。どちらのAPIも、それだけでは周囲のリストが行の高さをどのように補間するかを指定しません。そのため、移動トランジションを追加すると表示時の動きは変わっても、望むレイアウト動作が解決しない場合があります。move(edge:)

これは特定の内部実装の証拠ではなく、診断上の区別として捉えてください。詳細がフェードする一方で後続の行が跳ねるなら、詳細のトランジションとは分けてコンテナのジオメトリを調べます。ドキュメントに記載されたList APIは、選択機能を任意で備える行コンテナについて説明していますが、ネストの構成やリストスタイルを問わず、特定の展開カーブを保証しているわけではありません。List

ネイティブのネストされた開閉グループから始める

一般的なMacの階層には、独自の開閉コントロールを実装する前にDisclosureGroupを使いましょう。これはBooleanバインディングで展開状態を公開し、Appleのドキュメントでは開閉グループの中に別の開閉グループをネストする方法が明示的に示されています。このAPIはmacOS 11以降で利用できます。DisclosureGroup

XcodeでSwiftUIを使ったmacOSアプリのプロジェクトを作成します。生成されたアプリ宣言を次の完全なプログラムに置き換え、ターゲット内の@main宣言は1つだけにしてください。サンプルの内容と2つの展開状態がすべて含まれています。

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

バインディングのanimationメソッドは、値が変わるときにアニメーションを要求します。Collectionをクリックし、Shapesを展開してから、Collectionを閉じてもう一度開いてください。状態を個別に保存するため、親を閉じている間もShapesが展開されていたかどうかをサンプルが記憶できます。これは現在のビュー内のメモリ上の状態であり、アプリを起動し直した後も保持されるものではありません。アニメーション付きバインディングは、Appleがドキュメントで説明しているアニメーションの開始方法の1つです。アニメーション

この例では、説明用のデザイン上の選択として、0.25秒のイージングカーブを使っています。システムのReduce Motion設定が有効な場合はnilを渡します。AppleはaccessibilityReduceMotionを通じてこの設定を公開しており、有効な場合は大きなアニメーションを避けるよう案内しています。accessibilityReduceMotion

アプリがサポートするmacOSの各リリースとリストスタイルで、ネイティブ版を評価してください。挙動が要件を満たすなら、そのまま使いましょう。開閉表示だけを調整する必要がある場合は、macOS 13以降でDisclosureGroupStyleによるサポート対象のカスタマイズができます。ただし、カスタムスタイルもビューを組み合わせる仕組みであり、周囲のリストの行アニメーションを任意に制御できるとはドキュメントで保証されていません。DisclosureGroupStyle

必要な結果に応じてコンテナを選ぶ

修飾子を追加する前に、次の判断基準を使ってください。ネイティブの行操作と、明示的に制御する表示領域を分けて考えられます。

必要な結果 出発点 確認すること
選択可能な一般的な展開行 ListとDisclosureGroup 実際のmacOSバージョンとリストスタイルでの展開動作
開閉ラベルやコントロールの見た目を変更 DisclosureGroupStyle 周囲のコンテナで望むジオメトリが得られるか
小さな階層を指定した高さで表示 ScrollView、VStack、アニメーション可能なLayout ネストしたサイズ変更、クリッピング、コントロールの動作
列とデリゲートを使う既存のAppKitアウトライン NSOutlineView 独自の展開APIとデータソースの動作

以下のカスタム方式では、スクロールビュー内でスタックを意図的に使います。独自の開閉ボタンとジオメトリを用意するため、インターフェイスに必要なリスト選択や行コマンドも自分で実装する必要があります。Appleは選択機能をListの機能として説明しているため、コンテナを切り替えると出発点も変わります。List

カスタムレイアウトで表示する高さをアニメーション化する

macOS 13以降では、Layoutプロトコルにより、コンテナがサイズを計算して子ビューを配置できます。また、このプロトコルはAnimatableを継承しています。これらは、挿入時のエフェクトだけに頼らず、展開量を補間される数値にするための、ドキュメントに記載された構成要素です。Layout

次のオリジナル実装では、表示領域ごとに1つの子スタックを使います。利用可能な幅で高さを指定せずにそのスタックを測定し、測定した高さの一部を報告します。表示領域が変化しても、子ビューは上端に配置されたままです。Appleは、提案サイズを使ってサブビューを測定する方法と、一致する提案サイズで配置する方法の両方を説明しています。sizeThatFits(_:)、place(at:anchor:proposal:)

前の例と置き換える完全なプログラムとして、こちらを使ってください。デプロイメントターゲットをmacOS 13以降に設定します。コードはドキュメントに記載されたAPIと照らして確認しましたが、この環境にはSwiftの実行ファイルも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)
        )
    }
}

重要な値はprogressです。アニメーション中、SwiftUIは古いanimatableDataの値と新しい値を補間します。ここではその値によって、報告される高さが子ビューの測定済みの高さとゼロの間で連続的に変化します。例として、測定した高さが160ポイントなら、進行度0.5で80ポイントの表示になります。これらの数値は算術上の例であり、アプリで測定した結果ではありません。Animatable

高さを指定しないことで、現在の表示領域の高さに合わせて子ビューを縮めるのではなく、提案された幅で理想の高さを求めます。折り返しが発生するラベルでは、この点が重要です。表示領域の変化に合わせて文字を徐々に押しつぶすのではなく、レイアウトされた状態を表示できるからです。Appleは、次元を未指定にすることを理想的なサイズを取得する方法として説明しています。ProposedViewSize

その後、クリッピングによって、報告された境界の外側にある部分を隠します。クリッピングをしないと、コンテンツがレイアウトで確保された領域の外にも描画される可能性があります。折りたたむ高さに含めるため、例のように下側のパディングは表示領域の内側に置いてください。clipped(antialiased:)

この実装では、折りたたんだ後も子ビューの階層を意図的に保持します。操作を無効にし、折りたたんだ領域をアクセシビリティ表現から除外します。表示されるボタンは展開状態を報告します。ヒットテストとアクセシビリティ上の表示は別々の制御なので、クリッピングだけでは不十分です。allowsHitTesting(_:)、accessibilityHidden(_:)

カスタム版を採用する前にネスト時の動作を確認する

次の手順で、単純な状態で見た目が整うだけのアニメーションと、正しく機能する表示領域を見分けてください。

  1. Collectionを展開し、続けてShapesを展開します。両方の階層が変化するときにFollowing itemを観察します。
  2. Shapesを開いたままCollectionを折りたたみ、もう一度開きます。意図した子の展開状態が保たれていることを確認します。
  3. アニメーションが終わる前に、開閉ボタンをもう一度クリックします。隙間を残さず、表示領域の変化が反転することを確認します。
  4. ウィンドウの幅を狭め、サンプルラベルを長めの架空のラベルに置き換えます。折り返しと、最終的に表示される高さを確認します。
  5. Reduce Motionを有効にし、アニメーションによる移動がなくても展開操作を利用できることを確認します。
  6. 展開グループと折りたたみグループの両方について、キーボード操作とアクセシビリティ表現を確認します。

このカスタムレイアウトは、これらのラベルやネストされたスタックのように、高さが有限のコンテンツを想定しています。縦方向の高さに上限がないスクロール領域を表示領域の中に入れ、有用な全コンテンツの高さが得られると期待するのは避けてください。この実装は理想の高さを明示的に要求するため、子ビューはその提案に対して意味のある値を返せる必要があります。これは選択したサイズ設計による制約です。ProposedViewSize

コンテンツを保持すると、構築とレイアウトのコストも残ります。小さな階層には通常のVStackから始め、このような全要素を先行生成する例を大きなツリーに適用する前に、別のアーキテクチャも評価してください。データ駆動の行では、展開や並べ替えをまたいでモデル識別子が安定するようにしてください。SwiftのIdentifiableプロトコルはエンティティの安定した識別情報を表し、ForEachは識別可能なデータを使ってビューを生成します。Identifiable、ForEach

AppKitとTextKitはどのような場合に使いますか?

すでにAppKitを使っているインターフェイスでは、NSOutlineViewが行と列で階層を表示するためのドキュメント記載のコントロールです。そのexpandItem(_:)メソッドは項目を展開し、すでに展開済みの場合や展開できない場合は何もしません。このメソッドにはSwiftUIのアニメーションカーブを指定するパラメータがないため、上で示したカスタムのタイミングを提供すると想定しないでください。NSOutlineView、expandItem(_:)

TextKitは、NSTextViewで使われる仕組みを含め、テキストの保存、レイアウト、描画を管理します。通常のラベルからなる階層では、展開するコンテナとは別の役割です。コンテンツ自体にテキスト編集やカスタムのテキストレイアウトが必要な場合に使います。SwiftUIのリスト展開をアニメーション化するためのスイッチとして、ドキュメントに記載されたものではありません。TextKit

ネイティブの開閉グループから始め、対象のMacインターフェイスでの動作を確認してください。高さを制御した表示領域が必要な場合、カスタムレイアウトを使うと、その高さに明示的なアニメーション可能な値を設定できます。ネイティブのリスト動作、カスタムの表示ジオメトリ、既存のAppKitアウトラインのうち、必要な操作に合わせて選びましょう。

参考資料