Тащите меня семеро, или D’n’D в iOS

от автора

Привет, Хабр! Меня зовут Никитин Алексей, я iOS разработчик в компании 65apps. Хорошо было бы порассуждать о Dungeons & Dragons, но нет. Речь пойдет о перемещении объектов. Перетаскивание как внутри одного приложения, так и между разными — с точки зрения пользователя вещь обыденная. Но под капотом механизма D’n’D в современных приложениях могут скрываться разные варианты решения. О них и поговорим.

Для начала — установим требования, которые нам необходимо выполнить: 

  1. Реализовать сортировку элементов внутри секции.

  2. Добавить возможность взаимодействия объектов между и внутри секций.

  3. Подсвечивать зону, в которую будет «падать» перемещенный объект.

Поставленные задачи повторяют требования реального проекта. Для примера будут представлены скриншоты и фрагменты кода из тестового приложения. Его можно будет посмотреть на 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 нет готовых реализаций, но есть достаточно простые способы добиться желаемого результата. Я решил эту задачу следующим образом:

  1. Создал backdropView и положил ее в tableView

  2. Во время определения UITableViewDropProposal вычисляю место падения ячейки. Для этого использую destinationIndexPath и метод таблицы rectForRow(at: IndexPath)

  3. Анимировано меняю frame у backdropView на только что вычисленный

  4. Скрываю 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *