Чтобы анимировать раскрытие вложенных элементов в приложении для Mac, начните с DisclosureGroup и привязки раскрытия с анимацией. Если вам нужен точный контроль над высотой раскрытия, используйте пользовательский layout в области прокрутки; один лишь переход содержимого не задаёт эту геометрию.
В этом руководстве рассматривается конкретная задача: раскрыть небольшую иерархию элементов так, чтобы показать её содержимое, а элементы ниже перемещались вслед за изменением высоты. В примерах используются условные подписи, а все действия выполняются внутри окна приложения.
Почему содержимое плавно появляется, но заметно не раскрывается?
При анализе результата разделяйте три вещи: состояние раскрытия, появление вставленного содержимого и пространство, выделяемое контейнером. Apple описывает withAnimation, модификаторы анимации, зависящие от значения, и привязки с анимацией как способы анимировать изменения состояния. Переходы описаны отдельно: это эффекты, применяемые при появлении представлений в иерархии или их удалении из неё. Анимации
Переход прозрачности меняет степень непрозрачности. Переход перемещения меняет положение появляющегося или исчезающего представления относительно края. Ни один из этих API сам по себе не определяет, как окружающий список должен интерполировать высоту строк. Поэтому добавление перехода перемещения может изменить вид появления, не решая нужную вам задачу компоновки. move(edge:)
Рассматривайте это как диагностическое различие, а не как доказательство конкретной внутренней реализации: если подробности плавно появляются, а следующая строка скачком меняет положение, исследуйте геометрию контейнера отдельно от перехода для подробностей. В документации API List описан контейнер строк с необязательным выделением; она не гарантирует определённую кривую раскрытия для всех вариантов вложения и стилей списков. List
Начните со встроенных вложенных групп раскрытия
Для обычной иерархии на Mac используйте DisclosureGroup, прежде чем создавать собственный элемент управления раскрытием. Раскрытие задаётся привязкой типа Boolean, и в документации Apple прямо показано вложение одной группы раскрытия в другую. API доступен в macOS 11 и более поздних версиях. DisclosureGroup
Создайте в Xcode проект приложения macOS на SwiftUI. Замените сгенерированное объявление приложения следующей полной программой; в целевом модуле должно остаться ровно одно объявление @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 и рекомендует избегать крупных анимаций, когда он включён. accessibilityReduceMotion
Проверьте встроенный вариант в каждой версии macOS и для каждого стиля списка, который поддерживает ваше приложение. Если он решает задачу, оставьте его. Если нужно изменить только вид элемента раскрытия, в macOS 13 и более поздних версиях для этого предусмотрен DisclosureGroupStyle. Пользовательский стиль по-прежнему комбинирует представления; его документация не обещает произвольного управления анимацией строк окружающего списка. DisclosureGroupStyle
Выбирайте контейнер под нужный результат
Прежде чем добавлять новые модификаторы, воспользуйтесь этой схемой выбора. Она различает встроенное взаимодействие со строками и раскрытие с явно заданными параметрами.
| Требуемый результат | С чего начать | Что проверить |
|---|---|---|
| Обычные раскрываемые строки с выделением в списке | List и DisclosureGroup |
Поведение раскрытия в целевой версии macOS и выбранном стиле списка |
| Другой вид подписи или элемента управления раскрытием | DisclosureGroupStyle |
Создаёт ли окружающий контейнер нужную геометрию |
| Точно заданная высота раскрытия для небольшой иерархии | ScrollView, VStack и анимируемый Layout |
Изменение размера вложенных элементов, обрезка и поведение элементов управления |
| Существующая структура AppKit с колонками и делегатами | NSOutlineView |
Собственные API раскрытия и поведение источника данных |
В приведённом ниже пользовательском варианте намеренно используется стек внутри области прокрутки. Он задаёт собственные кнопки раскрытия и геометрию, поэтому также нужно реализовать любое необходимое интерфейсу выделение элементов или команды строк. Apple описывает выделение как возможность List; при переходе на другой контейнер меняется исходный набор возможностей. List
Анимируйте высоту раскрытого содержимого с помощью пользовательского layout
В 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(_:)
Проверьте поведение вложенных элементов, прежде чем использовать пользовательский вариант
Выполните следующие шаги, чтобы отличить корректное раскрытие от анимации, которая хорошо выглядит лишь в простейшем состоянии.
- Раскройте
Collection, затемShapes. Наблюдайте заFollowing item, пока изменяется состояние обоих уровней. - Сверните
Collection, покаShapesоткрыта, затем снова раскройте её. Убедитесь, что нужное состояние дочерней группы сохранилось. - Ещё раз нажмите кнопку раскрытия до завершения анимации. Проверьте, что раскрытие меняет направление без появления пробела.
- Сузьте окно и замените примерную подпись более длинной условной подписью. Проверьте перенос строк и итоговую высоту раскрытия.
- Включите
Reduce Motionи убедитесь, что раскрытием можно пользоваться без анимированного перемещения. - Проверьте активацию с клавиатуры и представление раскрытых и свёрнутых групп для специальных возможностей.
Пользовательский layout рассчитан на содержимое с конечной высотой, например такие подписи и вложенные стеки. Не помещайте в раскрываемый блок вертикально неограниченную область прокрутки в расчёте на то, что она сообщит полезную полную высоту содержимого. Реализация явно запрашивает идеальную высоту, поэтому дочерний элемент должен уметь задать осмысленное значение в ответ на это предложение. Это следствие выбранной схемы расчёта размеров. ProposedViewSize
Сохранение содержимого в свёрнутом состоянии также сохраняет затраты на его создание и компоновку. Для небольшой иерархии начните с обычного VStack; прежде чем применять этот вариант с немедленным созданием всех элементов к большому дереву, оцените другую архитектуру. Для строк, формируемых на основе данных, сохраняйте стабильные идентификаторы моделей при раскрытии и изменении порядка. Протокол Swift Identifiable описывает стабильную идентичность сущности, а ForEach использует идентифицируемые данные для создания представлений. Identifiable, ForEach
Где здесь место AppKit и TextKit?
Если интерфейс уже использует AppKit, NSOutlineView — документированный элемент управления для иерархического представления строк и столбцов. Его метод expandItem(_:) раскрывает элемент и ничего не делает, если элемент уже раскрыт или не может быть раскрыт. Этот метод не предоставляет параметр кривой анимации SwiftUI, поэтому не следует считать, что он обеспечивает описанную выше пользовательскую настройку времени. NSOutlineView, expandItem(_:)
TextKit управляет хранением, компоновкой и отрисовкой текста, включая механизмы, используемые NSTextView. Для иерархии обычных подписей это отдельная задача, не связанная с контейнером раскрытия. Используйте его, когда само содержимое требует редактирования текста или пользовательской компоновки текста; это не документированный переключатель для анимации раскрытия списка SwiftUI. TextKit
Начните со встроенных групп раскрытия и проверьте их поведение в целевом интерфейсе для Mac. Если задаче нужна раскрываемая область с контролируемой высотой, пользовательский layout задаёт для этой высоты явное анимируемое значение. Выбирайте подход с учётом нужного взаимодействия: встроенное поведение списка, пользовательская геометрия раскрытия или существующая структура AppKit.
Источники
- DisclosureGroup — Apple
- Анимации — Apple
- move(edge:) — Apple
- List — Apple
- accessibilityReduceMotion — Apple
- DisclosureGroupStyle — Apple
- Layout — Apple
- sizeThatFits(_:) — Apple
- place(at:anchor:proposal:) — Apple
- Animatable — Apple
- ProposedViewSize — Apple
- clipped(antialiased:) — Apple
- allowsHitTesting(_:) — Apple
- accessibilityHidden(_:) — Apple
- Identifiable — Apple
- ForEach — Apple
- NSOutlineView — Apple
- expandItem(_:) — Apple
- TextKit — Apple