diff --git a/demoApp/src/main/java/sh/calvin/reorderable/LongPressHandleReorderableColumnScreen.kt b/demoApp/src/main/java/sh/calvin/reorderable/LongPressHandleReorderableColumnScreen.kt new file mode 100644 index 0000000..b728f74 --- /dev/null +++ b/demoApp/src/main/java/sh/calvin/reorderable/LongPressHandleReorderableColumnScreen.kt @@ -0,0 +1,74 @@ +package sh.calvin.reorderable + +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp + +@Composable +fun LongPressHandleReorderableColumnScreen() { + val view = LocalView.current + + var list by remember { mutableStateOf(items.take(5)) } + + ReorderableColumn( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + list = list, + onSettle = { fromIndex, toIndex -> + list = list.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + } + }, + onMove = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK) + } + }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { _, item, isDragging -> + key(item.id) { + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Card( + modifier = Modifier.height(item.size.dp), + shadowElevation = elevation, + ) { + Text(item.text, Modifier.padding(horizontal = 8.dp)) + IconButton( + modifier = Modifier.longPressDraggableHandle( + onDragStarted = { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + }, + onDragStopped = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END) + } + }, + ), + onClick = {}, + ) { + Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder") + } + } + } + } +} \ No newline at end of file diff --git a/demoApp/src/main/java/sh/calvin/reorderable/MainActivity.kt b/demoApp/src/main/java/sh/calvin/reorderable/MainActivity.kt index af092f6..1720f84 100644 --- a/demoApp/src/main/java/sh/calvin/reorderable/MainActivity.kt +++ b/demoApp/src/main/java/sh/calvin/reorderable/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -38,7 +39,9 @@ class MainActivity : ComponentActivity() { composable("main") { MainScreen(navController) } composable("SimpleReorderableLazyColumn") { SimpleReorderableLazyColumnScreen() } composable("ComplexReorderableLazyColumn") { ComplexReorderableLazyColumnScreen() } + composable("SimpleLongPressHandleReorderableLazyColumn") { SimpleLongPressHandleReorderableLazyColumnScreen() } composable("ReorderableColumn") { ReorderableColumnScreen() } + composable("LongPressHandleReorderableColumn") { LongPressHandleReorderableColumnScreen() } composable("SimpleReorderableLazyRow") { SimpleReorderableLazyRowScreen() } composable("ComplexReorderableLazyRow") { ComplexReorderableLazyRowScreen() } composable("ReorderableRow") { ReorderableRowScreen() } @@ -64,10 +67,19 @@ fun MainScreen(navController: NavController) { Button(onClick = { navController.navigate("ComplexReorderableLazyColumn") }) { Text("Complex ReorderableLazyColumn") } + Button(onClick = { navController.navigate("SimpleLongPressHandleReorderableLazyColumn") }) { + Text( + "Simple ReorderableLazyColumn with\n.longPressDraggableHandle", + textAlign = TextAlign.Center + ) + } Text("Column", Modifier.padding(8.dp), color = MaterialTheme.colorScheme.onBackground) Button(onClick = { navController.navigate("ReorderableColumn") }) { Text("ReorderableColumn") } + Button(onClick = { navController.navigate("LongPressHandleReorderableColumn") }) { + Text("ReorderableColumn with\n.longPressDraggableHandle", textAlign = TextAlign.Center) + } Text("LazyRow", Modifier.padding(8.dp), color = MaterialTheme.colorScheme.onBackground) Button(onClick = { navController.navigate("SimpleReorderableLazyRow") }) { diff --git a/demoApp/src/main/java/sh/calvin/reorderable/SimpleLongPressHandleReorderableLazyColumnScreen.kt b/demoApp/src/main/java/sh/calvin/reorderable/SimpleLongPressHandleReorderableLazyColumnScreen.kt new file mode 100644 index 0000000..0d2e855 --- /dev/null +++ b/demoApp/src/main/java/sh/calvin/reorderable/SimpleLongPressHandleReorderableLazyColumnScreen.kt @@ -0,0 +1,80 @@ +package sh.calvin.reorderable + +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SimpleLongPressHandleReorderableLazyColumnScreen() { + val view = LocalView.current + + var list by remember { mutableStateOf(items) } + val lazyListState = rememberLazyListState() + val reorderableLazyColumnState = rememberReorderableLazyColumnState(lazyListState) { from, to -> + list = list.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(list, key = { it.id }) { + ReorderableItem(reorderableLazyColumnState, it.id) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Card( + modifier = Modifier.height(it.size.dp), + shadowElevation = elevation, + ) { + Text(it.text, Modifier.padding(horizontal = 8.dp)) + IconButton( + modifier = Modifier.longPressDraggableHandle( + onDragStarted = { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + }, + onDragStopped = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END) + } + }, + ), + onClick = {}, + ) { + Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder") + } + } + } + } + } +} \ No newline at end of file diff --git a/reorderable/src/main/java/sh/calvin/reorderable/ReorderableLazyList.kt b/reorderable/src/main/java/sh/calvin/reorderable/ReorderableLazyList.kt index 1f2e1be..c917bb7 100644 --- a/reorderable/src/main/java/sh/calvin/reorderable/ReorderableLazyList.kt +++ b/reorderable/src/main/java/sh/calvin/reorderable/ReorderableLazyList.kt @@ -469,6 +469,7 @@ class ReorderableLazyListState internal constructor( if (scrollToIndex != null) { scope.launch { // this is needed to neutralize automatic keeping the first item first. + // see https://github.com/Calvin-LL/Reorderable/issues/4 state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) onMoveState.value(draggingItem, targetItem) } @@ -515,6 +516,22 @@ interface ReorderableItemScope { onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, interactionSource: MutableInteractionSource? = null, ): Modifier + + + /** + * Make the UI element the draggable handle for the reorderable item. Drag will start only after a long press. + * + * This modifier can only be used on the UI element that is a child of [LazyItemScope.ReorderableItem]. + * + * @param enabled Whether or not drag is enabled + * @param onDragStarted The function that is called when the item starts being dragged + * @param onDragStopped The function that is called when the item stops being dragged + */ + fun Modifier.longPressDraggableHandle( + enabled: Boolean = true, + onDragStarted: (startedPosition: Offset) -> Unit = {}, + onDragStopped: () -> Unit = {}, + ): Modifier } internal class ReorderableItemScopeImpl( @@ -523,6 +540,7 @@ internal class ReorderableItemScopeImpl( private val orientation: Orientation, private val itemPositionProvider: () -> Float ) : ReorderableItemScope { + override fun Modifier.draggableHandle( enabled: Boolean, onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit, @@ -560,6 +578,51 @@ internal class ReorderableItemScopeImpl( }, ) } + + override fun Modifier.longPressDraggableHandle( + enabled: Boolean, + onDragStarted: (startedPosition: Offset) -> Unit, + onDragStopped: () -> Unit, + ) = composed { + var handleOffset = remember { 0f } + var handleSize = remember { 0 } + + val coroutineScope = rememberCoroutineScope() + + onGloballyPositioned { + handleOffset = when (orientation) { + Orientation.Vertical -> it.positionInRoot().y + Orientation.Horizontal -> it.positionInRoot().x + } + handleSize = when (orientation) { + Orientation.Vertical -> it.size.height + Orientation.Horizontal -> it.size.width + } + }.longPressDraggable( + enabled = enabled && (reorderableLazyListState.isItemDragging(key).value || !reorderableLazyListState.isAnItemDragging().value), + onDragStarted = { + coroutineScope.launch { + val handleOffsetRelativeToItem = handleOffset - itemPositionProvider() + val handleCenter = handleOffsetRelativeToItem + handleSize / 2f + + reorderableLazyListState.onDragStart(key, handleCenter) + } + onDragStarted(it) + }, + onDragStopped = { + reorderableLazyListState.onDragStop() + onDragStopped() + }, + onDrag = { _, dragAmount -> + reorderableLazyListState.onDrag( + offset = when (orientation) { + Orientation.Vertical -> dragAmount.y + Orientation.Horizontal -> dragAmount.x + } + ) + }, + ) + } } /** diff --git a/reorderable/src/main/java/sh/calvin/reorderable/ReorderableList.kt b/reorderable/src/main/java/sh/calvin/reorderable/ReorderableList.kt index 48b228d..92e5e69 100644 --- a/reorderable/src/main/java/sh/calvin/reorderable/ReorderableList.kt +++ b/reorderable/src/main/java/sh/calvin/reorderable/ReorderableList.kt @@ -39,8 +39,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity @@ -205,6 +208,19 @@ interface ReorderableScope { onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, interactionSource: MutableInteractionSource? = null, ): Modifier + + /** + * Make the UI element the draggable handle for the reorderable item. Drag will start only after a long press. + * + * @param enabled Whether or not drag is enabled + * @param onDragStarted The function that is called when the item starts being dragged + * @param onDragStopped The function that is called when the item stops being dragged + */ + fun Modifier.longPressDraggableHandle( + enabled: Boolean = true, + onDragStarted: (startedPosition: Offset) -> Unit = {}, + onDragStopped: (velocity: Float) -> Unit = {}, + ): Modifier } internal class ReorderableScopeImpl( @@ -232,6 +248,44 @@ internal class ReorderableScopeImpl( onDragStopped(velocity) }, ) + + override fun Modifier.longPressDraggableHandle( + enabled: Boolean, + onDragStarted: (startedPosition: Offset) -> Unit, + onDragStopped: (velocity: Float) -> Unit, + ) = composed { + val velocityTracker = remember { VelocityTracker() } + val coroutineScope = rememberCoroutineScope() + + longPressDraggable( + enabled = enabled && (state.isItemDragging(index).value || !state.isAnItemDragging().value), + onDragStarted = { + state.startDrag(index) + onDragStarted(it) + }, + onDragStopped = { + val velocity = velocityTracker.calculateVelocity() + velocityTracker.resetTracking() + + val velocityVal = when (orientation) { + Orientation.Vertical -> velocity.y + Orientation.Horizontal -> velocity.x + } + coroutineScope.launch { state.settle(index, velocityVal) } + onDragStopped(velocityVal) + }, + onDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + + state.draggableStates[index].dispatchRawDelta( + when (orientation) { + Orientation.Vertical -> dragAmount.y + Orientation.Horizontal -> dragAmount.x + } + ) + }, + ) + } } /** diff --git a/reorderable/src/main/java/sh/calvin/reorderable/longPressDraggable.kt b/reorderable/src/main/java/sh/calvin/reorderable/longPressDraggable.kt new file mode 100644 index 0000000..5faf139 --- /dev/null +++ b/reorderable/src/main/java/sh/calvin/reorderable/longPressDraggable.kt @@ -0,0 +1,47 @@ +package sh.calvin.reorderable + +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput + +internal fun Modifier.longPressDraggable( + enabled: Boolean = true, + onDragStarted: (Offset) -> Unit = { }, + onDragStopped: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) = composed { + var dragStarted = remember { false } + + pointerInput(enabled) { + if (enabled) { + detectDragGesturesAfterLongPress( + onDragStart = { + dragStarted = true + + onDragStarted(it) + }, + onDragEnd = { + if (dragStarted) { + onDragStopped() + } + + dragStarted = false + + }, + onDragCancel = { + if (dragStarted) { + onDragStopped() + } + + dragStarted = false + + }, + onDrag = onDrag, + ) + } + } +} \ No newline at end of file