무려 2년 전에 기존 XML을 사용하여 UI를 그릴 때, Drag and Drop 기능을 통해 아이템들의 순서를 변경하는 기능을 구현한 적이 있다.
2022.11.12 - [Android/Utility] - [Android] RecyclerView Drag and Drop
필자는 XML을 사용했을 때는 비교적 쉽게(?) 해당 기능을 구현했었다고 기억하고 있는데, Compose 환경에서는 해당 기능을 제공하거나 구현하기 더 간단하지 않을까?라는 생각을 했다.
그래서 실무에서 해당 기능을 구현해 보려고 도전해 봤는데, 존재하는 라이브러리는 코틀린 버전 문제로 사용 못하거나 스크롤 이벤트들이 정상적으로 동작하지 않거나 했고, 믿었던 GPT 마저도 정상적으로 기능을 구현해주지 못해 직접 구현해 보게 되었다.
완벽하게, 자연스럽게 동작하는 샘플은 아니지만 해당 코드에서 조금만 더 고도화를 한다면 실제로 사용이 가능할 것으로 생각되어 해당 기능을 찾아보고, 구현하기 까지를 기록 해보고자 한다.
우선 필자는 어떻게 구현을 해야하니 막막하여 구글링을 먼저 해보았다.
https://developer.android.com/develop/ui/compose/touch-input/user-interactions/drag-and-drop
Drag and Drop기능을 제공해 준다고 해서 확인해 보니, 우리가 쉽게 생각하는 이미지와 같은 것들을 드래그해서 놓는 기능을 말하고 있었다.
같은 Drag and Drop 기능이지만 필자가 원하는 것은 LazyColumn과 같은 Component 환경에서 그 안에 들어가 있는 아이템들을 드래그하여 순서를 옮기고 싶은 것이다.
관련해서 계속해서 구글링을 해도, 위의 Android Developer 페이지에서 확인할 수 있는 기능에 대해서만 나온다.
다른 워딩을 사용하여 검색하기 위해 gpt에게 한번 키워드를 물어봤더니 다음과 같은 답변을 내놓았다.
- Reorderable List (재정렬 가능한 리스트)
- Drag and Drop List (드래그 앤 드롭 리스트)
- Sortable List (정렬 가능한 리스트)
- List with Reordering (재정렬 기능이 있는 리스트)
따라서 Reorderable List로 검색을 해보니 여러 가지 내용을 확인해 볼 수 있었고, 그중에는 라이브러리도 존재했다.
1. https://github.com/aclassen/ComposeReorderable
2. https://github.com/Calvin-LL/Reorderable?tab=readme-ov-file#faq
해당 라이브러리를 사용하여 구현을 하려고 해보았지만 다음과 같은 이유로 사용할 수 없었다.
- Drag 시 스크롤이 되지 않음.
- 제공 중인 Kotlin, Compose 버전을 대응할 수 없음.
1번 라이브러리의 경우 정상적으로 동작하고, 생각보다 많은 케이스의 Drag and Drop이 가능하지만 아이템이 화면을 넘어갈 때 scroll이 정상적으로 되지 않았고,
2번 라이브러리의 경우 라이브러리를 추가했을 때 kotlin 버전에 따른 에러가 발생하여 해당 버전을 찾아 적용하려고 하였으나, 정말 최신 버전을 사용하였는지 1.9.24 버전, 그에 대응하는 compose 버전 1.5.14 버전으로 올려서 확인해 봐도 계속해서 버전을 변경하라는
Kotlin library {0} was complied with a newer Kotlin compiler and can't be read
와 같은 에러가 발생하여 사용할 수 없었다.
실무에서 사용할 것을 생각하고 적용해보는 것이기 때문에, 가장 최신 버전의 kotlin, compose 버전은 현재로서 사용하기 힘들다고 판단했고, 사용한다면 1번 라이브러리를 커스텀하겠지만 한번 구현을 해보고 싶어서 사용하지 않는 방향으로 생각하였다,
코드를 작성하기 앞서, 간단하게 생각해서 다음과 같은 순서대로 구현을 목표로 하였다.
- LazyColumn을 사용하여 스크롤 되는 UI를 그린다.
- Drag Event를 추가한다.
- Drag 시 Item의 순서를 변경하는 로직을 추가한다.
- Scroll 이벤트를 추가한다.
1번 항목을 구현하면 다음과 같다.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DragAndDropExampleUI(
onBackButtonClick: () -> Unit
) {
var items by remember { mutableStateOf(List(20) { "Item $it" }) }
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(color = Color.White)
) {
stickyHeader {
...
}
itemsIndexed(items) { index, item ->
DraggableItem(
item = item
)
}
}
}
@Composable
fun DraggableItem(
item: String,
) {
Card(
modifier = Modifier
.fillMaxWidth()
) {
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.background(color = Color.Gray)
)
}
}
다음으로는 DraggableItem에 Drag 이벤트를 추가해야 한다.
2번. Drag 이벤트는 Card Component에 추가해야 하며, Modifier에는 터치 이벤트를 처리할 수 있는 pointerInput 가 존재한다.
pointerInput를 사용해서 그 안에서 item에 대한 터치 이벤트를 처리해야 하는데, 찾아보다 보니 정확하게 필자가 필요한 LongPress 하여 아이템을 Drag 하는 경우를 감지하는 detectDragGesturesAfterLongPress라는 API가 존재했다.
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { },
onDragEnd = { },
onDrag = { change, dragAmount ->
}
)
}
detectDragGesturesAfterLongPress는 drag의 시작과 끝, 하는 도중에 대한 이벤트를 받아서 처리가 가능하므로 해당 타이밍에 Drag 하는 card item에 대한 이동 처리를 해주면 된다.
위에 대한 이벤트들을 추가하여 DraggableItem Component를 다음과 같이 구현한다.
@Composable
fun DraggableItem(
item: String,
onDragStart: () -> Unit,
onDragEnd: () -> Unit,
onDrag: (Offset) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart() },
onDragEnd = { onDragEnd() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
}
)
}
) {
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.background(color = Color.Gray)
)
}
}
drag 이벤트 3개를 그대로 받아서 호출하는 부분에서 처리할 수 있도록 호이스팅 해주도록 한다.
이제 2번과 3번 이벤트를 처리해 주면 된다.드래그 이벤트와 더불어 드래그 시 아이템의 index를 변경해 주는 작업이 필요하다.
이 과정을 구현한다고 생각할 때, 기본적으로 필요한 부분은 현재 drag를 수행한 index를 저장할 데이터가 필요할 것이다.드래그 시 현재 index를 드래그하는 index 값으로 저장하고, 그 값을 기준으로 계산하여 아이템의 index를 변경하여 순서를 갱신해 주면 된다.
onDragStart = { draggedItemIndex = index },
onDragEnd = { draggedItemIndex = null },
해당 데이터를 기반으로 이제 onDrag 함수를 구현하면 되는데, 이것이 생각보다 복잡하다.
dragAmount 값으로 호이스팅 하여 사용 가능한 변수는 drag 시 움직이는 좌표값을 가져올 수 있다.
그리고 현재 구현을 해야 하는 부분은 LazyColumn에서 세로로 움직이는 아이템의 drag를 구현해야 하므로 dragAmount로 넘어가는 좌표값 중 y 좌표를 사용할 수 있을 것이다.또한, drag 되는 좌표 값이 움직이는 아이템의 높이를 넘어가면, 하나의 아이템을 드래그하여 넘어갔다는 것이 되므로 그것을 기준으로 index 값을 갱신할 수 있다.
이 2가지를 고려하면 다음과 같은 구조를 구현할 수 있다.
onDrag = { offset ->
dragOffset += offset.y
val currentIndex = draggedItemIndex ?: return@DraggableItem
val targetIndex =
(currentIndex + (dragOffset / itemHeightPx).toInt())
.coerceIn(0, items.lastIndex)
...
}
drag 하는 아이템의 Offset의 변화를 dragOffset 값에 지정하고,현재 index 값과 drag 한 offset 값 / 아이템의 높이를 사용하여 다음 타깃 되는 아이템의 index를 구한다.
여기서 draggedItemIndex의 값은 null로 갱신하는 부분이 있기 때문에 안정성을 위해 엘비스 연산자를 사용해 주었고,drag 시에 가지고 있는 아이템의 index를 넘어가는 경우 다시 범위 내로 이동시키기 위해 coerceIn을 사용하여 처리해 주었다.
dragOffset 값이 item의 높이를 넘어가면 해당 나눗셈이 정수를 반환하고, offset이 양수, 음수에 따라서 targetIndex는 +-1이 될 것이다.
itemHeight를 사용해야 하므로, 다음과 같이 고정된 값을 받아서 사용하도록 수정해 준다.
Card(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart() },
onDragEnd = { onDragEnd() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
}
)
}
)
아이템의 변경될 index까지 구했으므로, 실제 아이템의 순서를 변경해 준다.
if (targetIndex != currentIndex) {
items = items.toMutableList().apply {
add(targetIndex, removeAt(currentIndex))
}
draggedItemIndex = targetIndex
dragOffset -= itemHeightPx
}
아이템의 index를 변경하면 아이템의 크기만큼 offset을 제거하여 다시 0부터 조절 가능하도록 수정한다.
이렇게 까지 하면 아주 기본적인 구현은 얼추 된 것 같다.
3번까지의 항목이 끝났으니 우선 빌드를 해서 확인을 해보고자 했는데,
문제점이 여러 개 보인다.
- 스크롤되는 아이템이 클릭된 지 직관적이지 않다.
- drag 시 아이템의 이동 정도가 예상과 다르게 너무 크다.
따라서 이와 같은 문제점을 해결해 보도록 하자.
스크롤되는 아이템이 클릭됨을 확실히 인식하기 위해서는 클릭되어 이동이 가능하다는 상태를 명확하게 보여줘야 하고, 아주 간단하게 아이템의 수직 위치를 조절해 주었다.
Card(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.graphicsLayer(translationY = dragOffset)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart() },
onDragEnd = { onDragEnd() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
}
)
}
)
graphicsLayer는 드래그 시 아이템의 수직 위치를 변경하는 데 사용해 주는 Modifier 옵션이다.
2번 항목인 drag 시 아이템의 이동 정도가 너무 큰 부분을 수정하기 위해 dragOffset -= itemHeightPx에서 변경하는 정도를 줄여주도록 한다.
이 부분은 정해진 것도 아니고, 필자가 어느 정도 값을 변경해 가면서 수정한 부분이기 때문에 해당 수치는 사용 시 알아서 변경이 필요하다.
// dragOffset의 변화량을 조절. itemHeightPx을 그대로 사용하기엔 변화량이 너무 크다.
val changeDragOffset = // 변경 수치 * +-1
(itemHeightPx - itemHeightPx / 10) * (targetIndex - currentIndex).sign
이런 식으로 dragOffset을 적당히 조절해서 해당 drag 되는 offset의 정도를 어색하지 않게 설정해 주었다.
이제 마지막으로 drag 하면서 scroll이 가능하도록 lazyListState를 설정해 주면 된다.
coroutineScope.launch {
if (draggedItemIndex != null) {
if (dragOffset > 0) {
if (draggedItemIndex!! >= centerIndex) {
listState.scrollBy(changeDragOffset / 2)
}
}
else {
if (draggedItemIndex!! <= centerIndex) {
listState.scrollBy(changeDragOffset / 2)
}
}
}
}
위에 draggedItemIndex의 값은 null로 갱신하는 부분이 있기 때문에 안정성을 위해 null이 아닐 때만 이동이 되도록 조건을 만들어두고 시작한다.
dragOffset이 0보다 크면 아래로 스크롤, 그렇지 않으면 위로 스크롤 되는 경우이므로 그 값을 사용한 changeDragOffset을 사용하여 scroll을 해주면 된다.
이때 scroll 되는 정도 또한 changeDragOffset를 그대로 사용하면 너무 스크롤되는 변화량이 크기 때문에 이것을 / 2 하여 적절하게 조절해 주었다.
이것으로 LazyColumn Item의 drag and drop 기능을 직접 구현해보았다.
물론 적당히 아이템의 크기에 맞춰서, 상황에 따라서 변경되는 값을 사용하여 구현을 했다는 것이 마음에 들지는 않지만
그렇게 유동적으로 사용되는 component가 아니라고 생각이 되었고, 구현을 해보고 나니 조금 만 더 상황에 맞춰서 고도화 시키면 사용할 수는 있을 것 같다. 라고 생각이 들었다.
물론, 라이브러리가 계속해서 고도화 되서 만들어질 것이고 상황에 따라서 이미 사용이 가능한 라이브러리가 있기 때문에 그것을 사용하는 것이 조금 더 안정적일 수 있겠지만, 그래도 이렇게 기능을 구현해보는 것이 생각보다 재미가 있었고, 구현 된 라이브러리의 코드를 까보면서 구현을 한다면 다양한 방면으로 고도화 시킬 수 있겠다 라고 생각한다.
단순하게 다른것들을 참고하지 않고 코드를 구현해보는 것이 얼마만인지 싶기도 하고, 생각보다 쓸수 있겠는데? 라고 생각이 들어서 굉장히 재미있게 구현한 기능이라고 생각한다.
해당 게시글에 사용한 예제는 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
'Android > Jetpack Compose' 카테고리의 다른 글
[Android] Shimmer UI 구현하기 (2) | 2024.10.06 |
---|---|
[android] BottomNavigation의 구현 및 방법에 따른 차이 (1) | 2024.09.24 |
[Compose] Compose환경 WebView에서 JavascriptInterface를 사용할 때 주의할 점 (0) | 2024.05.26 |
[Compose] Side Effect 관련 API 재 정리 (0) | 2024.05.06 |
[Compose] SwipeToDismiss를 사용하여 스와이프 이벤트를 추가해보자. (1) | 2024.04.27 |