
Привет, Хабр! Меня зовут Никитин Алексей, я iOS разработчик в компании 65apps. Хорошо было бы порассуждать о Dungeons & Dragons, но нет. Речь пойдет о перемещении объектов. Перетаскивание как внутри одного приложения, так и между разными — с точки зрения пользователя вещь обыденная. Но под капотом механизма D’n’D в современных приложениях могут скрываться разные варианты решения. О них и поговорим.
Для начала — установим требования, которые нам необходимо выполнить:
-
Реализовать сортировку элементов внутри секции.
-
Добавить возможность взаимодействия объектов между и внутри секций.
-
Подсвечивать зону, в которую будет «падать» перемещенный объект.
Поставленные задачи повторяют требования реального проекта. Для примера будут представлены скриншоты и фрагменты кода из тестового приложения. Его можно будет посмотреть на github.
Простая реализация
«Же по Невскому марше.
Же перчатку ле пердю.
Же её — шерше, шерше!
Плюнула, и вновь марше.»
Стишок, с помощью которого гимназистки заучивали французские глаголы — великолепно описывает суть вопроса. В процессе перетаскивания объект теряет свое место внутри секции. Наша задача — определить его новое положение и заново расставить элементы.
Очевидное решение — использовать стандартный механизм пересортировки. Для его минимальной реализации понадобиться только UITableViewDataSource. DataSource определяет возможность объекта к перемещению, а также сообщает о необходимости изменения данных, после окончания действия пользователя. Для этого надо реализовать следующие методы:
func tableView( _ tableView: UITableView, canMoveRowAt indexPath: IndexPath ) -> Bool func tableView( _ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath )
Для реализации ограничения перемещения только внутри секции можно определить еще один метод:
func tableView( _ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath ) -> IndexPath
На выходе мы получим следующий результат:

Основной плюс этого подхода — его очень быстро настроить. Однако он накладывает вполне конкретные ограничения. Во-первых, он работает только в режиме редактирования таблицы. Можно применить пару грязных хаков, чтобы обойти это ограничение, но придется подвести сову поближе к глобусу.
А во-вторых, и это главное, данная реализация дает возможность выполнить только первый пункт наших требований, заявленных в начале статьи — реализовать сортировку объектов внутри секции. И только. Для того, чтобы добавить возможность кастомизации процесса переноса и взаимодействия объектов между секциями и внутри — бедное пернатое придется на глобус натянуть.
Гринпис против и вообще — это не наш метод, так что присвоим данному решению статус «незачет» и перейдем к следующему варианту.
Drag and Drop в UIKit
API Drag and Drop позволяет сортировать элементы без включения режима редактирования и раскрывает большие возможности по кастомизации самого процесса — от изменения превью перемещаемого объекта до выбора способа и места взаимодействия с местом «падения». Для того, чтобы начать пользоваться данным API, надо изучить классы DragDelegate, DropDelegate и их методы. Но для начала подключим данные классы и активируем drag and drop.
tableView.dragDelegate = dragDelegate tableView.dropDelegate = dropDelegate tableView.dragInteractionEnabled = true
DragDelegate
DragDelegate определяет, какие именно объекты мы переносим, их полезную нагрузку и много чего еще. Для того, чтобы инициировать процесс переноса — зададим следующий метод:
func tableView( _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath ) -> [UIDragItem] { let item = UIDragItem(itemProvider: NSItemProvider()) item.localObject = indexPath return [item] }
В нем мы определяем список UIDragItem — объекты, которые мы переносим, и их полезную нагрузку, а потому этот метод является обязательным в любом DragDelegate. У UIDragItem есть важные свойства: itemProvider и localObject.
ItemProvider позволяет перемещать объекты между приложениями, что особенно актуально для iPad в многооконном режиме. Если мы работаем в рамках одного приложения, то нам будет достаточно localObject, в который мы можем разместить любой объект, не задумываясь про его сериализацию в Data и обратно.
DropDelegate
DropDelegate служит для определения поведения при «падении» объекта в определенную зону. У него также всего один обязательный к реализации метод.
func tableView( _ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator ) { let destinationIndexPath = coordinator.destinationIndexPath coordinator.drop(item, toRowAt: sourceIndexPath) }
Здесь главной сущностью является объект UITableViewDropCoordinator. Он позволяет получить доступ к финальной позиции и всей полезной нагрузке. Так же благодаря координатору мы можем красиво завершить действие, использовав метод drop(:toRowAt:). Это позволит анимировано расставить все объекты по новым местам. Обратите внимание, что без вызова метода drop(:toRowAt:) — превью объекта вернется в начальную позицию.
После того, как мы реализуем обязательные методы из DragDelegate и DropDelegate — мы можем выполнить самое простое перемещение объектов. Однако для полного соответствия первому и второму пунктам наших требований нужно немного доработать код.

UITableViewDropProposal
Для управления поведением перемещаемых объектов через API нам понадобится метод DropDelegate, который возвращает UITableViewDropProposal.
func tableView( _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath? ) -> UITableViewDropProposal { guard let item = session.items.first, let fromIndexPath = item.localObject as? IndexPath, let toIndexPath = destinationIndexPath else { backdrop.frame = .zero return UITableViewDropProposal(operation: .forbidden) } if fromIndexPath.section == toIndexPath.section { return UITableViewDropProposal( operation: .move, intent: .automatic ) } return UITableViewDropProposal( operation: .move, intent: .insertIntoDestinationIndexPath ) }
С этим стоит разобраться чуть более подробно. Возвращаемый объект имеет поля — operation и intent, на основе которых меняется отображение превью и места «падения» объектов.
Operation — определяет, что сделать с перетаскиваемым объектом в конечной точке (при дропе). Возможны 4 различные ситуации:
-
cancel — перетаскивание запрещено, данные не передаются, операция перетаскивания отменяется
-
forbidden — перетаскивание в общем случае разрешено, но в данном конкретном сценарии невозможно, операция перетаскивания отменяется
-
move — полезная нагрузка из перетаскиваемого объекта должна быть перемещена в целевое представление
-
copy — полезная нагрузка из перетаскиваемого объекта должна быть скопирована в целевое представление
Intent — сообщает, куда конкретно будет «падать» объект. На выбор доступны 4 варианта:
-
unspecified — поведение не определено
-
insertAtDestinationIndexPath — объект будет положен рядом, вызывая смещение соседних объектов.
-
insertIntoDestinationIndexPath — объект будет положен на объект под ним, не вызывая смещение соседних объектов.
-
automatic — поведение будет определено автоматически между insertAtDestinationIndexPath и insertIntoDestinationIndexPath в зависимости от расположения пальца.
Комбинируя эти свойства мы можем реализовать поставленные в ТЗ задачи: в зависимости от секции исходной и финальной позиции определять разные действия через UITableViewDropProposal. Причем решается все на уровне комбинации буквально пары параметров.
Если требуется выполнить сортировку, то возвращаем:
operation = .move intent = .insertAtDestinationIndexPath
Если же хотим взаимодействовать между объектами — тогда нам подходит комбинация
operation = .move intent = .insertIntoDestinationIndexPath
Отдельно стоит присмотреться к intent = .automatic — очень полезная опция, которая позволяет внутри одной секции поддерживать как сортировку, так и взаимодействие.
UITableViewDropProposal дает возможность более точно определить, что хотим сделать с объектами, а значит — сделать интуитивно понятную для пользователя анимацию его drag-действия.
Обработка UITableViewDropProposal в performDropWith
Выше мы сообщили приложению, что ожидаем от drop-действия в данном конкретном месте. Пришло время обработать это действие. Для этого у координатора есть свойство proposal. При помощи его полей мы сможем вызвать правильный метод бизнес логики. Немного изменим наш обязательный метод DropDelegate.
switch coordinator.proposal.intent { case .insertAtDestinationIndexPath: move(from: sourceIndexPath, to: destinationIndexPath) coordinator.drop(item, toRowAt: destinationIndexPath) case .insertIntoDestinationIndexPath: interact(from: sourceIndexPath, to: destinationIndexPath) coordinator.drop(item, toRowAt: sourceIndexPath) default: break }
Обратите внимание, мы обрабатываем только 2 возможных варианта intent’а, а как же быть с остальными? Тут происходит самое интересное! Определяя взаимодействия внутри секции, мы возвращали свойство intent равное automatic, а значит — в метод координатор передается автоматически вычисленное действие. То есть один из двух вариантов: insertAtDestinationIndexPath или insertIntoDestinationIndexPath. Просто супер!

Кастомизации
Осталось совсем чуть-чуть. Мы же хотим, чтобы наша реализация D’n’D была приятна для пользователя визуально? Для этого немного изменим превью и подсветим место падения объекта.
Для реализации первого пункта нам достаточно реализовать метод dragPreviewParametersForRowAt у DragDelegate. Базовое превью выглядит точно так же, как сама ячейка. Этот же метод позволяет немного изменить его геометрию, цвет и тени. В нашем примере мы немного закруглим углы у превью.
func tableView( _ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath ) -> UIDragPreviewParameters? { guard let cell = tableView.cellForRow(at: indexPath) else { return nil } let preview = UIDragPreviewParameters() preview.visiblePath = UIBezierPath(roundedRect: cell.bounds.insetBy(dx: 5, dy: 5), cornerRadius: 12) return preview }
Для подсветки места падения, к сожалению, в D’n’D API нет готовых реализаций, но есть достаточно простые способы добиться желаемого результата. Я решил эту задачу следующим образом:
-
Создал backdropView и положил ее в tableView
-
Во время определения UITableViewDropProposal вычисляю место падения ячейки. Для этого использую destinationIndexPath и метод таблицы rectForRow(at: IndexPath)
-
Анимировано меняю frame у backdropView на только что вычисленный
-
Скрываю backdropView когда drop-сессия заканчивается.
if let destinationIndexPath = destinationIndexPath { let newFrame = tableView.rectForRow(at: destinationIndexPath) if backdropView.frame == .zero { backdropView.frame = newFrame } else { UIView.animate(withDuration: 0.15) { [backdropView] in backdropView.frame = newFrame } } } else { backdropView.frame = .zero }

А что там с коллекциями?
Вот и все. Основная механика изучена, все поставленные задачи выполнены. Но что, если в будущем, наш экран будет изменен с UITableView на UICollectionView? А ничего не изменится. Ну почти. Нам придется всего лишь заменить имена делегатов, а все упоминания таблиц поменять на коллекции. Список и сигнатуры обязательных методов одинаковы.
SwiftUI
Можно было бы на это и закончить, но… Построение интерфейсов с применением SwiftUI предполагает декларативный подход. Это оставляет свои отпечаток на использовании знакомых ранее технологий, коснулись изменения и Drag and Drop. Что ж, давайте разберемся и с этим.
Так же как и в UIKit нам доступны 2 способа реализации данной механики. Это обычная сортировка списка и полноценный Drag and Drop.
Использование onMove
Все, что необходимо для реализации стандартной сортировки, это добавить модификатор onMove:
func onMove( perform action: Optional<(IndexSet, Int) -> Void> ) -> some DynamicViewContent
Этот модификатор устанавливает действие в виде замыкания, которое необходимо выполнить после завершения перемещения. В нем вы обращаетесь к бизнес-логике и меняете сортировку физически.
ForEach(group.teams) { team in TeamView(team: team) Divider() } .onMove(perform: { indices, newOffset in // do smth })
Этот способ имеет те же плюсы, что и в UIKit, а именно: простота и скорость реализации. Однако и минусы значительные. Так, в SwiftUI мы можем использовать этот модификатор только для объектов типа DynamicViewContent. Это особый вид вью, порождаемый коллекционными контейнерами, такими как ForEach. Так же мы не можем применять сортировку между секциями, а еще — View должна находится в режиме редактирования.
Использование onDrag и onDrop
В отличии от сортировки, DnD доступен не только для DynamicViewContent. Мы можем начать перемещать любые объекты и ронять их на любую поддерживаемую View. Как и в UIKit, реализация DnD в SwiftUI делится на 2 этапа — обработка поднятия элемента и его падения. Разберем их.
Для того, чтобы объект был доступен для поднятия необходимо применить модификатор, вызвав метод onDrag:
func onDrag(_ data: @escaping () -> NSItemProvider) -> some View
Внутри метода мы должны вернуть NSItemProvider для конкретного элемента. Эта структура данных используется со времени UIKit и используется как транспорт данных между и внутри процессов.
TeamView(team: team) .onDrag({ let draggedItem = DropItem(division: group.division, team: team) self.draggedItem = draggedItem return NSItemProvider( object: draggedItem ) })
Ура, теперь наш объект можно перемещать. Далее необходимо обработать его перемещение и падение. Для этого нам понадобится метод onDrop и делегат DropDelegate.
Дисклеймер: внимательно следите за руками.
Когда в UIKit речь заходит о делегатах, мы представляем себе стандартную реализацию паттерна делегирования. В нем у нас есть один экземпляр класса делегата, например, для таблицы. И в его методы передается вся информация, достаточная для принятия определенного решения, независимо от контекста вызова. Выглядит это обычно так:
tableView.delegate = delegate tableView.dragDelegate = dragDelegate tableView.dropDelegate = dropDelegate
В SwiftUI другая история. Один экземпляр делегата подразумевает привязку к конкретной зоне для падения. При создании делегата мы передаем ему все необходимые данные для для вычисления логики и контента данной сессии.
TeamView(team: team) .onDrag(...) .onDrop( of: DropItem.writableTypeIdentifiersForItemProvider, delegate: TeamsDropDelegate( droppedItem: DropItem(division: group.division, team: team), draggedItem: $draggedItem, items: $viewModel.groups ) )
На этом обработка во View окончена. Наш объект может подниматься и падать. Осталось реализовать вызов бизнес-логики. Для этого обратимся к созданному нами DropDelegate.
DropDelegate
DropDelegate в SwiftUI выполняет те же функции, что и в UIKit, но с одним отличием — он создается для каждого View, которое принимает на себя объекты с D’n’D. Обычно в нем хранится вся необходимая информация для принятия решения либо статически, либо через механизм Binding’ов. Пример полей нашего делегата ниже:
struct TeamsDropDelegate: DropDelegate { private let uuid = UUID() let droppedItem: DropItem @Binding var draggedItem: DropItem? @Binding var items: [Group] }
Также делегат имеет список обязательных методов. Все они связаны с жизненным циклом dropSession. Часть из них имеют реализацию по-умолчанию, поэтому в минимальный набор входит определение метода performDrop(info: DropInfo).
func performDrop(info: DropInfo) -> Bool { draggedItem = nil return true }
Тут выясняется один очень важный нюанс D’n’D в SwiftUI — мы сами отвечаем за все. Вообще за все. Даже за сортировку объектов, когда мы еще только двигаем объект и не роняем его.
UIKit делал это за нас в зависимости от DropProposal, а в SwiftUI пересортировку мы должны отлавливать сами. Отменять, разумеется, тоже. Например так:
func dropEntered(info: DropInfo) { guard let draggedItem = self.draggedItem else { return } guard draggedItem.team != droppedItem.team, draggedItem.division.id == droppedItem.division.id, let divisionIndex = items.firstIndex(where: { $0.division.id == draggedItem.division.id }), let from = items[divisionIndex].teams.firstIndex(of: draggedItem.team), let to = items[divisionIndex].teams.firstIndex(of: droppedItem.team) else { return } withAnimation(.default) { self.items[divisionIndex].teams.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to) } }

Вместо титров
Пожалуй, это все, что я хотел рассказать про Drag and Drop в рамках данной статьи. Это действительно очень богатое и полезное API, которое можно применять и вне таблиц и коллекций. Оно сильно упрощает жизнь как разработчикам, так и конечным пользователям.
Если остались вопросы — задавайте их в комментариях или напрямую на почту — anikitin@65apps.com
ссылка на оригинал статьи https://habr.com/ru/articles/594581/
Добавить комментарий