要在 Mac 应用中为嵌套展开添加动画,请从 DisclosureGroup 和带动画的展开绑定开始。如果需要精确控制展开高度,请在滚动视图中使用自定义布局;仅靠内容过渡无法定义这种几何尺寸。

本教程针对一个具体任务:展开一小组层级项目,以显示其内容,并让下方项目随高度变化而移动。示例使用虚构标签,完全在应用自身窗口内运行。

为什么内容会淡入,却看不出明显展开?

诊断结果时,请区分三件事:展开状态、插入内容的外观,以及容器分配的空间。Apple 将 withAnimation、由值驱动的动画修饰符和带动画的绑定记载为动画化状态变化的方法。它将过渡单独记载为视图进入或离开层级时使用的效果。动画

不透明度过渡会改变透明度。移动过渡会改变进入或退出的视图相对于某个边缘的位置。这两种 API 本身都没有规定周围列表应如何插值行高。因此,添加移动过渡可能会改变内容进入时的可见效果,却无法解决你想要的具体布局行为。move(edge:)

请将这一区别视为诊断思路,而不是对某种特定内部实现的证明:如果详细内容淡入,而后续行突然跳动,请将容器几何布局与详细内容的过渡分开排查。已记载的 List API 描述的是带可选选择功能的行容器,并未承诺每种嵌套排列和列表样式都具有特定的展开曲线。列表

从原生嵌套披露组开始

对于常见的 Mac 层级结构,先使用 DisclosureGroup,再考虑自行实现披露控件。它通过布尔绑定公开展开状态,Apple 的文档也明确演示了在一个披露组中嵌套另一个披露组。该 API 适用于 macOS 11 及更高版本。DisclosureGroup

在 Xcode 中使用 SwiftUI 创建 macOS 应用项目。将生成的应用声明替换为以下完整程序,并确保目标中恰好只有一个 @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 是否处于展开状态。这是当前视图的内存状态,不会在应用启动之间持久保存。带动画的绑定是 Apple 记载的动画入口之一。动画

示例使用四分之一秒的缓动曲线作为设计示例。当系统启用 Reduce Motion 偏好设置时,它会传入 nil。Apple 通过 accessibilityReduceMotion 提供该偏好设置,并建议在其值为 true 时避免大幅动画。accessibilityReduceMotion

请在应用支持的每个 macOS 版本和列表样式上评估原生实现。如果效果满足需求,就保留它。如果只需调整披露控件的外观,macOS 13 及更高版本提供了受支持的自定义入口 DisclosureGroupStyle。自定义样式仍然是通过组合视图实现的;其文档并未承诺可以任意控制周围列表的行动画。DisclosureGroupStyle

根据所需结果选择容器

在添加更多修饰符之前,请使用以下决策参考。它将原生行交互与可明确控制的内容展开区分开来。

所需结果 起点 需要验证的内容
支持列表选择的常规可展开行 List 和 DisclosureGroup 实际 macOS 版本和列表样式下的展开行为
不同的披露标签或控件外观 DisclosureGroupStyle 周围容器是否仍能产生所需的几何效果
为小型层级结构精确定义展开高度 ScrollView、VStack 和可动画的 Layout 嵌套调整大小、裁剪和控件行为
现有的、带列和代理的 AppKit 大纲视图 NSOutlineView 它自身的展开 API 和数据源行为

下面的自定义方案有意采用滚动视图中的堆栈。它提供自己的披露按钮和几何布局,因此你还需要实现界面所需的任何列表选择或行命令。Apple 将选择功能记载为 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 点的展开区域;这些数字只是算术示例,并非应用实测结果。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 大纲视图。

来源