Compose에서 드래그 앤 드롭: 재정렬 가능한 리스트 및 스와이프 삭제

발행: (2026년 3월 2일 오전 10:19 GMT+9)
6 분 소요
원문: Dev.to

Source: Dev.to

myougaTheAxo

드래그 제스처 감지

detectDragGesturesAfterLongPress 수정자는 롱프레스 후에 드래그 상호작용을 가능하게 합니다:

var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }

Box(
    modifier = Modifier
        .size(100.dp)
        .background(Color.Blue)
        .pointerInput(Unit) {
            detectDragGesturesAfterLongPress(
                onDrag = { change, dragAmount ->
                    change.consume()
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                },
                onDragEnd = {
                    // Handle drag completion
                }
            )
        }
)

시각적 피드백을 위한 GraphicsLayer

graphicsLayer를 사용하여 드래그 작업 중 실시간 시각적 피드백을 제공합니다:

Box(
    modifier = Modifier
        .size(100.dp)
        .background(Color.Blue)
        .graphicsLayer {
            translationX = offsetX
            translationY = offsetY
            scaleX = if (isDragging) 1.1f else 1f
            scaleY = if (isDragging) 1.1f else 1f
        }
        .pointerInput(Unit) {
            detectDragGesturesAfterLongPress(
                onDragStart = { isDragging = true },
                onDrag = { change, dragAmount ->
                    change.consume()
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                },
                onDragEnd = {
                    isDragging = false
                    // 원래 위치로 애니메이션
                }
            )
        }
)

드래그 핸들 아이콘

드래그‑핸들 아이콘을 사용하여 항목이 드래그 가능함을 알립니다:

@Composable
fun DraggableListItem(
    item: String,
    onDrag: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp)
            .pointerInput(Unit) {
                detectDragGesturesAfterLongPress(
                    onDragStart = onDrag,
                    onDrag = { change, _ -> change.consume() }
                )
            },
        verticalAlignment = Alignment.CenterVertically
    ) {
        Icon(
            imageVector = Icons.Default.DragHandle,
            contentDescription = "Drag to reorder",
            modifier = Modifier
                .padding(end = 8.dp)
                .alpha(0.6f)
        )
        Text(item, modifier = Modifier.weight(1f))
    }
}

SwipeToDismissBox for Swipe‑to‑Delete

SwipeToDismissBox 컴포저블은 사용자 정의 가능한 배경 콘텐츠와 함께 스와이프‑투‑디스미스 패턴을 제공합니다:

@Composable
fun SwipeDeleteItem(
    item: String,
    onDelete: () -> Unit,
    modifier: Modifier = Modifier
) {
    SwipeToDismissBox(
        modifier = modifier,
        backgroundContent = {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Red)
                    .padding(16.dp),
                contentAlignment = Alignment.CenterEnd
            ) {
                Icon(
                    imageVector = Icons.Default.Delete,
                    contentDescription = "Delete",
                    tint = Color.White
                )
            }
        },
        content = {
            ListItem(
                headlineContent = { Text(item) },
                modifier = Modifier.background(Color.White)
            )
        },
        onDismissed = { onDelete() }
    )
}

전체 재정렬 가능한 리스트

스와이프‑투‑삭제 기능이 포함된 전체 재정렬 가능한 리스트를 위해 이 패턴들을 결합하세요:

@Composable
fun ReorderableListScreen() {
    var items by remember { mutableStateOf(listOf("Item 1", "Item 2", "Item 3")) }
    var draggedItem by remember { mutableStateOf<String?>(null) }

    LazyColumn(modifier = Modifier.fillMaxSize()) {
        itemsIndexed(
            items = items,
            key = { _, item -> item }
        ) { index, item ->
            SwipeToDismissBox(
                modifier = Modifier
                    .animateItem(
                        fadeInSpec = tween(300),
                        fadeOutSpec = tween(300),
                        placementSpec = spring()
                    )
                    .pointerInput(Unit) {
                        detectDragGesturesAfterLongPress(
                            onDragStart = { draggedItem = item },
                            onDrag = { change, _ -> change.consume() },
                            onDragEnd = { draggedItem = null }
                        )
                    },
                backgroundContent = {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .background(Color.Red)
                            .padding(16.dp),
                        contentAlignment = Alignment.CenterEnd
                    ) {
                        Icon(
                            imageVector = Icons.Default.Delete,
                            contentDescription = "Delete",
                            tint = Color.White
                        )
                    }
                },
                content = {
                    ListItem(
                        headlineContent = { Text(item) },
                        modifier = Modifier.background(
                            if (draggedItem == item) Color.LightGray else Color.White
                        )
                    )
                },
                onDismissed = {
                    items = items.toMutableList().also { it.remove(item) }
                }
            )
        }
    }
}

Source:

Swipe‑to‑Dismiss & Drag‑to‑Reorder 예제

@Composable
fun SwipeAndDragList() {
    var items by remember { mutableStateOf(listOf("Item 1", "Item 2", "Item 3")) }

    LazyColumn {
        itemsIndexed(
            items = items,
            key = { _, item -> item }
        ) { index, item ->
            SwipeToDismiss(
                state = rememberDismissState(),
                background = {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .background(Color.Red),
                        contentAlignment = Alignment.CenterEnd
                    ) {
                        Icon(Icons.Default.Delete, contentDescription = null)
                    }
                },
                content = {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .background(Color.White)
                            .padding(16.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Icon(Icons.Default.DragHandle, contentDescription = null)
                        Text(item, modifier = Modifier.weight(1f))
                    }
                },
                onDismissed = {
                    items = items.filterNot { it == item }
                }
            )
        }
    }
}

성능 최적화

itemsIndexed()key 매개변수를 사용하여 올바른 컴포지션 추적을 수행합니다:

itemsIndexed(
    items = items,
    key = { _, item -> item.id }   // 고유하고 안정적인 식별자
) { index, item ->
    // Your content
}

안정적인 키를 제공하면 리스트 재정렬 중에 애니메이션과 상태 관리가 올바르게 작동합니다.

모범 사례

  • 안정적인 키 사용 – 리스트 항목마다 고유하고 안정적인 식별자를 항상 제공하세요.
  • 시각적 피드백 – 드래그 중에 스케일/알파 변화를 보여주어 UX를 향상시키세요.
  • 접근성 – 드래그 핸들과 삭제 동작에 대한 라벨을 명확히 표시하세요.
  • 성능 – 드래그되지 않은 항목은 불필요하게 재컴포즈되지 않도록 하세요.
  • 애니메이션 – 부드러운 리스트 전환을 위해 animateItem()을 사용하세요.

Jetpack Compose의 제스처 감지 및 Modifier 시스템을 활용하면 반응성이 뛰어나고 세련된 인터랙티브 리스트를 손쉽게 구현할 수 있습니다.


8 Android App Templates →

0 조회
Back to Blog

관련 글

더 보기 »