Привет, Хабр! Меня зовут Никитин Алексей, я 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
2Jumper3
Ой плюс 100500 в карму. Прямо с удовольствием перечитал)