이번 블로그 포스트에서는 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 |