[Compose] LazyColumn Drag And Drop Reordering

2024. 9. 9. 01:23·📂 Android/Compose
반응형

이번 블로그 포스트에서는 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
'Android/Compose' 카테고리의 다른 글
  • [Compose] Modifier Extension - DrawScrollbar
  • [Compose] Modifier Extension - AddFocusCleaner
  • [Compose] Jetpack Compose 상태 관리 기초
  • [Compose] Android Composable Lifecycle
Moondev
Moondev
공부 기록장
  • Moondev
    Moondev
    Moondev
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Android
        • ViewSystem
        • Compose
      • Language
        • Kotlin
      • Review
        • 프로젝트 후기
        • Conference 후기
        • 우아한테크코스 후기
      • Etc
        • Git
        • Gradle
  • 링크

    • GitHub
  • 인기 글

  • 300x250
  • hELLO· Designed By정상우.v4.10.3
Moondev
[Compose] LazyColumn Drag And Drop Reordering
상단으로

티스토리툴바