Реалізація анімації Drag & Reorder у мобільних додатках
Drag & Reorder дозволяє користувачам перетягувати елементи списку для зміни їх порядку. Користувач довго натискає на елемент; список розуміє намір (елемент піднімається і трохи масштабується); потім працює drag: інші елементи розступаються, показуючи місце вставки.
Технічно це один із найскладніших списків анімацій: відстежувати drag у реальному часі, обчислювати цільову позицію вставки, анімувати сусідні елементи без перекомпозиції всього списку.
iOS: UICollectionView з реордерингом
UICollectionView має вбудовану підтримку інтерактивного реордерингу через UICollectionViewDragDelegate та UICollectionViewDropDelegate (iOS 11+):
collectionView.dragDelegate = self
collectionView.dropDelegate = self
collectionView.dragInteractionEnabled = true
// UICollectionViewDragDelegate:
func collectionView(_ collectionView: UICollectionView,
itemsForBeginning session: UIDragSession,
at indexPath: IndexPath) -> [UIDragItem] {
let item = items[indexPath.item]
let itemProvider = NSItemProvider(object: item.id as NSString)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = item
return [dragItem]
}
// UICollectionViewDropDelegate:
func collectionView(_ collectionView: UICollectionView,
performDropWith coordinator: UICollectionViewDropCoordinator) {
guard let destinationIndexPath = coordinator.destinationIndexPath,
let item = coordinator.items.first,
let sourceIndexPath = item.sourceIndexPath else { return }
collectionView.performBatchUpdates {
items.move(fromOffsets: IndexSet(integer: sourceIndexPath.item),
toOffset: destinationIndexPath.item)
collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath)
}
coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)
}
UICollectionView автоматично анімує перемещення сусідніх ячеєк — це найцінніше. Не потрібно вручну рахувати й анімувати offset кожного елемента.
Для UITableView — аналогічно через UITableViewDragDelegate/UITableViewDropDelegate або через старий підхід з editingStyle:
tableView.isEditing = true
// Delegate method:
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
items.move(fromOffsets: IndexSet(integer: sourceIndexPath.row),
toOffset: destinationIndexPath.row)
}
У SwiftUI — List з .onMove:
List {
ForEach(items) { item in
ItemRow(item: item)
}
.onMove { source, destination in
items.move(fromOffsets: source, toOffset: destination)
}
}
.environment(\.editMode, .constant(.active))
.onMove додає стандартні drag handles. Для кастомного long-press drag без edit mode — DragGesture з .onChanged та .onEnded, плюс вручну вичислення цільового індекса. Складніше, але дає повний контроль над видом.
Android Compose: LazyColumn + reorderable
Compose немає вбудованого reorder — використовуємо бібліотеку sh.calvin.reorderable:reorderable:2.4.0:
val listState = rememberLazyListState()
var list by remember { mutableStateOf(items) }
val reorderState = rememberReorderableLazyListState(listState) { from, to ->
list = list.toMutableList().apply { add(to.index, removeAt(from.index)) }
}
LazyColumn(
state = listState,
modifier = Modifier.reorderable(reorderState)
) {
items(list, key = { it.id }) { item ->
ReorderableItem(reorderState, key = item.id) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
val scale by animateFloatAsState(if (isDragging) 1.05f else 1f)
Card(
modifier = Modifier
.fillMaxWidth()
.scale(scale)
.shadow(elevation),
) {
Row {
Text(item.title, modifier = Modifier.weight(1f).padding(16.dp))
Icon(
Icons.Default.DragHandle,
contentDescription = null,
modifier = Modifier
.detectReorderAfterLongPress(reorderState) // або draggableHandle()
.padding(16.dp)
)
}
}
}
}
}
isDragging дозволяє анімувати піднятий елемент — scale та shadow через animateFloatAsState та animateDpAsState. Інші елементи автоматично анімуються через анімацію розміщення LazyColumn.
Для кастомної анімації розступання — Modifier.animateItem() на Compose 1.7+:
items(list, key = { it.id }) { item ->
ItemRow(item, modifier = Modifier.animateItem())
}
animateItem() автоматично анімує появу, зникнення та зміщення елементів у LazyColumn при зміні списку.
Flutter
// pubspec: reorderable_list: ^0.2.2 або вбудований ReorderableListView
ReorderableListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
key: ValueKey(items[index].id),
title: Text(items[index].title),
trailing: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
);
},
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
},
proxyDecorator: (child, index, animation) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final scale = Tween<double>(begin: 1.0, end: 1.05)
.evaluate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
return Transform.scale(scale: scale, child: child);
},
child: child,
);
},
)
proxyDecorator — widget-заміна для перетаскуваного елемента. Через нього додаємо scale та shadow ефект без зміни оригінального ListTile.
Типові помилки
Списки без key на елементах — при реординге framework не може зіставити старі та нові позиції, анімація «прискорюється» або ломається. key: ValueKey(item.id) обов'язковий.
Мутація списку без сповіщення framework — у Compose mutableStateOf з list.toMutableList() перед мутацією. У Flutter — setState. Без цього UI не оновлюється.
Збереження нового порядку: після onReorder — одразу відправляємо новий порядок на backend або локальне сховище. Якщо користувач уйде — порядок збережений. Оптимістичне оновлення: застосовуємо до UI одразу, rollback при помилці мережі.
Терміни
Drag & Reorder для простого списку через вбудовані API (UITableView, ReorderableListView): половина дня. Кастомний drag з анімованим проксивідом, розступанням сусідніх елементів та збереженням у сховищі: 1–2 дні. Вартість розраховується індивідуально.







