diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt index 2f37fc07d9..edfb60649b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt @@ -1,23 +1,22 @@ package org.thoughtcrime.securesms.components.settings.app.chats.folders import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -31,6 +30,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -42,7 +43,9 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs import org.signal.core.ui.Dividers +import org.signal.core.ui.DropdownMenus import org.signal.core.ui.Previews import org.signal.core.ui.Scaffolds import org.signal.core.ui.SignalPreview @@ -52,6 +55,7 @@ import org.signal.core.ui.copied.androidx.compose.rememberDragDropState import org.signal.core.util.toInt import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate /** @@ -83,6 +87,16 @@ class ChatFoldersFragment : ComposeFragment() { Toast.makeText(requireContext(), getString(R.string.ChatFoldersFragment__folder_added, folder.name), Toast.LENGTH_SHORT).show() viewModel.createFolder(requireContext(), folder) }, + onDeleteClicked = { folder -> + viewModel.setCurrentFolder(folder) + viewModel.showDeleteDialog(true) + }, + onDeleteConfirmed = { + viewModel.deleteFolder(context = requireContext(), forceRefresh = true) + }, + onDeleteDismissed = { + viewModel.showDeleteDialog(false) + }, onPositionUpdated = { fromIndex, toIndex -> viewModel.updatePosition(fromIndex, toIndex) } ) } @@ -95,101 +109,125 @@ fun FoldersScreen( modifier: Modifier = Modifier, onFolderClicked: (ChatFolderRecord) -> Unit = {}, onAdd: (ChatFolderRecord) -> Unit = {}, + onDeleteClicked: (ChatFolderRecord) -> Unit = {}, + onDeleteConfirmed: () -> Unit = {}, + onDeleteDismissed: () -> Unit = {}, onPositionUpdated: (Int, Int) -> Unit = { _, _ -> } ) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val isRtl = ViewUtil.isRtl(LocalContext.current) val listState = rememberLazyListState() val dragDropState = - rememberDragDropState(listState) { fromIndex, toIndex -> + rememberDragDropState(listState, includeHeader = true, includeFooter = true) { fromIndex, toIndex -> onPositionUpdated(fromIndex, toIndex) } - Column(modifier = modifier.verticalScroll(rememberScrollState())) { - Text( - text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 12.dp, start = 24.dp) - ) - Text( - text = stringResource(id = R.string.ChatFoldersFragment__folders), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp) - ) - FolderRow( - icon = R.drawable.symbol_plus_compact_16, - title = stringResource(R.string.ChatFoldersFragment__create_a_folder), - onClick = { onFolderClicked(ChatFolderRecord()) } + if (state.showDeleteDialog) { + Dialogs.SimpleAlertDialog( + title = "", + body = stringResource(id = R.string.CreateFoldersFragment__delete_this_chat_folder), + confirm = stringResource(id = R.string.delete), + onConfirm = onDeleteConfirmed, + dismiss = stringResource(id = android.R.string.cancel), + onDismiss = onDeleteDismissed ) + } - val columnHeight = dimensionResource(id = R.dimen.chat_folder_row_height).value * state.folders.size - LazyColumn( - modifier = Modifier - .height(columnHeight.dp) - .dragContainer(dragDropState), - state = listState - ) { - itemsIndexed(state.folders) { index, folder -> - DraggableItem(dragDropState, index) { isDragging -> - val elevation = if (isDragging) 1.dp else 0.dp - val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL - FolderRow( - icon = R.drawable.symbol_folder_24, - title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name, - subtitle = getFolderDescription(folder), - onClick = if (!isAllChats) { - { onFolderClicked(folder) } - } else null, - elevation = elevation, - showDragHandle = true - ) - } + LazyColumn( + modifier = Modifier.dragContainer( + dragDropState = dragDropState, + leftDpOffset = if (isRtl) 0.dp else screenWidth - 48.dp, + rightDpOffset = if (isRtl) 48.dp else screenWidth + ), + state = listState + ) { + item { + DraggableItem(dragDropState, 0) { + Text( + text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(top = 12.dp, bottom = 12.dp, end = 12.dp, start = 24.dp) + ) + Text( + text = stringResource(id = R.string.ChatFoldersFragment__folders), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp) + ) + FolderRow( + icon = R.drawable.symbol_plus_compact_16, + title = stringResource(R.string.ChatFoldersFragment__create_a_folder), + onClick = { onFolderClicked(ChatFolderRecord()) } + ) } } - if (state.suggestedFolders.isNotEmpty()) { - Dividers.Default() - - Text( - text = stringResource(id = R.string.ChatFoldersFragment__suggested_folders), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp) - ) + itemsIndexed(state.folders) { index, folder -> + DraggableItem(dragDropState, 1 + index) { isDragging -> + val elevation = if (isDragging) 1.dp else 0.dp + val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL + FolderRow( + icon = R.drawable.symbol_folder_24, + title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name, + subtitle = getFolderDescription(folder), + onClick = if (!isAllChats) { + { onFolderClicked(folder) } + } else null, + onDelete = { onDeleteClicked(folder) }, + elevation = elevation, + showDragHandle = true + ) + } } - state.suggestedFolders.forEach { chatFolder -> - when (chatFolder.folderType) { - ChatFolderRecord.FolderType.UNREAD -> { - val title: String = stringResource(R.string.ChatFoldersFragment__unread) - FolderRow( - icon = R.drawable.symbol_chat_badge_24, - title = title, - subtitle = stringResource(R.string.ChatFoldersFragment__unread_messages), - onAdd = { onAdd(chatFolder) } + item { + DraggableItem(dragDropState, 1 + state.folders.size) { + if (state.suggestedFolders.isNotEmpty()) { + Dividers.Default() + + Text( + text = stringResource(id = R.string.ChatFoldersFragment__suggested_folders), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp) ) } - ChatFolderRecord.FolderType.INDIVIDUAL -> { - val title: String = stringResource(R.string.ChatFoldersFragment__one_on_one_chats) - FolderRow( - icon = R.drawable.symbol_person_light_24, - title = title, - subtitle = stringResource(R.string.ChatFoldersFragment__only_direct_messages), - onAdd = { onAdd(chatFolder) } - ) - } - ChatFolderRecord.FolderType.GROUP -> { - val title: String = stringResource(R.string.ChatFoldersFragment__groups) - FolderRow( - icon = R.drawable.symbol_group_light_20, - title = title, - subtitle = stringResource(R.string.ChatFoldersFragment__only_group_messages), - onAdd = { onAdd(chatFolder) } - ) - } - ChatFolderRecord.FolderType.ALL -> { - throw IllegalStateException("All chats should not be suggested") - } - ChatFolderRecord.FolderType.CUSTOM -> { - throw IllegalStateException("Custom folders should not be suggested") + + state.suggestedFolders.forEach { chatFolder -> + when (chatFolder.folderType) { + ChatFolderRecord.FolderType.UNREAD -> { + val title: String = stringResource(R.string.ChatFoldersFragment__unread) + FolderRow( + icon = R.drawable.symbol_chat_badge_24, + title = title, + subtitle = stringResource(R.string.ChatFoldersFragment__unread_messages), + onAdd = { onAdd(chatFolder) } + ) + } + ChatFolderRecord.FolderType.INDIVIDUAL -> { + val title: String = stringResource(R.string.ChatFoldersFragment__one_on_one_chats) + FolderRow( + icon = R.drawable.symbol_person_light_24, + title = title, + subtitle = stringResource(R.string.ChatFoldersFragment__only_direct_messages), + onAdd = { onAdd(chatFolder) } + ) + } + ChatFolderRecord.FolderType.GROUP -> { + val title: String = stringResource(R.string.ChatFoldersFragment__groups) + FolderRow( + icon = R.drawable.symbol_group_light_20, + title = title, + subtitle = stringResource(R.string.ChatFoldersFragment__only_group_messages), + onAdd = { onAdd(chatFolder) } + ) + } + ChatFolderRecord.FolderType.ALL -> { + error("All chats should not be suggested") + } + ChatFolderRecord.FolderType.CUSTOM -> { + error("Custom folders should not be suggested") + } + } } } } @@ -218,6 +256,7 @@ private fun getFolderDescription(folder: ChatFolderRecord): String { } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun FolderRow( modifier: Modifier = Modifier, @@ -226,12 +265,26 @@ fun FolderRow( subtitle: String = "", onClick: (() -> Unit)? = null, onAdd: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, elevation: Dp = 0.dp, showDragHandle: Boolean = false ) { + val menuController = remember { DropdownMenus.MenuController() } + Row( verticalAlignment = Alignment.CenterVertically, - modifier = if (onClick != null) { + modifier = if (onClick != null && onDelete != null) { + modifier + .combinedClickable( + onClick = onClick, + onLongClick = { menuController.show() } + ) + .fillMaxWidth() + .defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height)) + .shadow(elevation = elevation) + .background(MaterialTheme.colorScheme.background) + .padding(start = 24.dp, end = 12.dp) + } else if (onClick != null) { modifier .clickable(onClick = onClick) .fillMaxWidth() @@ -284,6 +337,47 @@ fun FolderRow( tint = MaterialTheme.colorScheme.onSurfaceVariant ) } + + DropdownMenus.Menu(controller = menuController, offsetX = 0.dp, offsetY = 4.dp) { menuController -> + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { + onClick!!() + menuController.hide() + }) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_edit_24), + contentDescription = null + ) + Text( + text = stringResource(R.string.ChatFoldersFragment__edit_folder), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { + onDelete!!() + menuController.hide() + }) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_trash_24), + contentDescription = null + ) + Text( + text = stringResource(R.string.CreateFoldersFragment__delete_folder), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt index 6445ffe659..8f383dba60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt @@ -147,10 +147,13 @@ class ChatFoldersViewModel : ViewModel() { } } - fun deleteFolder() { + fun deleteFolder(context: Context, forceRefresh: Boolean = false) { viewModelScope.launch(Dispatchers.IO) { ChatFoldersRepository.deleteFolder(internalState.value.originalFolder) + if (forceRefresh) { + loadCurrentFolders(context) + } internalState.update { it.copy(showDeleteDialog = false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt index a0a68c94f8..aa43b6ef8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt @@ -129,7 +129,7 @@ class CreateFoldersFragment : ComposeFragment() { onToggleShowMuted = { viewModel.toggleShowMutedChats(it) }, onDeleteClicked = { viewModel.showDeleteDialog(true) }, onDeleteConfirmed = { - viewModel.deleteFolder() + viewModel.deleteFolder(requireContext()) navController.popBackStack() }, onDeleteDismissed = { diff --git a/core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt b/core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt index 75ced668de..b04fe3e438 100644 --- a/core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt +++ b/core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import org.signal.core.ui.copied.androidx.compose.material3.DropdownMenu @@ -32,6 +33,8 @@ object DropdownMenus { fun Menu( controller: MenuController = remember { MenuController() }, modifier: Modifier = Modifier, + offsetX: Dp = dimensionResource(id = R.dimen.core_ui__gutter), + offsetY: Dp = 0.dp, content: @Composable ColumnScope.(MenuController) -> Unit ) { MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(18.dp))) { @@ -39,8 +42,8 @@ object DropdownMenus { expanded = controller.isShown(), onDismissRequest = controller::hide, offset = DpOffset( - x = dimensionResource(id = R.dimen.core_ui__gutter), - y = 0.dp + x = offsetX, + y = offsetY ), content = { content(controller) }, modifier = modifier diff --git a/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt index 053fd0ff9c..c767318fb1 100644 --- a/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt +++ b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt @@ -3,7 +3,6 @@ package org.signal.core.ui.copied.androidx.compose 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.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -23,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp import androidx.compose.ui.zIndex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel @@ -33,13 +33,14 @@ import kotlinx.coroutines.launch * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt * * Allows for dragging and dropping to reorder within lazy columns + * Supports adding non-draggable headers and footers. */ @Composable -fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState { +fun rememberDragDropState(lazyListState: LazyListState, includeHeader: Boolean, includeFooter: Boolean, onMove: (Int, Int) -> Unit): DragDropState { val scope = rememberCoroutineScope() val state = remember(lazyListState) { - DragDropState(state = lazyListState, onMove = onMove, scope = scope) + DragDropState(state = lazyListState, onMove = onMove, includeHeader = includeHeader, includeFooter = includeFooter, scope = scope) } LaunchedEffect(state) { while (true) { @@ -54,6 +55,8 @@ class DragDropState internal constructor( private val state: LazyListState, private val scope: CoroutineScope, + private val includeHeader: Boolean, + private val includeFooter: Boolean, private val onMove: (Int, Int) -> Unit ) { var draggingItemIndex by mutableStateOf(null) @@ -80,7 +83,11 @@ internal constructor( internal fun onDragStart(offset: Offset) { state.layoutInfo.visibleItemsInfo - .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } + .firstOrNull { item -> + offset.y.toInt() in item.offset..(item.offset + item.size) && + (!includeHeader || item.index != 0) && + (!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1)) + } ?.also { draggingItemIndex = it.index draggingItemInitialOffset = it.offset @@ -106,6 +113,10 @@ internal constructor( } internal fun onDrag(offset: Offset) { + if ((includeHeader && draggingItemIndex == 0) || + (includeFooter && draggingItemIndex == (state.layoutInfo.totalItemsCount - 1)) + ) return + draggingItemDraggedDelta += offset.y val draggingItem = draggingItemLayoutInfo ?: return @@ -116,19 +127,20 @@ internal constructor( val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> middleOffset.toInt() in item.offset..item.offsetEnd && - draggingItem.index != item.index + item.index != draggingItem.index && + (!includeHeader || item.index != 0) && + (!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1)) } - if (targetItem != null) { - if ( - draggingItem.index == state.firstVisibleItemIndex || - targetItem.index == state.firstVisibleItemIndex - ) { - state.requestScrollToItem( - state.firstVisibleItemIndex, - state.firstVisibleItemScrollOffset - ) + + if (targetItem != null && + (!includeHeader || targetItem.index != 0) && + (!includeFooter || targetItem.index != (state.layoutInfo.totalItemsCount - 1)) + ) { + if (includeHeader) { + onMove.invoke(draggingItem.index - 1, targetItem.index - 1) + } else { + onMove.invoke(draggingItem.index, targetItem.index) } - onMove.invoke(draggingItem.index, targetItem.index) draggingItemIndex = targetItem.index } else { val overscroll = @@ -149,16 +161,18 @@ internal constructor( get() = this.offset + this.size } -fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { +fun Modifier.dragContainer(dragDropState: DragDropState, leftDpOffset: Dp, rightDpOffset: Dp): Modifier { return pointerInput(dragDropState) { - detectDragGesturesAfterLongPress( + detectDragGestures( onDrag = { change, offset -> change.consume() dragDropState.onDrag(offset = offset) }, onDragStart = { offset -> dragDropState.onDragStart(offset) }, onDragEnd = { dragDropState.onDragInterrupted() }, - onDragCancel = { dragDropState.onDragInterrupted() } + onDragCancel = { dragDropState.onDragInterrupted() }, + leftDpOffset = leftDpOffset, + rightDpOffset = rightDpOffset ) } } diff --git a/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragGestureDetector.kt b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragGestureDetector.kt new file mode 100644 index 0000000000..1b220667be --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragGestureDetector.kt @@ -0,0 +1,131 @@ +package org.signal.core.ui.copied.androidx.compose + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.fastForEach +import kotlinx.coroutines.CancellationException + +/** + * Modified version of detectDragGesturesAfterLongPress from [androidx.compose.foundation.gestures.DragGestureDetector] + * that allows you to optionally offset the starting and ending position of the draggable area + */ +suspend fun PointerInputScope.detectDragGestures( + onDragStart: (Offset) -> Unit = { }, + onDragEnd: () -> Unit = { }, + onDragCancel: () -> Unit = { }, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, + leftDpOffset: Dp = 0.dp, + rightDpOffset: Dp +) { + awaitEachGesture { + try { + val down = awaitFirstDown(requireUnconsumed = false) + val drag = awaitLongPressOrCancellation(down.id) + if (drag != null && (drag.position.x > leftDpOffset.toPx()) && (drag.position.x < rightDpOffset.toPx())) { + onDragStart.invoke(drag.position) + + if ( + drag(drag.id) { + onDrag(it, it.positionChange()) + it.consume() + } + ) { + // consume up if we quit drag gracefully with the up + currentEvent.changes.fastForEach { + if (it.changedToUp()) it.consume() + } + onDragEnd() + } else { + onDragCancel() + } + } + } catch (c: CancellationException) { + onDragCancel() + throw c + } + } +} + +/** + * Modified version of awaitLongPressOrCancellation from [androidx.compose.foundation.gestures.DragGestureDetector] with a reduced long press timeout + */ +suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation( + pointerId: PointerId +): PointerInputChange? { + if (currentEvent.isPointerUp(pointerId)) { + return null // The pointer has already been lifted, so the long press is cancelled. + } + + val initialDown = + currentEvent.changes.fastFirstOrNull { it.id == pointerId } ?: return null + + var longPress: PointerInputChange? = null + var currentDown = initialDown + val longPressTimeout = (viewConfiguration.longPressTimeoutMillis / 100) + return try { + // wait for first tap up or long press + withTimeout(longPressTimeout) { + var finished = false + while (!finished) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) { + // All pointers are up + finished = true + } + + if ( + event.changes.fastAny { + it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding) + } + ) { + finished = true // Canceled + } + + // Check for cancel by position consumption. We can look on the Final pass of + // the existing pointer event because it comes after the Main pass we checked + // above. + val consumeCheck = awaitPointerEvent(PointerEventPass.Final) + if (consumeCheck.changes.fastAny { it.isConsumed }) { + finished = true + } + if (event.isPointerUp(currentDown.id)) { + val newPressed = event.changes.fastFirstOrNull { it.pressed } + if (newPressed != null) { + currentDown = newPressed + longPress = currentDown + } else { + // should technically never happen as we checked it above + finished = true + } + // Pointer (id) stayed down. + } else { + longPress = event.changes.fastFirstOrNull { it.id == currentDown.id } + } + } + } + null + } catch (_: PointerEventTimeoutCancellationException) { + longPress ?: initialDown + } +} + +private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean = + changes.fastFirstOrNull { it.id == pointerId }?.pressed != true