이번 블로그 포스트에서는 Jetpack Compose를 사용하여 드래그 앤 드롭 기능이 포함된 재정렬 가능한 리스트를 만드는 방법을 소개합니다. 이 예제는 사용자가 리스트 아이템을 재정렬하고 제거할 수 있는 기능을 간단 애니메이션과 함께 제공합니다.
draggableItems
이 함수는 LazyListScope에 대한 확장 함수로, 리스트의 아이템을 드래그할 수 있도록 표시합니다.
@OptIn(ExperimentalFoundationApi::class) inline fun <T : Any> LazyListScope.draggableItems( items: List<T>, dragDropState: DragDropState, crossinline content: @Composable (Modifier, T) -> Unit, ) { itemsIndexed( items = items, contentType = { index, _ -> DraggableItem(index = index) }) { index, item -> val modifier = if (dragDropState.draggingItemIndex == index) { Modifier .zIndex(1f) .graphicsLayer { translationY = dragDropState.delta } } else { Modifier.animateItemPlacement( animationSpec = tween( durationMillis = 500, easing = LinearOutSlowInEasing ) ) } content(modifier, item) } }
목적: 이 함수는 드래그 상태에 따라 아이템의 애니메이션과 시각적 표현을 처리합니다.
매개변수
- items: 표시할 아이템 목록입니다.
- dragDropState: 드래그 앤 드롭 작업을 관리하는 상태 객체입니다.
- content: 각 아이템이 어떻게 표시될지를 정의하는 람다입니다.
기능: 드래그 중인 아이템은 시각적으로 들어 올려지며 (zIndex와 graphicsLayer 사용), 다른 아이템은 위치가 변경될 때 애니메이션을 적용합니다.
dragContainer
이 함수는 Modifier에 드래그 처리를 추가합니다.
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { return this.then(pointerInput(dragDropState) { detectDragGesturesAfterLongPress( onDrag = { change, offset -> // 사용자의 터치 이벤트를 소비함으로써 다른 핸들러로 전달되지 않도록 함 change.consume() // DragDropState 객체의 onDrag 함수를 호출하여 현재 드래그 중임을 알리고, 이동된 거리를 전달 dragDropState.onDrag(offset = offset) }, onDragStart = { offset -> // DragDropState 객체의 onDragStart 함수를 호출하여 드래그 시작을 알림 dragDropState.onDragStart(offset) }, onDragEnd = { // DragDropState 객체의 onDragInterrupted 함수를 호출하여 드래그 동작이 중단됨을 알림 dragDropState.onDragInterrupted() }, onDragCancel = { // DragDropState 객체의 onDragInterrupted 함수를 호출하여 드래그 동작이 중단됨을 알림 dragDropState.onDragInterrupted() } ) }) }
목적: 이 함수는 드래그 동작을 감지하고 처리하기 위해 pointerInput을 사용합니다.
매개변수:
- dragDropState: 드래그 앤 드롭 상태를 관리하는 객체입니다.
기능: 아이템을 드래그할 때, 시작, 진행, 종료, 취소 이벤트를 처리하여 상태를 업데이트합니다.
pointerInput: 터치 이벤트를 감지하기 위해 사용되는 Modifier
detectDragGesturesAfterLongPress:
사용자가 컴포넌트를 길게 누른 후 드래그 제스처를 감지
드래그 중 또는 드래그 완료 시 발생하는 다양한 이벤트에 대한 콜백을 제공
rememberDragDropState
이 함수는 드래그 앤 드롭 상태를 기억하고 관리합니다.
@Composable fun rememberDragDropState( lazyListState: LazyListState, onMove: (Int, Int) -> Unit, draggableItemsNum: Int ): DragDropState { val state = remember(lazyListState) { DragDropState( draggableItemsNum = draggableItemsNum, stateList = lazyListState, onMove = onMove, ) } LaunchedEffect(state) { while (true) { val diff = state.scrollChannel.receive() lazyListState.scrollBy(diff) } } return state }
목적: 이 함수는 드래그 앤 드롭 상태를 기억하고 관리합니다.
매개변수:
- lazyListState: LazyListState 객체입니다.
- onMove: 아이템이 이동할 때 호출되는 콜백 함수입니다.
- draggableItemsNum: 드래그 가능한 아이템의 수입니다.
기능: 상태를 기억하고, 드래그 중 스크롤을 처리합니다.
DragDropState
이 클래스는 드래그 앤 드롭 상태를 관리합니다.
class DragDropState( private val draggableItemsNum: Int, // 드래그 가능한 항목의 총 개수 private val stateList: LazyListState, // LazyColumn 또는 LazyRow의 상태 객체 private val onMove: (Int, Int) -> Unit, // 항목 이동을 처리하는 람다 함수 ) { var draggingItemIndex: Int? by mutableStateOf(null) // 현재 드래그 중인 항목의 인덱스 var delta by mutableFloatStateOf(0f) // 드래그 시 누적된 Y축 이동 거리 val scrollChannel = Channel<Float>() // 자동 스크롤을 위한 채널 private var draggingItem: LazyListItemInfo? = null // 드래그 중인 항목의 정보 internal fun onDragStart(offset: Offset) { // 드래그 시작 시 호출됨 stateList.layoutInfo.visibleItemsInfo .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } ?.also { // 터치 위치에 해당하는 항목이 드래그 가능하다면 설정 (it.contentType as? DraggableItem)?.let { draggableItem -> draggingItem = it draggingItemIndex = draggableItem.index } } } internal fun onDragInterrupted() { // 드래그가 중단되었을 때 호출됨 (예: 터치가 해제된 경우) draggingItem = null draggingItemIndex = null delta = 0f } internal fun onDrag(offset: Offset) { // 드래그 중일 때 반복적으로 호출됨 delta += offset.y // 누적 이동 거리 업데이트 val currentDraggingItemIndex = draggingItemIndex ?: return val currentDraggingItem = draggingItem ?: return // 드래그 중인 항목의 시작, 중간, 끝 위치 계산 val startOffset = currentDraggingItem.offset + delta val endOffset = currentDraggingItem.offset + currentDraggingItem.size + delta val middleOffset = startOffset + (endOffset - startOffset) / 2 // 드래그 중인 항목과 교환할 수 있는 타겟 항목 찾기 val targetItem = stateList.layoutInfo.visibleItemsInfo.find { item -> middleOffset.toInt() in item.offset..item.offset + item.size && currentDraggingItem.index != item.index && item.contentType is DraggableItem } if (targetItem != null) { // 타겟 항목을 찾은 경우 항목 교환 수행 val targetIndex = (targetItem.contentType as DraggableItem).index onMove(currentDraggingItemIndex, targetIndex) // 항목 이동 처리 draggingItemIndex = targetIndex delta += currentDraggingItem.offset - targetItem.offset draggingItem = targetItem } else { // 타겟 항목을 찾지 못한 경우 스크롤 처리 val startOffsetToTop = startOffset - stateList.layoutInfo.viewportStartOffset val endOffsetToBottom = endOffset - stateList.layoutInfo.viewportEndOffset val scroll = when { startOffsetToTop < 0 -> startOffsetToTop.coerceAtMost(0f) // 위로 스크롤 endOffsetToBottom > 0 -> endOffsetToBottom.coerceAtLeast(0f) // 아래로 스크롤 else -> 0f } // 스크롤이 필요하고, 드래그 중인 항목이 첫 번째 또는 마지막 항목이 아닌 경우 if (scroll != 0f && currentDraggingItemIndex != 0 && currentDraggingItemIndex != draggableItemsNum - 1) { scrollChannel.trySend(scroll) // 스크롤 명령 전송 } } } }
목적: 드래그 앤 드롭의 상태를 관리하고, 드래그 시작, 진행, 종료 이벤트를 처리합니다.
주요 기능:
- onDragStart(): 드래그가 시작될 때 호출됩니다. 현재 드래그 중인 아이템을 식별합니다.
- onDragInterrupted(): 드래그가 중단될 때 호출됩니다. 상태를 초기화합니다.
- onDrag(): 드래그 중 아이템의 위치를 업데이트합니다. 필요한 경우 리스트를 스크롤합니다.
ReorderableScreen
@Composable fun ReorderableScreen() { var list by remember { mutableStateOf((0 until 20).toList()) } var scrollToIndex by remember { mutableStateOf<Int?>(null) } val draggableItems by remember { derivedStateOf { list.size } } val stateList = rememberLazyListState() val dragDropState = rememberDragDropState( lazyListState = stateList, draggableItemsNum = draggableItems, onMove = { fromIndex, toIndex -> val adjustedFromIndex = list.size - 1 - fromIndex val adjustedToIndex = list.size - 1 - toIndex list = list.toMutableList().apply { add(adjustedToIndex, removeAt(adjustedFromIndex)) } } ) LaunchedEffect(scrollToIndex) { scrollToIndex?.let { index -> stateList.animateScrollToItem(index) scrollToIndex = null } } Column { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Button(onClick = { val newIndex = list.size list = list.toMutableList().apply { add(newIndex) } scrollToIndex = 0 }) { Text("Add Item") } } LazyColumn( modifier = Modifier .dragContainer(dragDropState) .fillMaxSize(), state = stateList, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { item { Text(text = "Memo", fontSize = 30.sp) } draggableItems(items = list.reversed(), dragDropState = dragDropState) { modifier, item -> Item( modifier = modifier, index = item, onRemove = { itemToRemove -> list = list.toMutableList().filterNot { it == itemToRemove } } ) } } } }
Item
이 함수는 개별 아이템을 구성합니다.
@Composable private fun Item(modifier: Modifier = Modifier, index: Int, onRemove: (Int) -> Unit) { Card( modifier = modifier ) { Row( modifier = Modifier .fillMaxWidth() .padding(20.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( "Item $index", fontSize = 18.sp ) Box( modifier = Modifier .clickable( onClick = { onRemove(index) }, indication = null, interactionSource = remember { MutableInteractionSource() } ) .padding(8.dp) ) { Icon( imageVector = Icons.Filled.Clear, contentDescription = "Remove item" ) } } } }
전체 코드
Android-Compose-Study/presentation/week1/ReorderableList at main · moondev03/Android-Compose-Study
Android Jetpack Compose Study. Contribute to moondev03/Android-Compose-Study development by creating an account on GitHub.
github.com
참고
GitHub - Artemake/Reordering-LazyColumn
Contribute to Artemake/Reordering-LazyColumn development by creating an account on GitHub.
github.com
'Android > Compose' 카테고리의 다른 글
[Compose] Modifier Extension - DrawScrollbar (0) | 2024.09.25 |
---|---|
[Compose] Modifier Extension - AddFocusCleaner (0) | 2024.09.25 |
[Compose] Jetpack Compose 상태 관리 기초 (0) | 2024.09.08 |
[Compose] Android Composable Lifecycle (0) | 2024.09.08 |
[Compose] Slot-based Layouts (0) | 2024.09.06 |