Based on google samples and some posts in Medium, i came up with this implementation:
DragDropColumn.kt ->
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T : Any> DragDropColumn(
items: List<T>,
onSwap: (Int, Int) -> Unit,
itemContent: @Composable LazyItemScope.(item: T) -> Unit
) {
var overscrollJob by remember { mutableStateOf<Job?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex ->
onSwap(fromIndex, toIndex)
}
LazyColumn(
modifier = Modifier
.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
if (overscrollJob?.isActive == true)
return@detectDragGesturesAfterLongPress
dragDropState
.checkForOverScroll()
.takeIf { it != 0f }
?.let {
overscrollJob =
scope.launch {
dragDropState.state.animateScrollBy(
it*1.3f, tween(easing = FastOutLinearInEasing)
)
}
}
?: run { overscrollJob?.cancel() }
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = {
dragDropState.onDragInterrupted()
overscrollJob?.cancel()
},
onDragCancel = {
dragDropState.onDragInterrupted()
overscrollJob?.cancel()
}
)
},
state = listState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(items = items) { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index
) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Card(elevation = elevation) {
itemContent(item)
}
}
}
}
}
DragDropState.kt ->
class DragDropState internal constructor(
val state: LazyListState,
private val scope: CoroutineScope,
private val onSwap: (Int, Int) -> Unit
) {
private var draggedDistance by mutableStateOf(0f)
private var draggingItemInitialOffset by mutableStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggedDistance - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == currentIndexOfDraggedItem }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
// used to obtain initial offsets on drag start
private var initiallyDraggedElement by mutableStateOf<LazyListItemInfo?>(null)
var currentIndexOfDraggedItem by mutableStateOf<Int?>(null)
private val initialOffsets: Pair<Int, Int>?
get() = initiallyDraggedElement?.let { Pair(it.offset, it.offsetEnd) }
private val currentElement: LazyListItemInfo?
get() = currentIndexOfDraggedItem?.let {
state.getVisibleItemInfoFor(absoluteIndex = it)
}
fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
draggingItemInitialOffset = it.offset
}
}
fun onDragInterrupted() {
if (currentIndexOfDraggedItem != null) {
previousIndexOfDraggedItem = currentIndexOfDraggedItem
// val startOffset = draggingItemOffset
scope.launch {
//previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
tween(easing = FastOutLinearInEasing)
)
previousIndexOfDraggedItem = null
}
}
draggingItemInitialOffset = 0
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
}
fun onDrag(offset: Offset) {
draggedDistance += offset.y
initialOffsets?.let { (topOffset, bottomOffset) ->
val startOffset = topOffset + draggedDistance
val endOffset = bottomOffset + draggedDistance
currentElement?.let { hovered ->
state.layoutInfo.visibleItemsInfo
.filterNot { item -> item.offsetEnd < startOffset || item.offset > endOffset || hovered.index == item.index }
.firstOrNull { item ->
val delta = (startOffset - hovered.offset)
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
scope.launch {
onSwap.invoke(
current,
item.index
)
}
}
currentIndexOfDraggedItem = item.index
}
}
}
}
fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
return@let when {
draggedDistance > 0 -> (endOffset - state.layoutInfo.viewportEndOffset+50f).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - state.layoutInfo.viewportStartOffset-50f).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
}
DragDropExt.kt ->
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
onSwap: (Int, Int) -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(
state = lazyListState,
onMove = onSwap,
scope = scope
)
}
return state
}
fun LazyListState.getVisibleItemInfoFor(absoluteIndex: Int): LazyListItemInfo? {
return this
.layoutInfo
.visibleItemsInfo
.getOrNull(absoluteIndex - this.layoutInfo.visibleItemsInfo.first().index)
}
val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
@ExperimentalFoundationApi
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val current: Float by animateFloatAsState(dragDropState.draggingItemOffset * 0.67f)
val previous: Float by animateFloatAsState(dragDropState.previousItemOffset.value * 0.67f)
val dragging = index == dragDropState.currentIndexOfDraggedItem
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = current
}
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = previous
}
} else {
Modifier.animateItemPlacement(
tween(easing = FastOutLinearInEasing)
)
}
Column(modifier = modifier.then(draggingModifier)) {
content(dragging)
}
}
And using this will look like:
@Composable
fun SectionListUI() {
val viewModel: SectionListViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsState()
DragDropColumn(items = uiState.sections, onSwap = viewModel::swapSections) { item ->
Card(
modifier = Modifier
.clickable { viewModel.sectionClick(item) },
) {
Text(
text = item.title,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
}
This implementation suits well for my usecase..
But, feel free to comment and make improvements