Improve reordering folder experience.

This commit is contained in:
Michelle Tang
2024-10-22 13:05:46 -07:00
committed by Greyson Parrelli
parent 9e955e94d9
commit 422acde111
6 changed files with 350 additions and 105 deletions

View File

@@ -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

View File

@@ -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<Int?>(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
)
}
}

View File

@@ -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