본문 바로가기
안드로이드 코틀린

[Kotlin][Android] Jetpack Compose로 아이템 Drag & Drop 구현하기

by teamnova 2024. 12. 15.
728x90

 

안녕하세요 오늘은 Jetpack Compose의 주요 기능 (제스처, 애니메이션, 리스트 렌더링) 들을 활용해 

리스트의 아이템들을 드래그 앤 드롭 해보도록 하겠습니다 

 

To-Do List, 이미지 갤러리, 카드 정렬 등 다양한 앱에서 응용 가능합니다

 

 

1. ToDoListWithFloatingDrag

리스트의 전체 구조 및 드래그 앤 드롭 로직을 관리하는 함수입니다 

@Composable
fun ToDoListWithFloatingDrag() {
    var tasks by remember { mutableStateOf(mutableListOf("Task 1", "Task 2", "Task 3", "Task 4")) }
    var draggedIndex by remember { mutableStateOf<Int?>(null) }
    var offsetY by remember { mutableStateOf(0f) }
    val coroutineScope = rememberCoroutineScope()
    val dragThreshold = 50f // 이동을 위한 최소 거리

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        itemsIndexed(tasks) { index, task ->
            val isDragging = draggedIndex == index

            DraggableTaskItem(
                task = task,
                isDragging = isDragging,
                offsetY = if (isDragging) offsetY else 0f,
                onDragStart = {
                    draggedIndex = index
                },
                onDrag = { dragAmount ->
                    if (draggedIndex == null) return@DraggableTaskItem
                    offsetY += dragAmount

                    // 새로운 인덱스 계산 (양수: 아래, 음수: 위)
                    val indexShift = (offsetY / 100).toInt()
                    val newIndex = (draggedIndex!! + indexShift).coerceIn(0, tasks.size - 1)

                    // 새로운 인덱스로 이동 (올림/내림 포함)
                    if (newIndex != draggedIndex && kotlin.math.abs(offsetY) > dragThreshold) {
                        coroutineScope.launch {
                            tasks = tasks.toMutableList().apply {
                                add(newIndex, removeAt(draggedIndex!!))
                            }
                            offsetY = 0f // 오프셋 초기화
                            draggedIndex = newIndex
                        }
                    }
                },
                onDragEnd = {
                    draggedIndex = null
                    offsetY = 0f
                }
            )
        }
    }
}

 

드래그 앤 드롭 기능의 핵심은 리스트 데이터(tasks)를 사용자가 드래그한 위치에 따라 재정렬하는 로직입니다. 이를 위해 LazyColumn에 렌더링된 리스트 형태의 UI와 사용자 제스처를 처리하여 동적으로 리스트를 업데이트합니다.

 

 

2. DraggableTaskItem

개별 아이템의 제스처 감지와 UI 표현을 담당하는 함수입니다. 

@Composable
fun DraggableTaskItem(
    task: String,
    isDragging: Boolean,
    offsetY: Float,
    onDragStart: () -> Unit,
    onDrag: (Float) -> Unit,
    onDragEnd: () -> Unit
) {
    val animatedOffsetY by animateFloatAsState(targetValue = if (isDragging) offsetY else 0f)

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .background(if (isDragging) Color.Gray else Color.LightGray, shape = MaterialTheme.shapes.medium)
            .shadow(if (isDragging) 8.dp else 0.dp)
            .offset { IntOffset(0, animatedOffsetY.roundToInt()) }
            .zIndex(if (isDragging) 1f else 0f) // 드래그 중인 아이템이 위로 보이도록 설정
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { onDragStart() },
                    onDragEnd = { onDragEnd() },
                    onDragCancel = { onDragEnd() },
                    onDrag = { change, dragAmount ->
                        change.consumePositionChange()
                        onDrag(dragAmount.y)
                    }
                )
            }
            .padding(16.dp),
        contentAlignment = Alignment.CenterStart
    ) {
        Text(
            text = task,
            fontSize = 16.sp,
            color = Color.Black
        )
    }
}

 

드래그 앤 드롭 기능에서 개별 아이템의 UI와 제스처를 처리하는 핵심은 DraggableTaskItem 컴포저블입니다. 이 함수는 사용자 드래그 제스처를 감지하고, 드래그 중인 아이템을 시각적으로  떨어져 움직이는 효과를 제공합니다.

 

 

 

아래는 메인 액티비티가 포함된 전체 코드입니다

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                    ToDoListWithFloatingDrag()
                }
            }
        }
    }
}
@Composable
fun ToDoListWithFloatingDrag() {
    var tasks by remember { mutableStateOf(mutableListOf("Task 1", "Task 2", "Task 3", "Task 4")) }
    var draggedIndex by remember { mutableStateOf<Int?>(null) }
    var offsetY by remember { mutableStateOf(0f) }
    val coroutineScope = rememberCoroutineScope()
    val dragThreshold = 50f // 이동을 위한 최소 거리

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        itemsIndexed(tasks) { index, task ->
            val isDragging = draggedIndex == index

            DraggableTaskItem(
                task = task,
                isDragging = isDragging,
                offsetY = if (isDragging) offsetY else 0f,
                onDragStart = {
                    draggedIndex = index
                },
                onDrag = { dragAmount ->
                    if (draggedIndex == null) return@DraggableTaskItem
                    offsetY += dragAmount

                    // 새로운 인덱스 계산 (양수: 아래, 음수: 위)
                    val indexShift = (offsetY / 100).toInt()
                    val newIndex = (draggedIndex!! + indexShift).coerceIn(0, tasks.size - 1)

                    // 새로운 인덱스로 이동 (올림/내림 포함)
                    if (newIndex != draggedIndex && kotlin.math.abs(offsetY) > dragThreshold) {
                        coroutineScope.launch {
                            tasks = tasks.toMutableList().apply {
                                add(newIndex, removeAt(draggedIndex!!))
                            }
                            offsetY = 0f // 오프셋 초기화
                            draggedIndex = newIndex
                        }
                    }
                },
                onDragEnd = {
                    draggedIndex = null
                    offsetY = 0f
                }
            )
        }
    }
}

@Composable
fun DraggableTaskItem(
    task: String,
    isDragging: Boolean,
    offsetY: Float,
    onDragStart: () -> Unit,
    onDrag: (Float) -> Unit,
    onDragEnd: () -> Unit
) {
    val animatedOffsetY by animateFloatAsState(targetValue = if (isDragging) offsetY else 0f)

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .background(if (isDragging) Color.Gray else Color.LightGray, shape = MaterialTheme.shapes.medium)
            .shadow(if (isDragging) 8.dp else 0.dp)
            .offset { IntOffset(0, animatedOffsetY.roundToInt()) }
            .zIndex(if (isDragging) 1f else 0f) // 드래그 중인 아이템이 위로 보이도록 설정
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { onDragStart() },
                    onDragEnd = { onDragEnd() },
                    onDragCancel = { onDragEnd() },
                    onDrag = { change, dragAmount ->
                        change.consumePositionChange()
                        onDrag(dragAmount.y)
                    }
                )
            }
            .padding(16.dp),
        contentAlignment = Alignment.CenterStart
    ) {
        Text(
            text = task,
            fontSize = 16.sp,
            color = Color.Black
        )
    }
}

 

 

 

시연 영상입니다.