Привет, Хабр! Меня зовут Никитин Алексей, я 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

Комментарии (3)


  1. 2Jumper3
    15.12.2021 13:07

    Ой плюс 100500 в карму. Прямо с удовольствием перечитал)


  1. valery_garaev
    15.12.2021 15:43

    Разве DnD это не Dungeons & Dragons)?


    1. AlekseyNikitin Автор
      15.12.2021 15:44

      Спасибо, исправлено!