Содержание
Сегодня научимся изменять порядок ячеек, перетаскивать ячейки группами, перемещать ячейки между коллекциями и даже между приложениями. Разберём перетаскивание для коллекции и таблицы.
Перед погружением в код разберёмся, как устроен жизненный цикл драга и дропа.

Драг отвечает за перемещение объекта, а дроп — за сброс объекта и его новое положение. Нет сервиса/модели, которое отвечает за начало драга. Когда палец с ячейкой ползёт по экрану, вызывается метод делегата. Очень похоже на
let itemProvider = NSItemProvider.init(object: yourObject)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = action
return dragItem
Чтобы провайдер смог скушать любой объект, реализуйте протокол
extension YourClass: NSItemProviderWriting {
public static var writableTypeIdentifiersForItemProvider: [String] {
return ["YourClass"]
}
public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
return nil
}
}
Мы готовы. Потянули!
Разберем на примере коллекции. Советую использовать
Установим драг-делегат:
class CollectionController: UICollectionViewController {
func viewDidLoad() {
super.viewDidLoad()
collectionView.dragDelegate = self
}
}
Реализуем протокол
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let itemProvider = NSItemProvider.init(object: yourObject)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = action
return dragItem
}
Вы уже видели этот код выше. Он оборачивает наш объект в
Добавим ещё два метода —
extension CollectionController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let itemProvider = NSItemProvider.init(object: yourObject)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = action
return dragItem
}
func collectionView(_ collectionView: UICollectionView, dragSessionWillBegin session: UIDragSession) {
}
func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) {
}
}
Первый метод вызывается, когда драг начался, а второй - когда драг закончился. Перед
Давайте посмотрим, что получается на этом этапе.
Ячейка возвращается на место потому что дроп еще не готов, его реализуем дальше.
В протоколе
func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
// Код аналогичен.
// Создаём `UIDragItem` на основе нашего объекта.
let itemProvider = NSItemProvider.init(object: yourObject)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = action
return dragItem
}
Теперь ячейки собираются в стопку. Стопку можно сбрасывать как отдельные ячейки.
Драг - половина дела. Теперь научимся сбрасывать ячейку. Реализуем протокол
extension CollectionController: UICollectionViewDropDelegate {
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
}
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
}
func collectionView(_ collectionView: UICollectionView, dropSessionDidEnd session: UIDropSession) {
}
}
Первый метод требует вернуть объект
Вернуть можно один из нескольких статусов, разберём каждый.
// Ячейка вернётся на место, визуальные индикаторы не появятся. Действие не смещает другие ячейки.
return .init(operation: .cancel)
// Появится серая перечеркнутая иконка. Это значит, что операция запрещена.
return .init(operation: .forbidden)
// Произойдёт полезное действие, визуальные индикаторы не появятся.
return .init(operation: .move)
// Ячейки смещаются для предлагаемого места дропа, визуальные индикаторы не появятся.
return .init(operation: .move, intent: .insertAtDestinationIndexPath)
// Появляется зелёный плюс — индикатор копирования.
return .init(operation: .copy)
В нашем примере сделаем так - если есть прогнозируемый IndexPath, то разрешаем сброс. Если нет - то запрещаем. Лучше поставить отмену, но так будет нагляднее.
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
guard let _ = destinationIndexPath else { return .init(operation: .forbidden) }
return .init(operation: .move, intent: .insertAtDestinationIndexPath)
}
Здесь решаем самые главные дела: меняем данные, переставляем ячейки и уведомляем систему, куда дропнули вьюху, чтобы система отрисовала анимацию.
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
// Если система не смогла определить IndexPath, то останавливаем выполнение.
// Дальше мы научимся определять индекс самостоятельно, но пока оставим так.
guard let destinationIndexPath = coordinator.destinationIndexPath else { return }
for item in coordinator.items {
// Получаем доступ к нашему объекту, приводим тип.
guard let yourObject = item.dragItem.localObject as? YourClass else { continue }
// Объект перемещаем из одного места в другое. Я использую псевдофункцию, подразумевая кастомную логику:
move(object: yourObject, to: destinationIndexPath)
}
// Не забудьте обновить коллекцию.
// Если используете классический data source, изменения вносите в блоке `performBatchUpdates`.
// Если у вас diffable data source, используйте обновление снепшота.
// Функция для примера, такой функции нет.
collectionView.reloadAnimatable()
// Уведомляем, куда сбросили элемент.
// Самостоятельно реализуйте функцию `getIndexPath`.
for item in coordinator.items {
guard let yourObject = item.dragItem.localObject as? YourClass else { continue }
if let indexPath = getIndexPath(for: yourObject) {
coordinator.drop(item.dragItem, toItemAt: indexPath)
}
}
}
Теперь коллекция и data source обновляются при перемещении, ячейка дропается по новому индексу. Глянем, что получилось:
Чтобы ячейки расступались для дропа другой ячейки, используйте Drop Proposal c
При попытке сбросить ячейку последней FlowLayout запросит несуществующие атрибуты ячейки. Когда ячейки расступаются, лейаут рисует ячейку внутри, а при дропе получается ячеек больше, чем моделей в Data Source. Это решается переопределением метода в
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
if let countItems = collectionView?.numberOfItems(inSection: indexPath.section) {
if countItems == indexPath.row {
// If ask layout cell which not isset,
// shouldn't call super.
return nil
}
}
return super.layoutAttributesForItem(at: indexPath)
}
Для таблицы есть аналогичные протоколы
public protocol UITableViewDragDelegate: NSObjectProtocol {
optional func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem]
optional func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession)
optional func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession)
}
Дроп работает аналогично. Дроп работает без костылей в таблице, подозреваю что из-за отсутствия лейаута.
Редактирование таблицы никак не влияет на вызовы методов дропа.
tableView.isEditing = true
То есть у вас может быть системный реордер ячеек и дроп внутрь ячеек.
Системный параметр
Давайте напишем функцию, которая сможет предложить свой индекс, если системное предложение равно
// В качестве входных параметров используем системный индекс и сессию дропа.
// Если системный индекс будет равен `nil`, то у нас появятся две системы расчёта.
private func getDestinationIndexPath(system passedIndexPath: IndexPath?, session: UIDropSession) -> IndexPath? {
// Здесь попытаемся получить индекс по локации дропа.
// Чаще всего результат будет совпадать с системным, но когда системного нет, может вернуть хорошее значение.
let systemByLocationIndexPath = collectionView.indexPathForItem(at: session.location(in: collectionView))
// Здесь хардкор. Берём локацию и ищем в радиусе 100 точек ближайшую ячейку.
var customByLocationIndexPath: IndexPath? = nil
if systemByLocationIndexPath == nil {
var closetCell: UICollectionViewCell? = nil
var closetCellVerticalDistance: CGFloat = 100
let tapLocation = session.location(in: collectionView)
for indexPath in collectionView.indexPathsForVisibleItems {
guard let cell = collectionView.cellForItem(at: indexPath) else { continue }
let cellCenterLocation = collectionView.convert(cell.center, to: collectionView)
let verticalDistance = abs(cellCenterLocation.y - tapLocation.y)
if closetCellVerticalDistance > verticalDistance {
closetCellVerticalDistance = verticalDistance
closetCell = cell
}
}
if let cell = closetCell {
customByLocationIndexPath = collectionView.indexPath(for: cell)
}
}
// Вернём значение в порядке приоритета.
return passedIndexPath ?? systemByLocationIndexPath ?? customByLocationIndexPath
}
Улучшим код для обновления интерфейса:
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
guard let _ = getDestinationIndexPath(system: destinationIndexPath, session: session) else { return .init(operation: .forbidden) }
return .init(operation: .move, intent: .insertAtDestinationIndexPath)
}
Обратите внимание: метод поможет только с дропом. Если используете
Другие туториалы
Знакомимся с модификатором
Вместе c iOS 15 обновили SF Symbols до 3-ей версии. Добавили 600 новых символов и разные способы покрасить их. Некоторые символы получили вариации форм.
Как устроен ProgressView. Как настроить внешний вид: спиннер и прогресс-бар.
В этой статье я покажу как добавить свою View в Xcode Library с помощью LibraryContentProvider.
В telegram-канале приходят уведомления о новых туториалах. В чате для iOS разработчиков ответят на вопросы.