若要在 Mac app 中為巢狀展開加入動畫,請從 DisclosureGroup 和帶有動畫的展開繫結開始。若需要精確控制展開高度,請在捲動檢視中使用自訂版面配置;僅靠內容轉場無法定義該幾何尺寸。

本教學說明一項特定工作:展開小型項目階層,顯示其內容,並讓下方項目隨高度變化而移動。範例使用合成標籤,且完全在 app 自身的視窗內運作。

為什麼內容會淡入,卻看不出展開效果?

診斷結果時,請區分三件事:展開狀態、新插入內容的外觀,以及容器配置的空間。Apple 說明,withAnimation、由值驅動的動畫修飾子,以及動畫繫結都可用來為狀態變更加入動畫。Apple 另外將轉場說明為檢視進入或離開階層時使用的效果。動畫

透明度轉場會改變透明度。移動轉場會改變進入或離開檢視相對於某個邊緣的位置。這兩種 API 本身都沒有指定周圍清單必須如何內插列高。因此,加入移動轉場可能會改變進場時的可見效果,卻無法解決你所需的特定版面配置行為。move(edge:)

請將此視為診斷上的區別,而非特定內部實作方式的證明:如果詳細內容淡入,而後續列突然跳動,請將容器幾何尺寸與詳細內容的轉場分開調查。文件所述的 List API 是具有選取功能(可選)的列容器,並未保證每種巢狀排列和清單樣式都會呈現特定的展開曲線。List

從原生巢狀揭露群組開始

對於一般的 Mac 階層,請先使用 DisclosureGroup,再考慮自行實作揭露控制項。它透過布林繫結提供展開狀態,而 Apple 的文件也明確示範如何將一個揭露群組巢狀放入另一個揭露群組中。macOS 11 及以上版本皆可使用此 API。DisclosureGroup

在 Xcode 中使用 SwiftUI 建立 macOS app 專案。將產生的 app 宣告替換為以下完整程式,並確保目標中恰好只有一個 @main 宣告。程式包含所有範例內容和兩種展開狀態。

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 是否已展開。這是目前檢視中的記憶體狀態,不會在 app 啟動之間保存。動畫繫結是 Apple 文件列出的動畫入口之一。動畫

此範例使用四分之一秒的緩動曲線作為設計示例。若系統啟用 Reduce Motion 偏好設定,程式會傳入 nil。Apple 透過 accessibilityReduceMotion 提供此偏好設定,並建議其值為 true 時避免使用大幅度動畫。accessibilityReduceMotion

請在 app 支援的各個 macOS 版本和清單樣式上評估原生版本。如果它符合需求,就保留使用。若只需調整揭露外觀,macOS 13 及以上版本可透過 DisclosureGroupStyle 提供支援的自訂方式。自訂樣式仍是由檢視組合而成;其文件並未保證能任意控制周圍清單的列動畫。DisclosureGroupStyle

根據所需結果選擇容器

新增更多修飾子之前,請先使用這份決策指南。它將原生列互動與可明確控制的揭露效果分開說明。

所需結果 起始選項 檢查項目
可選取清單中的一般可展開列 List 和 DisclosureGroup 在實際 macOS 版本和清單樣式中的展開行為
不同的揭露標籤或控制項外觀 DisclosureGroupStyle 周圍容器是否仍能產生所需的幾何效果
為小型階層精確定義揭露高度 ScrollView、VStack 和可動畫的 Layout 巢狀調整大小、裁切和控制項行為
既有的 AppKit 大綱檢視,包含欄位和委派物件 NSOutlineView 其自身的展開 API 和資料來源行為

下方的自訂方案刻意在捲動檢視中使用堆疊。它提供自己的揭露按鈕和幾何配置,因此也需要你自行實作介面所需的清單選取或列命令。Apple 將選取功能記載為 List 的能力;切換容器也會改變這項起點。List

使用自訂版面配置為揭露高度加入動畫

macOS 13 及以上版本中,Layout 協定可讓容器計算大小並放置子項目。它也繼承自 Animatable。這些是文件所述的構件,可讓展開量成為可內插的數值,而非只依賴插入效果。Layout

以下原創實作會為每個揭露區域使用一個子堆疊。它會在可用寬度下,以未指定高度的提案測量該堆疊,再回報測量高度的一部分。子項目會維持在頂端的位置,同時改變可見區域。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 點高的揭露區域;這些數字只是算術示例,並非 app 的實測結果。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 使用的機制。對於一般標籤構成的階層,這與展開容器是不同的考量。當內容本身需要文字編輯或自訂文字版面配置時,請使用 TextKit;它不是文件所述的 SwiftUI 清單展開動畫切換功能。TextKit

先從原生揭露群組開始,並在目標 Mac 介面中確認其行為。若需求是可控制的高度揭露效果,自訂版面配置便能為高度提供明確的可動畫值。請根據所需互動來選擇:原生清單行為、自訂揭露幾何配置,或既有的 AppKit 大綱檢視。

來源