※ 업데이트된 버전을 사용하는 것을 추천합니다.
2025.04.18 - [Jetpack Compose] - [Jetpack Compose] Reorderable List 만들기
[Jetpack Compose] Reorderable List 만들기
아래 글의 업데이트 버전이다.2024.04.29 - [Jetpack Compose] - [Jetpack Compose] Drag and Drop(드래그해서 재정렬) 기능 만들기 [Jetpack Compose] Drag and Drop(드래그해서 재정렬) 기능 만들기Drag and Drop 시작하며최초
developuzzle.tistory.com
Drag and Drop
시작하며
최초 목표는 버튼을 잡고 바로 위아래로 슬라이드 하면 드래그되면서 순서가 변경되게 하려 했지만 LazyColumn의 드래그가 clickable이나 detectDragGestures보다 먼저 인식되는 문제에 부딪혔고 detectDragGesturesAfterLongPress를 사용해 버튼을 꾹 눌러 슬라이드 하는 방식으로 구현하게 되었다.
코드 설명
state holder 클래스를 생성하고 remember로 인스턴스를 초기화해 사용할 컴포저블 함수에서 쉽게 호출할 수 있도록 한다.
- 확장 함수 생성
// 순서 변경 함수
fun <T> MutableList<T>.move(from: Int, to: Int) {
if (from == to) return
val element = this.removeAt(from) ?: return
this.add(to, element)
}
순서 변경 함수는 MutableStateList에는 직접 확장함수를 만들 수 없어 MutableList에 확장함수를 만들었지만 MutableStateList에도 문제없이 사용 가능하다.
- 클래스 생성
class DragDropListState(
val lazyListState: LazyListState,
private val onMove: (Int, Int) -> Unit
) {
// 드래그된 거리
private var draggedDistance by mutableFloatStateOf(0f)
// 드래그 시작한 아이템 인포
private var initiallyDraggedElement by mutableStateOf<LazyListItemInfo?>(null)
// 드래그 시작한 아이템의 상하단 오프셋
private val initialOffsets: Pair<Int, Int>?
get() = initiallyDraggedElement?.let { Pair(it.offset, it.offset + it.size) }
// 드래그 중인 아이템의 현재 위치 (인덱스)
var currentIndexOfDraggedItem by mutableStateOf<Int?>(null)
// 드래그 중인 아이템을 이동시킬 거리
val elementDisplacement
get() = currentIndexOfDraggedItem?.let {
lazyListState.layoutInfo.visibleItemsInfo.getOrNull(it - lazyListState.layoutInfo.visibleItemsInfo.first().index)
}?.let { item ->
(initiallyDraggedElement?.offset ?: 0f).toFloat() + draggedDistance - item.offset
}
// 드래그 중인 아이템 인포
val currentElement: LazyListItemInfo?
get() = currentIndexOfDraggedItem?.let {
lazyListState.layoutInfo.visibleItemsInfo.getOrNull(it - lazyListState.layoutInfo.visibleItemsInfo.first().index)
}
// 오버스크롤 job
var overscrollJob by mutableStateOf<Job?>(null)
// 드래그 시작
fun onDragStart(offset: Offset) {
lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { item ->
offset.y.toInt() in item.offset..(item.offset + item.size)
}?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
}
// 드래그 종료
fun onDragInterrupted() {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
// 드래그 중
fun onDrag(offset: Offset) {
draggedDistance += offset.y
initialOffsets?.let { (topOffset, bottomOffset) ->
val startOffset = topOffset + draggedDistance // 드래그 시작한 아이템의 상단 오프셋 + 드래그 거리
val endOffset = bottomOffset + draggedDistance // 드래그 시작한 아이템의 하단 오프셋 + 드래그 거리
currentElement?.let { hovered -> // 드래그 중인 아이템
lazyListState.layoutInfo.visibleItemsInfo.filterNot { item -> // 드래그 중인 아이템과 겹치지 않는 아이템 필터링
item.offset + item.size <= startOffset || item.offset >= endOffset || hovered.index == item.index
}.firstOrNull { item -> // 드래그 중인 아이템의 현재 위치에 있는 아이템 찾기
when { // 임계점을 넘었는지 판단
startOffset > hovered.offset -> (endOffset > item.offset + item.size / 2) // 드래그 중인 아이템이 위로 이동할 때
else -> (startOffset < item.offset + item.size / 2) // 드래그 중인 아이템이 아래로 이동할 때
}
}?.also { item ->
currentIndexOfDraggedItem?.let { current -> // 드래그 중인 아이템의 현재 위치와 이동할 위치
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
// 오버스크롤 체크
fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offset + it.size + draggedDistance
return@let when {
draggedDistance > 0 -> (endOffset - lazyListState.layoutInfo.viewportEndOffset).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - lazyListState.layoutInfo.viewportStartOffset).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
}
// 해당 함수를 컴포저블 함수에서 호출해서 사용
@Composable
fun rememberDragDropListState(
lazyListState: LazyListState = rememberLazyListState(),
onMove: (Int, Int) -> Unit,
): DragDropListState {
return remember {
DragDropListState(
lazyListState = lazyListState,
onMove = onMove
)
}
}
임계점을 넘었는지, 즉 자리를 바꿀지 판단하는 부분이 (offset + size / 2)이므로 잡고 있는 컴포넌트가 위 또는 아래 컴포넌트의 절반을 통과했을 때 자리가 바뀐다. 이 비율을 원하는 대로 조정해 임계점을 바꿀 수 있다.
사용 방법
val scope = rememberCoroutineScope()
val list = remember { //toMutableList를 사용하거나 mutableList를 선언 }
val listState = rememberDragDropListState(onMove = { from, to -> list.move(from, to) })
LazyColumn(
state = listState.lazyListState,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset -> listState.onDragStart(offset) },
onDragEnd = { listState.onDragInterrupted() },
onDragCancel = { listState.onDragInterrupted() },
onDrag = { change, offset ->
change.consume()
listState.onDrag(offset)
if (listState.overscrollJob?.isActive == true) return@detectDragGesturesAfterLongPress
listState.checkForOverScroll().takeIf { it != 0f }?.let {
listState.overscrollJob = scope.launch { listState.lazyListState.scrollBy(it) }
} ?: listState.overscrollJob?.cancel()
}
)
}
) {
itemsIndexed(
items = list,
key = { index, item -> "$index-$item" }
) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.zIndex(if (index == listState.currentIndexOfDraggedItem) 1f else 0f)
.graphicsLayer {
translationY = listState.elementDisplacement.takeIf { index == listState.currentIndexOfDraggedItem } ?: 0f
shadowElevation = 4.dp.toPx().takeIf { index == listState.currentIndexOfDraggedItem } ?: 0f
}
.fillMaxWidth()
.height(68.dp)
.background(Color.White)
) {
Icon(
painter = painterResource(R.drawable.ic_remove_filled),
contentDescription = "삭제",
tint = Color.Red,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { list.removeAt(index) }
)
)
Text(
text = item,
fontFamily = pretendard,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 20.dp)
)
Icon(
painter = painterResource(R.drawable.ic_menu),
contentDescription = "드래그",
modifier = Modifier
.padding(end = 20.dp)
)
}
}
}
그 외 궁금한 점이나 피드백은 댓글이나 카톡으로 연락해 주시면 최대한 빠르게 답변해드리겠습니다.
https://open.kakao.com/o/syg9ngUf
Reference
https://gist.github.com/virendersran01/9185a251654d09ee4d9dae2ec45d99fa
Drag-n-Drop implementation in Jetpack Compose
Drag-n-Drop implementation in Jetpack Compose. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
https://developer.android.com/develop/ui/compose/state?hl=ko#retrigger-remember
상태 및 Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 상태 및 Jetpack Compose 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 앱의 상태는 시간이 지남에 따라
developer.android.com