Реализация анимации перетаскивания элементов (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's item placement animation.
Для кастомной анимации расступания — 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 — виджет-заменитель для перетаскиваемого элемента. Через него добавляем 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 дня. Стоимость рассчитывается индивидуально.







