아래 글의 업데이트 버전이다.
2024.04.29 - [Jetpack Compose] - [Jetpack Compose] Drag and Drop(드래그해서 재정렬) 기능 만들기
[Jetpack Compose] Drag and Drop(드래그해서 재정렬) 기능 만들기
Drag and Drop 시작하며최초 목표는 버튼을 잡고 바로 위아래로 슬라이드 하면 드래그되면서 순서가 변경되게 하려 했지만 LazyColumn의 드래그가 clickable이나 detectDragGestures보다 먼저 인식되는 문제
developuzzle.tistory.com
해당 글에서는 드래그 앤 드롭이라고 했었지만 컴포즈에 별도의 드래그 앤 드롭(Drag and Drop) API 가 별도 존재하기 때문에 좀 더 명확한 이름인 정렬 가능한 리스트(Reorderable List)라고 이름을 변경하였다.

업데이트하게 된 계기
이전 버전에서는 컴포즈의 MutableState<T>를 사용하는 리스트 형식(mutableStateListOf)을 사용한다면 아이템을 잡고 이동 중 순서 변경이 재구성(recomposition)을 발생시켜 현재 들고 있는 아이템이 계속해서 변경되는 문제가 있었다. (1번 아이템을 들고 아래로 드래그 시 2번, 3번, 4번으로 드래그 중인 아이템이 계속 바뀌며 순서 변경이 이루어지지 않음)
이로 인해 Stateless한 mutableList로 원본 리스트를 바꿔서 사용해야 했고 그에 따라 animateItem()을 사용하는 애니메이션 적용도 원치 않은 순서 변경을 트리거해 사용하기 힘들었다.
새로운 순서 변경 로직
Stateful한 리스트를 사용할 방법을 찾으려 했고 이전 버전에서 발생한 문제점의 가장 큰 원인이 아이템이 직접 이동하는 과정 중 재구성이 발생하기 때문이였으므로 새로운 순서 변경 로직을 만들어보았다. 새로운 로직은 이전과 달리 드래그한 만큼 아이템을 직접 이동시키지 않고 상하단에서 이동한 y값만큼 더한 값이 위치를 변경할 아이템의 중앙(임계값)을 지나면 업데이트한다.
동작 화면
아이템이 드래그하는 만큼 움직이는 것이 아니며 순서를 변경했을 때 그 위치로 이동하는 것이 애니메이션을 사용해서 쭉 드래그하는 것처럼 보인다. 영상으로는 잘 와닿지 않을 수 있는데 직접 사용해 보면 무슨 말인지 이해하기 쉬울 것이다.
- 자동 스크롤

오토 스크롤 동작은 포인터가 LazyColumn 영역 밖으로 나간 상태로 드래그된다면 아이템은 포인터를 따라오지 않으며 포인터가 밖에서 다시 안으로 들어오면 그 위치로 드래그 중이었던 아이템을 가져온다. 위의 gif를 보면 화면에 아이템이 남아있을 때와 사라졌을 때 모두 동일하다.
화면의 속도는 16f로 설정한 값이며 변경해도 괜찮고 나간 거리에 따른 속도 조절을 하고 싶다면 이전 글을 참고해 해당 로직을 추가하면 된다.
코드 설명
- 확장 함수
private fun <T> SnapshotStateList<T>.reorder( from: Int, to: Int, onBlockScroll: () -> Unit ) { if (from == to || to < 0 || to >= this.size) return add(to, removeAt(from)) if (from == 0 || to == 0) onBlockScroll() }
제네릭<T>를 사용하는 SnapshotStateList에 대한 확장 함수로 제작해 stateful한 리스트에 문제없이 사용할 수 있다. onBlockScroll()은 드래그 중 리스트 양끝의 아이템이 인근 아이템과 무한히 순서 변경되는 현상이 있어 그걸 막는 로직이다.
- Modifier 확장 함수
fun Modifier.reorderable( state: ReorderableState, onUpdateList: () -> Unit = {} ): Modifier = this.pointerInput(state) { detectDragGesturesAfterLongPress( onDragStart = { state.onDragStart(it) }, onDragEnd = { state.onDragEnd() onUpdateList() }, onDragCancel = { state.onDragEnd()}, onDrag = { _, offset -> state.onDrag(offset) } ) }
이전 버전에서는 LazyColumn에서 직접 pointerInput을 선언해 주었는데 그 부분을 사용하기 편리하게 분리했으며 선언한 ReorderableState를 입력해 주고 순서를 변경한 리스트로 업데이트하는 로직을 추가하면 된다. 이때 주의할 점은 순서를 변경할 동안은 LazyColumn에 입력된 리스트가 새로고침되면 안 되고 순서 변경이 완료된 후에 업데이트해야 한다.
- ReorderableState
class ReorderableState( private val scope: CoroutineScope, val lazyListState: LazyListState, private val onReorder: (Int, Int) -> Unit ) { // 드래그 시작한 아이템 상하단 y값 private var initialYBounds by mutableStateOf(0 to 0) // 드래그 거리 private var distance by mutableFloatStateOf(0f) // 드래그 중인 아이템 정보 private var info: LazyListItemInfo? by mutableStateOf(null) // 시작 상하단 y값 + 드래그 거리 private val currentYBounds: Pair<Int, Int> get() = initialYBounds.let { (topY, bottomY) -> topY + distance.toInt() to bottomY + distance.toInt() } // 순서 변경 임계값 private val threshold: Int get() = initialYBounds.let { (it.first + it.second) / 2 + distance.toInt() } // 드래그 중인 아이템의 변화하는 인덱스 val currentIndex: Int? get() = info?.index // 오토 스크롤 Job private var autoScrollJob by mutableStateOf<Job?>(null) // 드래그 시작 fun onDragStart(offset: Offset) { lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { item -> // Long Press한 위치에 있는 아이템 찾기 offset.y.toInt() > item.offset && offset.y.toInt() < (item.offset + item.size) }?.let { initialYBounds = it.offset to (it.offset + it.size) info = it } } // 아이템 인덱스 업데이트 private fun updateItemIndex() { info?.let { itemInfo -> when { currentYBounds.first < itemInfo.offset && threshold < itemInfo.offset -> { // 위로 드래그할 때 lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index - 1 }?.let { // 현재 아이템 위에 아이템이 있다면 onReorder(itemInfo.index, itemInfo.index - 1) info = it } ?: run { // 아래로 오토 스크롤 중 아이템이 화면에 보이지 않을 때 val firstItem = lazyListState.layoutInfo.visibleItemsInfo.first() info?.let { onReorder(it.index, firstItem.index) } info = lazyListState.layoutInfo.visibleItemsInfo.first() } } currentYBounds.second > itemInfo.offset + itemInfo.size && threshold > itemInfo.offset + itemInfo.size -> { // 아래로 드래그할 때 lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index + 1 }?.let { // 현재 아이템 아래에 아이템이 있다면 onReorder(itemInfo.index, itemInfo.index + 1) info = it } ?: run { // 위로 오토 스크롤 중 아이템이 화면에 보이지 않을 때 val lastItem = lazyListState.layoutInfo.visibleItemsInfo.last() info?.let { onReorder(it.index, lastItem.index) } info = lazyListState.layoutInfo.visibleItemsInfo.last() } } } } } // 드래그 중 fun onDrag(offset: Offset) { distance += offset.y updateItemIndex() onAutoScroll() } // 드래그 종료 fun onDragEnd() { initialYBounds = 0 to 0 distance = 0f info = null autoScrollJob = null } // 오토 스크롤 private fun onAutoScroll() { if (autoScrollJob?.isActive == true) return autoScrollJob = scope.launch(Dispatchers.Main) { while (true) { val scrollOffset = when { currentYBounds.first < lazyListState.layoutInfo.viewportStartOffset -> -16f currentYBounds.second > lazyListState.layoutInfo.viewportEndOffset -> 16f else -> null } scrollOffset?.let { lazyListState.scrollBy(it) delay(8) } ?: break } } } }
이 부분은 이해하기 쉽도록 주석을 추가해 두었다.
- 상태 객체 생성 함수
@Composable fun <T> rememberReorderableState( list: SnapshotStateList<T>, lazyListState: LazyListState = rememberLazyListState(), ): ReorderableState { val scope = rememberCoroutineScope() val onReorder: (Int, Int) -> Unit = { from, to -> list.reorder(from, to) { scope.launch { lazyListState.scrollToItem(0) } } } return remember { ReorderableState( scope = scope, lazyListState = lazyListState, onReorder = onReorder ) } }
ReorderableState를 초기화해 주는 함수이다. 컴포저블에서 다른 pagerState나 LazyListState 사용하듯이 사용하면 된다.
- Composable
val list = (1..100).map { it.toString() }.toMutableStateList() val reorderableState = rememberReorderableState(list) LazyColumn( state = reorderableState.lazyListState, modifier = Modifier .fillMaxHeight() .reorderable(reorderableState) { // 업데이트 로직 // } ) { itemsIndexed( items = list, key = { _, text -> text } ) { index, text -> Surface( color = Color(0xFFF7F7F7).takeIf { index == reorderableState.currentIndex } ?: Color.White, modifier = Modifier .animateItem() .fillMaxWidth() .height(48.dp) .zIndex(1f.takeIf { index == reorderableState.currentIndex } ?: 0f) .drawBehind { if (index == reorderableState.currentIndex) { drawLine( color = Color(0xFFDADADA), strokeWidth = 0.5.dp.toPx(), start = Offset(0f, 0f), end = Offset(size.width, 0f) ) drawLine( color = Color(0xFFDADADA), strokeWidth = 0.5.dp.toPx(), start = Offset(0f, size.height), end = Offset(size.width, size.height) ) } } ) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxSize() .padding(horizontal = 20.dp) ) { Text( text = text, fontSize = 16.sp, fontWeight = FontWeight.Normal, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier .weight(1f) ) } } } }
영상에 사용한 예시이다.
- LazyColumn의 state에 Reorderable의 lazyListState를 입력하고 modifier에 .reorderable(state)를 추가한다.
- itemIndexed를 사용할 때 고유한 key를 가지도록 설정해 주는 것이 좋다. 그렇지 않으면 애니메이션이 작동하지 않는다.
- 아이템 컨테이너의 zIndex를 드래그 중인 아이템이 가장 높게 설정해 준다.
detectVerticalDragGestures
이전 버전과 업데이트 버전 모두 detectDragGesturesAfterLongPress를 사용해 아이템을 꾹 눌러서 드래그하는 방식으로 구현하였는데 사용 중 꾹 눌러서 드래그하라고 설명하는 것보단 손잡이를 잡고 움직이는 게 낫지 않을까라고 생각해 detectVerticalDragGetstures를 사용하는 방식을 만들었고 수정된 부분이 많지 않아 기존 글에 추가하게 되었다.

detectVerticalGestures를 그냥 사용한다면 LazyColumn의 스크롤과 중첩되어서 스크롤이 되지 않거나 제스처가 인식되지 않는 등 한 가지 동작만 인식되기 때문에 이 부분을 처리해줘야 한다.
- 변경 부분
fun Modifier.reorderable( state: ReorderableState, onUpdateList: () -> Unit = {} ): Modifier = this.pointerInput(state) { detectVerticalDragGestures( onDragStart = { state.onDragStart(it) }, onVerticalDrag = { _, amount -> state.onDrag(amount) }, onDragEnd = { state.onDragEnd() onUpdateList() }, onDragCancel = { state.onDragEnd() } ) }
detectDragGesturesAfterLongPress를 detectVerticalDragGestures로 변경해 준다. onVerticalDrag는 두 번째 콜백 시그니처가 offset이 아닌 float이므로 이해하기 쉽게 amount로 변경하였다.
class ReorderableState( private val scope: CoroutineScope, val lazyListState: LazyListState, private val onReorder: (Int, Int) -> Unit ) { // ... fun onDrag(amount: Float) { distance += amount updateItemIndex() onAutoScroll() } // ... }
onDrag에 입력되는 타입이 변경되었기에 그에 따라 distance 계산 로직을 변경해 준다.
var isDragMode by remember { mutableStateOf(false) } // 새로 추가 LazyColumn( state = reorderableState.lazyListState, userScrollEnabled = !isDragMode, // 드래그 중일 때는 스크롤 비활성화 modifier = Modifier .weight(1f) .reorderable(reorderableState) { // 업데이트 로직 // } ) { // ... Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_menu), contentDescription = "순서 변경", modifier = Modifier .pointerInput(Unit) { detectTapGestures( onPress = { isDragMode = true // 터치했을 때 tryAwaitRelease() isDragMode = false // 터치 뗐을 때 } ) } ) // ... }
위에서 말한 스크롤과 드래그 중첩 문제는 손잡이에 detectTapGestures를 추가해 손잡이를 눌러 움직이기 시작하면 LazyColumn의 스크롤을 비활성화하고 tryAwaitRelease()를 사용해 손잡이에서 터치를 뗐을 때 다시 스크롤이 가능하게 설정해 해결하면 된다.
마치며
추가로 궁금한 점이나 피드백, 오류가 있다면 댓글이나 카톡으로 말씀해 주시면 빠르게 답변해 드리겠습니다.