From e68d7f158faa65c7abc79c4ee7a0c8afb5d9807b Mon Sep 17 00:00:00 2001 From: sanjeok77 <1+sanjeok77@noreply.localhost> Date: Fri, 21 Aug 2026 00:26:46 +0000 Subject: [PATCH] v0.3.0: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug --- .../components/DragDropListState.kt | 191 ++++++++---------- 1 file changed, 85 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/components/DragDropListState.kt b/app/src/main/java/com/hotdeal/alarm/presentation/components/DragDropListState.kt index 1029672..df017f7 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/components/DragDropListState.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/components/DragDropListState.kt @@ -1,11 +1,6 @@ package com.hotdeal.alarm.presentation.components -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -13,126 +8,110 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.zIndex -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch /** - * Compose LazyColumn Drag & Drop Reordering State + * Key 기반 드래그 앤 드롭 Reorder 시스템 + * + * - 각 아이템에 item-level long-press 제스처를 적용하여 드래그 시작 + * - 드래그 중 다른 아이템의 중심점과 겹치면 swap 실행 + * - swap 시 delta 보정으로 시각적 점프 방지 + * - 비드래그 아이템은 animateItemPlacement()로 부드러운 슬라이드 */ @Composable -fun rememberDragDropListState( - lazyListState: LazyListState, - onMove: (Int, Int) -> Unit, - onDragEnd: () -> Unit = {} -): DragDropListState { - val scope = rememberCoroutineScope() - val state = remember(lazyListState) { - DragDropListState( - lazyListState = lazyListState, - onMove = onMove, - onDragEnd = onDragEnd, - scope = scope - ) +fun rememberReorderState( + listState: LazyListState, + onSwap: (fromListIndex: Int, toListIndex: Int) -> Boolean +): ReorderState { + return remember(listState) { + ReorderState(listState, onSwap) } - return state } -class DragDropListState( - val lazyListState: LazyListState, - private val onMove: (Int, Int) -> Unit, - private val onDragEnd: () -> Unit, - private val scope: CoroutineScope +@Stable +class ReorderState( + val listState: LazyListState, + private val onSwap: (Int, Int) -> Boolean ) { - var initiallyDraggedElement by mutableStateOf(null) + /** 현재 드래그 중인 아이템의 key (null이면 드래그 없음) */ + var draggedKey by mutableStateOf(null) private set - var currentIndexOfDraggedItem by mutableStateOf(null) + /** 누적 드래그 Y축 오프셋 */ + var dragDelta by mutableFloatStateOf(0f) private set - private val dragOffset = Animatable(0f) + fun startDrag(key: Any) { + draggedKey = key + dragDelta = 0f + } - val elementOffset: Float - get() = dragOffset.value + fun updateDrag(delta: Offset) { + if (draggedKey == null) return + dragDelta += delta.y + checkOverlap() + } - fun onDragStart(offset: Offset) { - lazyListState.layoutInfo.visibleItemsInfo - .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } - ?.also { item -> - currentIndexOfDraggedItem = item.index - initiallyDraggedElement = item + fun endDrag() { + draggedKey = null + dragDelta = 0f + } + + private fun checkOverlap() { + val dragged = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == draggedKey } ?: return + + val draggedMid = dragged.offset + dragged.size / 2f + dragDelta + + val target = listState.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + item.key != draggedKey && + draggedMid >= item.offset.toFloat() && + draggedMid < (item.offset + item.size).toFloat() + } ?: return + + if (dragged.index == target.index) return + + val swapped = onSwap(dragged.index, target.index) + if (swapped) { + // swap 후 아이템이 새 위치로 이동하므로, 시각적 점프를 막기 위해 delta 보정 + dragDelta += if (dragged.index < target.index) { + -target.size.toFloat() + } else { + target.size.toFloat() } - } - - fun onDragInterrupted() { - initiallyDraggedElement = null - currentIndexOfDraggedItem = null - scope.launch { - dragOffset.snapTo(0f) - } - } - - fun onDrag(change: Offset) { - scope.launch { - dragOffset.snapTo(dragOffset.value + change.y) - - val currentElement = initiallyDraggedElement ?: return@launch - val startOffset = currentElement.offset + dragOffset.value - val endOffset = startOffset + currentElement.size - - val hoveredItem = lazyListState.layoutInfo.visibleItemsInfo - .firstOrNull { item -> - val itemMid = item.offset + item.size / 2 - val isMidInRange = itemMid in startOffset.toInt()..endOffset.toInt() - isMidInRange && item.index != currentIndexOfDraggedItem - } - - if (hoveredItem != null) { - val currentIndex = currentIndexOfDraggedItem ?: return@launch - val targetIndex = hoveredItem.index - onMove(currentIndex, targetIndex) - currentIndexOfDraggedItem = targetIndex - } - } - } - - fun onDragStop() { - onDragEnd() - scope.launch { - dragOffset.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow)) - initiallyDraggedElement = null - currentIndexOfDraggedItem = null } } } -fun Modifier.dragDropGesture( - dragDropState: DragDropListState -): Modifier = this.pointerInput(dragDropState) { - detectDragGesturesAfterLongPress( - onDragStart = { offset -> dragDropState.onDragStart(offset) }, - onDragEnd = { dragDropState.onDragStop() }, - onDragCancel = { dragDropState.onDragInterrupted() }, - onDrag = { change, dragAmount -> - change.consume() - dragDropState.onDrag(dragAmount) - } - ) -} - -fun Modifier.dragDropItem( - index: Int, - dragDropState: DragDropListState -): Modifier = this.then( - if (index == dragDropState.currentIndexOfDraggedItem) { - Modifier - .zIndex(10f) - .graphicsLayer { - translationY = dragDropState.elementOffset +/** + * 개별 아이템에 부착하는 드래그 핸들 + 시각 효과 Modifier + * - 길게 누르면 드래그 시작 + * - 드래그 중 아이템이 떠오르며 그림자 및 스케일업 + */ +fun Modifier.reorderableItem( + state: ReorderState, + key: Any +): Modifier { + val isDragged = state.draggedKey == key + return this + .zIndex(if (isDragged) 10f else 0f) + .graphicsLayer { + if (isDragged) { + translationY = state.dragDelta scaleX = 1.03f scaleY = 1.03f - shadowElevation = 8f + shadowElevation = 16f } - } else { - Modifier - } -) + } + .pointerInput(key) { + detectDragGesturesAfterLongPress( + onDragStart = { state.startDrag(key) }, + onDrag = { change, dragAmount -> + change.consume() + state.updateDrag(dragAmount) + }, + onDragEnd = { state.endDrag() }, + onDragCancel = { state.endDrag() } + ) + } +}