Чтобы перемещать группу вместе с дочерними элементами, храните их в модели родительского объекта и меняйте порядок коллекции родительских объектов. В этом руководстве создаётся небольшой список 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.

Как выяснить, почему группа перемещается неправильно?

Начните с самого простого наглядного примера и проверьте модель после одной операции. Следующие проверки помогут отличить ошибку порядка от несоответствия отображаемых строк базовым данным.

  1. Перемещается только заголовок: проверьте, не хранятся ли дочерние элементы как независимые записи в плоском массиве. Перемещайте родительский объект, которому они принадлежат, или явно реализуйте перемещение блока для плоского представления.
  2. Перемещение вниз ничего не меняет: проверьте, не передаёт ли кнопка index + 1. Для одного исходного элемента такая граница оставляет его на месте.
  3. После фильтрации перемещается не та группа: проверьте, не применяются ли смещения видимых строк к полному массиву. Перед изменением разрешите идентификаторы видимых групп в канонической модели и определите, где должно находиться отфильтрованное назначение.
  4. Порядок сразу восстанавливается: проверьте, не сортируется ли отображаемый массив заново при каждом отображении. Для ручной перестановки нужен порядок, который действительно сохраняет интерфейс.
  5. Дочерний элемент перемещается отдельно: проверьте, где подключён обработчик перемещения. В этом примере он подключён только к внешней коллекции групп.

Перед интеграцией проверьте перемещение вверх и вниз, оба конца списка, случай с одной оставшейся группой и пустую группу. Проверяйте не только внешний вид, но и инварианты модели: должны сохраняться те же идентификаторы групп, каждая группа должна сохранять те же идентификаторы дочерних элементов, а их порядок не должен меняться. Эти проверки непосредственно подтверждают, что родитель перемещается вместе со своими дочерними элементами.

Для компактного интерфейса Mac начните с одной модели родительского объекта на перемещаемую строку и общей функции перестановки. Тогда пользователь сможет переместить Group A одним действием, сохранив её дочерние элементы при ней. Добавляйте иерархическую или AppKit-реализацию, если интерфейсу потребуется отдельное взаимодействие с дочерними элементами или поведение коллекции на основе секций.

Источники