Jetpack Compose로 Drag and Drop(드래그로 순서 변경) 기능 만들기

2024. 4. 29. 22:37·Jetpack Compose

※ 업데이트된 버전을 사용하는 것을 추천합니다.

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(드래그로 순서 변경) 기능 만들기※ 업데이트된 버전

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

'Jetpack Compose' 카테고리의 다른 글
  • Jetpack Compose로 Calendar 만들기
  • Jetpack Compose Text Width(텍스트 너비) 측정하기
  • Jetpack Compose Text 기본 폰트 설정하기
  • Jetpack Compose 사용하기
브애애앳
브애애앳
  • 브애애앳
    디벨로퍼즐
    브애애앳
  • 전체
    오늘
    어제
    • 분류 전체보기 (29)
      • Android (4)
      • Android App (1)
      • Figma (1)
      • Jetpack (2)
      • Jetpack Compose (16)
      • Kotlin (4)
      • Tistory (1)
  • 링크

    • 카카오톡 문의
  • 인기 글

  • hELLO· Designed By정상우.v4.10.0
브애애앳
Jetpack Compose로 Drag and Drop(드래그로 순서 변경) 기능 만들기
상단으로

티스토리툴바