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..0266e6f 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,111 @@ 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() + + 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) + if (down.pressed) { + longPressed = true + onLongPress() + } + } + + // 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 + } + + // Handle drag after long-press + if (positionChange != Offset.Zero && longPressed) { + isDragging = true + longPressJob.cancel() + dragInteractionStart = DragInteraction.Start().also { + coroutineScope.launch { + interactionSource?.emit(it) + } + } + 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 && !pastTouchSlop) { + onClick() + } + break + } + } + + // Handle drag event + if (isDragging) { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.first() + + if (change.pressed) { + val dragAmount = change.positionChange() + onDrag(change, dragAmount) + // Consume the event to prevent LazyColumn scrolling + change.consume() + } else { + dragInteractionStart?.also { + coroutineScope.launch { + interactionSource?.emit(DragInteraction.Stop(it)) + } + } + onDragStopped() + longPressed = false + break + } + } + } else { + longPressed = false + } + } + } + } + } +}