Чтобы перемещать группу вместе с дочерними элементами, храните их в модели родительского объекта и меняйте порядок коллекции родительских объектов. В этом руководстве создаётся небольшой список SwiftUI для Mac, в котором каждая группа перемещается как одна строка, а её дочерние элементы остаются при ней.
Задача — менять порядок групп внутри собственного приложения, например переместить Group A, содержащую Tile A1 и Tile A2, ниже Group B. В примере используются синтетические локальные данные, а рядом с обработчиком перемещения списка SwiftUI размещены кнопки Move Up и Move Down.
Что считать одним перемещаемым элементом?
Сделайте единицу перемещения такой же, как единица в ваших данных. В этом интерфейсе внешний массив содержит группы, а каждая группа — собственный массив дочерних элементов. Изменение порядка внешнего массива меняет порядок групп, сохраняя дочерние элементы каждой группы и их текущий порядок.
Представьте каждую группу одной составной строкой списка: заголовок, кнопки перемещения и строки дочерних элементов с отступом внутри VStack. Добавьте onMove(perform:) к внешнему ForEach. В документации Apple указано, что смещения в обработчике относятся к базовой коллекции динамического представления; здесь это массив групп. Документация Apple об обработчике перемещения.
Назначьте группам и дочерним элементам стабильные идентификаторы. Для элементов ForEach требуются идентификаторы: тип должен соответствовать протоколу Identifiable либо нужно указать явный путь к свойству идентификатора. В этой схеме идентификаторы принадлежат моделям и не меняются при изменении их позиций. Не используйте позиции в массиве в качестве идентификаторов перемещаемых элементов. Документация Apple о ForEach.
Представление SwiftUI Group — это не модель приложения, используемая ниже. Название контейнера или отображение заголовка не определяет, какие дочерние записи должны перемещаться вместе с ним. Массив children, хранящийся в родительском объекте, явно задаёт эту связь.
Создайте полноценный пример SwiftUI
Создайте в Xcode проект приложения для macOS с интерфейсом SwiftUI и целевой версией macOS 12 или новее. Замените сгенерированный файл точки входа приложения приведённой ниже полной программой; в целевом объекте должна остаться только одна декларация приложения @main. Здесь намеренно используется простой список и текстовые кнопки, чтобы движение было легко проследить.
import Foundation
import SwiftUI
private struct DemoItem: Identifiable {
let id: String
let title: String
}
private struct DemoGroup: Identifiable {
let id: String
let title: String
let children: [DemoItem]
}
@main
struct GroupMoveDemoApp: App {
var body: some Scene {
WindowGroup {
GroupCollectionView()
}
}
}
private struct GroupCollectionView: View {
@State private var groups: [DemoGroup] = [
DemoGroup(
id: "group-a", title: "Group A",
children: [
DemoItem(id: "a1", title: "Tile A1"),
DemoItem(id: "a2", title: "Tile A2")
]
),
DemoGroup(
id: "group-b", title: "Group B",
children: [
DemoItem(id: "b1", title: "Tile B1")
]
),
DemoGroup(
id: "group-c", title: "Group C",
children: []
)
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Arrange Groups")
.font(.title2)
.padding(.horizontal)
List {
ForEach(groups) { group in
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(group.title).font(.headline)
Spacer()
Button("Move Up") {
moveOne(id: group.id, down: false)
}
.disabled(group.id == groups.first?.id)
Button("Move Down") {
moveOne(id: group.id, down: true)
}
.disabled(group.id == groups.last?.id)
}
.buttonStyle(BorderlessButtonStyle())
ForEach(group.children) { child in
Text(child.title)
.padding(.leading, 16)
}
}
.padding(.vertical, 8)
}
.onMove(perform: moveGroups)
}
}
.padding(.vertical)
.frame(minWidth: 520, minHeight: 320)
}
private func moveGroups(from source: IndexSet, to destination: Int) {
withAnimation {
groups.move(fromOffsets: source, toOffset: destination)
}
}
private func moveOne(id: String, down: Bool) {
guard let index = groups.firstIndex(where: { $0.id == id }) else {
return
}
let neighbor = down ? index + 1 : index - 1
guard groups.indices.contains(neighbor) else {
return
}
let destination = down ? index + 2 : index - 1
moveGroups(from: IndexSet(integer: index), to: destination)
}
}
Внешний ForEach создаёт по одному VStack на группу. Вложенный ForEach отображает дочерние элементы в этой строке; отдельного обработчика перемещения дочерних элементов нет. Обе кнопки перемещения находят текущую позицию группы по идентификатору и вызывают ту же функцию изменения, что и onMove.
Запустите приложение и нажмите Move Down рядом с Group A. Ожидаемый порядок: Group B, Group A, Group C; под Group A по-прежнему отображаются Tile A1 и Tile A2. Нажатие Move Up рядом с этой группой восстанавливает исходный порядок. Group C также показывает, что группу без дочерних элементов можно перемещать.
Порядок в этой программе хранится в памяти, пока существует текущее представление; уровень сохранения данных не предусмотрен. Ссылки и логику перемещения проверили, но здесь программу не компилировали: в доступной оболочке нет компилятора Swift. Перед интеграцией соберите программу и проверьте интерфейс в Xcode на Mac.
Почему для перемещения вниз используется другая позиция назначения?
Позиция назначения, передаваемая в move(fromOffsets:toOffset:), — это граница вставки в исходной коллекции. Apple определяет её как смещение, перед которым нужно вставить перемещаемые элементы, в диапазоне 0...count. Передача смещения самого элемента или следующего за ним не меняет порядок. Документация Apple о перемещении элементов коллекции.
Для начального порядка [A, B, C] следующие независимые операции показывают разницу:
| Требуемый результат | Исходное смещение | Позиция назначения, переданная в move |
Результат |
|---|---|---|---|
Переместить A ниже B |
0 |
2 |
[B, A, C] |
Переместить B выше A |
1 |
0 |
[B, A, C] |
Переместить A в конец |
0 |
3 |
[B, C, A] |
Оставить A на месте |
0 |
1 |
[A, B, C] |
Именно поэтому кнопка перемещения вниз использует index + 2: элемент вставляется перед элементом после следующего соседа либо в конец. Кнопка перемещения вверх использует index - 1. Для смещений, полученных непосредственно из onMove, передавайте их методу коллекции без повторного применения этого расчёта для кнопки.
Расчёт позиции назначения в примере применим к одному перемещению на соседнюю позицию. Если обработчик перемещения содержит несколько исходных смещений, передавайте методу коллекции полный IndexSet. Согласно документации, порядок перемещаемых элементов при этом сохраняется. Документация Apple о перемещении элементов коллекции.
Для какого интерфейса нужна другая реализация?
Выбирайте реализацию с учётом того, как отображается группа и как пользователям нужно взаимодействовать с её дочерними элементами. Пример с составной строкой подходит, если группа всегда должна перемещаться единым блоком. В нём дочерние элементы намеренно представлены как подписи внутри этого блока, а не как отдельные строки списка.
| Требование к интерфейсу | Подходящая отправная точка | Ответственность за перемещение |
|---|---|---|
| Заголовок группы и дочерние элементы образуют один перемещаемый блок | Внешний ForEach с одной составной строкой на группу |
Менять порядок массива родительских объектов |
| Раскрываемая иерархия с вложенными потомками | OutlineGroup |
Реализовать перемещение моделей для нужной коллекции родительских объектов |
| Коллекция AppKit с отдельной секцией для каждой группы | NSCollectionView |
Обновить источник данных, а затем уведомить представление коллекции о перемещении секции |
| Пользовательский редактируемый текст внутри элемента | TextKit внутри редактора элемента | Хранить порядок групп во внешней модели коллекции |
Для перемещения в раскрывающихся деревьях нужны явные правила
Apple описывает OutlineGroup как представление идентифицированных древовидных данных с элементами управления для раскрытия и сворачивания ветвей. Оно доступно в macOS 11 и новее. Задокументированное поведение отображения не определяет общую для всех приложений политику перемещения поддеревьев. Документация Apple об OutlineGroup.
Для дерева точно определите назначение: перемещение родителя между соседними элементами того же уровня и перемещение внутрь другого родителя — разные операции. Перестановка среди соседей меняет один родительский массив. Перемещение к другому родителю требует удалить узел из старого родителя, вставить в нового и запретить выбор назначения внутри собственных потомков этого узла. Эти правила модели приложения определяются структурой дерева.
Если задача по-прежнему состоит в перемещении целых групп верхнего уровня, пример с составной строкой избавляет от необходимости задавать дополнительные правила выбора назначения. Если дочерние элементы нужно выбирать или перемещать отдельно, сначала явно спроектируйте эти взаимодействия, а затем преобразуйте иерархию в плоский список видимых строк.
AppKit позволяет перемещать секцию вместе с содержащимися в ней элементами
Если ваш интерфейс — это NSCollectionView, где каждая группа представлена отдельной секцией, Apple предоставляет moveSection(_:toSection:). В документации явно описывается перестановка секций и содержащихся в них элементов, а также указывается, что перед вызовом API необходимо обновить источник данных. API доступен в macOS 10.11 и новее. Документация Apple о перемещении секций.
Для этого варианта определите исходный и конечный индексы секций, измените порядок данных групп и уведомите представление коллекции с помощью его API перемещения секций. Расчёт границы вставки SwiftUI нужно держать отдельно от индекса секции назначения, ожидаемого AppKit. Эти два метода по-разному описывают назначение.
Чтобы встроить коллекцию AppKit в SwiftUI, используйте NSViewRepresentable — документированную Apple оболочку для создания и обновления представления AppKit. Передавайте взаимодействия с коллекцией обратно в модель SwiftUI с помощью подходящего механизма координации, а внешним расположением встроенного представления пусть управляет SwiftUI. Документация Apple о NSViewRepresentable.
TextKit управляет компоновкой текста внутри элемента
Apple определяет NSTextLayoutManager как диспетчер пользовательской компоновки и представления текста, доступный в macOS 12 и новее. Он отвечает за текстовые контейнеры, элементы и фрагменты компоновки. Для этой задачи с коллекцией архитектурное следствие состоит в том, что порядок групп должен храниться в модели коллекции, даже если дочерний элемент содержит пользовательский редактор TextKit. Документация Apple о NSTextLayoutManager.
Как выяснить, почему группа перемещается неправильно?
Начните с самого простого наглядного примера и проверьте модель после одной операции. Следующие проверки помогут отличить ошибку порядка от несоответствия отображаемых строк базовым данным.
- Перемещается только заголовок: проверьте, не хранятся ли дочерние элементы как независимые записи в плоском массиве. Перемещайте родительский объект, которому они принадлежат, или явно реализуйте перемещение блока для плоского представления.
- Перемещение вниз ничего не меняет: проверьте, не передаёт ли кнопка
index + 1. Для одного исходного элемента такая граница оставляет его на месте. - После фильтрации перемещается не та группа: проверьте, не применяются ли смещения видимых строк к полному массиву. Перед изменением разрешите идентификаторы видимых групп в канонической модели и определите, где должно находиться отфильтрованное назначение.
- Порядок сразу восстанавливается: проверьте, не сортируется ли отображаемый массив заново при каждом отображении. Для ручной перестановки нужен порядок, который действительно сохраняет интерфейс.
- Дочерний элемент перемещается отдельно: проверьте, где подключён обработчик перемещения. В этом примере он подключён только к внешней коллекции групп.
Перед интеграцией проверьте перемещение вверх и вниз, оба конца списка, случай с одной оставшейся группой и пустую группу. Проверяйте не только внешний вид, но и инварианты модели: должны сохраняться те же идентификаторы групп, каждая группа должна сохранять те же идентификаторы дочерних элементов, а их порядок не должен меняться. Эти проверки непосредственно подтверждают, что родитель перемещается вместе со своими дочерними элементами.
Для компактного интерфейса Mac начните с одной модели родительского объекта на перемещаемую строку и общей функции перестановки. Тогда пользователь сможет переместить Group A одним действием, сохранив её дочерние элементы при ней. Добавляйте иерархическую или AppKit-реализацию, если интерфейсу потребуется отдельное взаимодействие с дочерними элементами или поведение коллекции на основе секций.
Источники
- onMove(perform:) — Apple
- ForEach — Apple
- move(fromOffsets:toOffset:) — Apple
- OutlineGroup — Apple
- moveSection(_:toSection:) — Apple
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple