From 9ad6f5d63382ad55d8fc9956c74d78b4c19e68bb Mon Sep 17 00:00:00 2001 From: "HE,XUAN-WEI" Date: Mon, 21 Oct 2024 14:42:59 +0800 Subject: [PATCH 1/2] add combinedGesture method for handling click, long press, and drag gestures --- .../sh/calvin/reorderable/demo/ui/App.kt | 8 + ...estureHandleReorderableLazyColumnScreen.kt | 202 ++++++++++++++++++ .../reorderable/ReorderableLazyCollection.kt | 76 +++++++ .../kotlin/sh/calvin/reorderable/draggable.kt | 90 ++++++++ 4 files changed, 376 insertions(+) create mode 100644 demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/SimpleCombinedGestureHandleReorderableLazyColumnScreen.kt diff --git a/demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/App.kt b/demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/App.kt index 541140d..066bfd8 100644 --- a/demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/App.kt +++ b/demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/App.kt @@ -62,6 +62,7 @@ internal fun App() { scene("SimpleReorderableLazyColumn") { SimpleReorderableLazyColumnScreen() } scene("ComplexReorderableLazyColumn") { ComplexReorderableLazyColumnScreen() } scene("SimpleLongPressHandleReorderableLazyColumn") { SimpleLongPressHandleReorderableLazyColumnScreen() } + scene("SimpleCombinedGestureHandleReorderableLazyColumn") { SimpleCombinedGestureHandleReorderableLazyColumnScreen() } scene("SimpleReorderableLazyVerticalGrid") { SimpleReorderableLazyVerticalGridScreen() } scene("SimpleReorderableLazyVerticalStaggeredGrid") { SimpleReorderableLazyVerticalStaggeredGridScreen() } scene("ReorderableColumn") { ReorderableColumnScreen() } @@ -117,6 +118,13 @@ fun MainScreen(navController: Navigator) { textAlign = TextAlign.Center ) } + Button( + onClick = { navController.navigate("SimpleCombinedGestureHandleReorderableLazyColumn") }) { + Text( + "\uD83D\uDEA7DEMO:\nSimple Reorderable LazyColumn with\n.combinedGestureHandle", + textAlign = TextAlign.Center + ) + } } Column( diff --git a/demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/SimpleCombinedGestureHandleReorderableLazyColumnScreen.kt b/demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/SimpleCombinedGestureHandleReorderableLazyColumnScreen.kt new file mode 100644 index 0000000..dfce54d --- /dev/null +++ b/demoApp/composeApp/src/commonMain/kotlin/sh/calvin/reorderable/demo/ui/SimpleCombinedGestureHandleReorderableLazyColumnScreen.kt @@ -0,0 +1,202 @@ +package sh.calvin.reorderable.demo.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.demo.ReorderHapticFeedbackType +import sh.calvin.reorderable.demo.items +import sh.calvin.reorderable.demo.rememberReorderHapticFeedback +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SimpleCombinedGestureHandleReorderableLazyColumnScreen() { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + var showBottomSheet by remember { mutableStateOf(false) } + var clickedItemId by remember { mutableStateOf(null) } + var selectedItemId by remember { mutableStateOf(null) } + + val haptic = rememberReorderHapticFeedback() + + var list by remember { mutableStateOf(items) } + val lazyListState = rememberLazyListState() + val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to -> + list = list.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + + haptic.performHapticFeedback(ReorderHapticFeedbackType.MOVE) + } + + Row { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + state = lazyListState, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(list, key = { _, item -> item.id }) { index, item -> + ReorderableItem(reorderableLazyColumnState, item.id) { isDragging -> + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .height(item.size.dp) + .fillMaxWidth() + .background( + color = Color.Gray, + shape = RoundedCornerShape(16.dp), + ) + .combinedGestureHandle( + onClick = { + clickedItemId = item.id + }, + onLongPress = { + selectedItemId = item.id + haptic.performHapticFeedback(ReorderHapticFeedbackType.START) + showBottomSheet = true + haptic.performHapticFeedback(ReorderHapticFeedbackType.END) + }, + onDragStarted = { + showBottomSheet = false + haptic.performHapticFeedback(ReorderHapticFeedbackType.START) + }, + onDragStopped = { + haptic.performHapticFeedback(ReorderHapticFeedbackType.END) + }, + interactionSource = interactionSource, + ) + .semantics { + customActions = listOf( + CustomAccessibilityAction( + label = "Move Up", + action = { + if (index > 0) { + list = list.toMutableList().apply { + add(index - 1, removeAt(index)) + } + true + } else { + false + } + } + ), + CustomAccessibilityAction( + label = "Move Down", + action = { + if (index < list.size - 1) { + list = list.toMutableList().apply { + add(index + 1, removeAt(index)) + } + true + } else { + false + } + } + ), + ) + }, + ) { + Row( + Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(item.text, Modifier.padding(horizontal = 8.dp)) + } + } + } + } + } + Card( + modifier = Modifier + .weight(3f) + .fillMaxHeight(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = clickedItemId?.let { "Item $it Content" } ?: "No Item Selected", + fontSize = 24.sp + ) + } + } + } + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + }, + sheetState = sheetState + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Item ${selectedItemId ?: ""}", textAlign = TextAlign.Center) + Spacer(Modifier.height(24.dp)) + Button(onClick = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + showBottomSheet = false + } + } + }) { + Text("Pin Item") + } + Spacer(Modifier.height(16.dp)) + Button(onClick = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + showBottomSheet = false + } + } + }) { + Text("Remove Item") + } + } + } + } +} diff --git a/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt b/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt index 1e29d5a..d0984cc 100644 --- a/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt +++ b/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/ReorderableLazyCollection.kt @@ -668,6 +668,27 @@ interface ReorderableCollectionItemScope { onDragStarted: (startedPosition: Offset) -> Unit = {}, onDragStopped: () -> Unit = {}, ): Modifier + + /** + * Make the UI element clickable, long-pressable, and draggable for the reorderable item. + * + * This modifier can only be used on the UI element that is a child of [ReorderableItem]. It allows the element to respond to click, long press, and drag gestures. + * + * @param enabled Whether the click, long press, and drag actions are enabled + * @param interactionSource [MutableInteractionSource] that will be used to emit interaction events + * @param onClick The function that is called when the element is clicked + * @param onLongPress The function that is called when the element is long-pressed + * @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.combinedGestureHandle( + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + onClick: () -> Unit = {}, + onLongPress: () -> Unit = {}, + onDragStarted: (startedPosition: Offset) -> Unit = {}, + onDragStopped: () -> Unit = {}, + ): Modifier } internal class ReorderableCollectionItemScopeImpl( @@ -772,6 +793,61 @@ internal class ReorderableCollectionItemScopeImpl( }, ) } + + /** + * Make the UI element the draggable handle for the reorderable item, combining click, long press, and drag interactions. + * + * @param enabled Whether or not drag is enabled + * @param interactionSource [MutableInteractionSource] that will be used to emit [DragInteraction.Start] when this draggable is being dragged + * @param onClick The function that is called when the item is clicked + * @param onLongPress The function that is called when the item is long pressed + * @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 + */ + override fun Modifier.combinedGestureHandle( + enabled: Boolean, + interactionSource: MutableInteractionSource?, + onClick: () -> Unit, + onLongPress: () -> Unit, + onDragStarted: (startedPosition: Offset) -> Unit, + onDragStopped: () -> Unit + ) = composed { + var handleOffset by remember { mutableStateOf(Offset.Zero) } + var handleSize by remember { mutableStateOf(IntSize.Zero) } + + val coroutineScope = rememberCoroutineScope() + + onGloballyPositioned { + handleOffset = it.positionInRoot() + handleSize = it.size + }.combinedGesture( + key1 = reorderableLazyCollectionState, + enabled = enabled && (reorderableLazyCollectionState.isItemDragging(key).value || !reorderableLazyCollectionState.isAnyItemDragging), + interactionSource = interactionSource, + onClick = onClick, + onLongPress = onLongPress, + onDragStarted = { + coroutineScope.launch { + val handleOffsetRelativeToItem = handleOffset - itemPositionProvider() + val handleCenter = Offset( + handleOffsetRelativeToItem.x + handleSize.width / 2f, + handleOffsetRelativeToItem.y + handleSize.height / 2f + ) + + reorderableLazyCollectionState.onDragStart(key, handleCenter) + } + onDragStarted(it) + }, + onDragStopped = { + reorderableLazyCollectionState.onDragStop() + onDragStopped() + }, + onDrag = { change, dragAmount -> + change.consume() + reorderableLazyCollectionState.onDrag(dragAmount) + }, + ) + } } /** diff --git a/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt b/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt index 18544fd..767b89c 100644 --- a/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt +++ b/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt @@ -14,7 +14,10 @@ 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.changedToUp import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import kotlinx.coroutines.delay import kotlinx.coroutines.launch internal fun Modifier.draggable( @@ -168,3 +171,90 @@ internal fun Modifier.longPressDraggable( } } } + +internal fun Modifier.combinedGesture( + key1: Any?, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + onClick: () -> Unit = { }, + onLongPress: () -> Unit = { }, + onDragStarted: (Offset) -> Unit = { }, + onDragStopped: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, +) = composed { + val coroutineScope = rememberCoroutineScope() + var dragInteractionStart by remember { mutableStateOf(null) } + var dragStarted by remember { mutableStateOf(false) } + var longPressed by remember { mutableStateOf(false) } + + pointerInput(key1, enabled) { + if (enabled) { + awaitPointerEventScope { + while (true) { + val down = awaitPointerEvent().changes.firstOrNull()?.takeIf { it.pressed } ?: continue + + val longPressTimeout = viewConfiguration.longPressTimeoutMillis + val longPressJob = coroutineScope.launch { + delay(longPressTimeout) + if (down.pressed) { + longPressed = true + onLongPress() + } + } + + var isDragging = false + while (down.pressed) { + val event = awaitPointerEvent() + val change = event.changes.first() + + if (change.positionChange() != Offset.Zero && longPressed) { + isDragging = true + dragStarted = true + longPressJob.cancel() + dragInteractionStart = DragInteraction.Start().also { + coroutineScope.launch { + interactionSource?.emit(it) + } + } + onDragStarted(change.position) + break + } + + if (change.changedToUp()) { + longPressJob.cancel() + if (!longPressed) { + onClick() + } + break + } + } + + if (isDragging) { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.first() + + if (change.pressed) { + val dragAmount = change.positionChange() + onDrag(change, dragAmount) + change.consume() + } else { + dragInteractionStart?.also { + coroutineScope.launch { + interactionSource?.emit(DragInteraction.Stop(it)) + } + } + onDragStopped() + dragStarted = false + longPressed = false + break + } + } + } else { + longPressed = false + } + } + } + } + } +} From 06ff42eda6b321800e5ce7749fbbb02d2ed14173 Mon Sep 17 00:00:00 2001 From: "HE,XUAN-WEI" Date: Tue, 22 Oct 2024 17:27:28 +0800 Subject: [PATCH 2/2] fix: resolve onClick trigger issue in combinedGesture during LazyList scrolling --- .../kotlin/sh/calvin/reorderable/draggable.kt | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt b/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt index 767b89c..0266e6f 100644 --- a/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt +++ b/reorderable/src/commonMain/kotlin/sh/calvin/reorderable/draggable.kt @@ -176,23 +176,30 @@ internal fun Modifier.combinedGesture( key1: Any?, enabled: Boolean = true, interactionSource: MutableInteractionSource? = null, - onClick: () -> Unit = { }, - onLongPress: () -> Unit = { }, - onDragStarted: (Offset) -> Unit = { }, - onDragStopped: () -> Unit = { }, + onClick: () -> Unit = {}, + onLongPress: () -> Unit = {}, + onDragStarted: (Offset) -> Unit = {}, + onDragStopped: () -> Unit = {}, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, ) = composed { val coroutineScope = rememberCoroutineScope() - var dragInteractionStart by remember { mutableStateOf(null) } - var dragStarted by remember { mutableStateOf(false) } - var longPressed by remember { mutableStateOf(false) } pointerInput(key1, enabled) { + val touchSlop = viewConfiguration.touchSlop if (enabled) { awaitPointerEventScope { while (true) { + // Wait for the finger to press down val down = awaitPointerEvent().changes.firstOrNull()?.takeIf { it.pressed } ?: continue + // Initialize variables + var longPressed = false + var isDragging = false + var totalMovement = Offset.Zero + var pastTouchSlop = false + var dragInteractionStart: DragInteraction.Start? = null + + // Start long-press job val longPressTimeout = viewConfiguration.longPressTimeoutMillis val longPressJob = coroutineScope.launch { delay(longPressTimeout) @@ -202,14 +209,24 @@ internal fun Modifier.combinedGesture( } } - var isDragging = false + // Gesture processing loop while (down.pressed) { val event = awaitPointerEvent() val change = event.changes.first() + val positionChange = change.positionChange() + totalMovement += positionChange + val distance = totalMovement.getDistance() + + // Check if the movement exceeds the touch slop + if (!longPressed && !pastTouchSlop && distance > touchSlop) { + pastTouchSlop = true + longPressJob.cancel() + // Continue processing, wait for the finger to lift up + } - if (change.positionChange() != Offset.Zero && longPressed) { + // Handle drag after long-press + if (positionChange != Offset.Zero && longPressed) { isDragging = true - dragStarted = true longPressJob.cancel() dragInteractionStart = DragInteraction.Start().also { coroutineScope.launch { @@ -217,18 +234,22 @@ internal fun Modifier.combinedGesture( } } onDragStarted(change.position) + // Consume the event to prevent LazyColumn scrolling + change.consume() break } + // Handle finger lift, trigger onClick if conditions are met if (change.changedToUp()) { longPressJob.cancel() - if (!longPressed) { + if (!longPressed && !pastTouchSlop) { onClick() } break } } + // Handle drag event if (isDragging) { while (true) { val event = awaitPointerEvent() @@ -237,6 +258,7 @@ internal fun Modifier.combinedGesture( if (change.pressed) { val dragAmount = change.positionChange() onDrag(change, dragAmount) + // Consume the event to prevent LazyColumn scrolling change.consume() } else { dragInteractionStart?.also { @@ -245,7 +267,6 @@ internal fun Modifier.combinedGesture( } } onDragStopped() - dragStarted = false longPressed = false break }