mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-03 07:01:05 +01:00
Improve reordering folder experience.
This commit is contained in:
committed by
Greyson Parrelli
parent
9e955e94d9
commit
422acde111
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user