summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Will Leshner <wleshner@google.com> 2025-02-23 20:08:21 -0800
committer Will Leshner <wleshner@google.com> 2025-03-05 10:17:18 -0800
commit91e3b02da70c3cad058b9d06bd8fe9a8287158cc (patch)
tree9c3f731f310931068fc205664c5440bc98a96152
parent2dd064df6db21344e067ff9280926950a16e1431 (diff)
Fix drag reordering on mobile.
Auto-scrolling now scrolls a column at a time in order to provide a more consistent scrolling experience. This fixes issues with dragging where auto-scrolling happens too quickly to be useful. Bug: 395227190 Test: Manually by drag reordering on both mobile and table. Also tested dragging from the widget picker. Flag: com.android.systemui.glanceable_hub_v2 Change-Id: I9959d21a8726abaac6aff74f86873de3c8a5665f
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ContentListState.kt10
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/DragAndDropTargetState.kt288
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/GridDragDropState.kt395
3 files changed, 642 insertions, 51 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ContentListState.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ContentListState.kt
index 8ad96a5bcb37..62b134279267 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ContentListState.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ContentListState.kt
@@ -77,6 +77,16 @@ internal constructor(
list.apply { add(toIndex, removeAt(fromIndex)) }
}
+ /** Swap the two items in the list with the given indices. */
+ fun swapItems(index1: Int, index2: Int) {
+ list.apply {
+ val item1 = get(index1)
+ val item2 = get(index2)
+ set(index2, item1)
+ set(index1, item2)
+ }
+ }
+
/** Remove widget from the list and the database. */
fun onRemove(indexToRemove: Int) {
if (list[indexToRemove].isWidgetContent()) {
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/DragAndDropTargetState.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/DragAndDropTargetState.kt
index 0aef7f2c7063..dda388aeeac6 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/DragAndDropTargetState.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/DragAndDropTargetState.kt
@@ -18,8 +18,10 @@ package com.android.systemui.communal.ui.compose
import android.content.ClipDescription
import android.view.DragEvent
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
@@ -37,6 +39,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.android.systemui.Flags.communalWidgetResizing
+import com.android.systemui.Flags.glanceableHubV2
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.ui.compose.extensions.firstItemAtOffset
import com.android.systemui.communal.util.WidgetPickerIntentUtils
@@ -51,13 +54,14 @@ import kotlinx.coroutines.launch
* @see dragAndDropTarget
*/
@Composable
-internal fun rememberDragAndDropTargetState(
+fun rememberDragAndDropTargetState(
gridState: LazyGridState,
contentOffset: Offset,
contentListState: ContentListState,
): DragAndDropTargetState {
val scope = rememberCoroutineScope()
val autoScrollThreshold = with(LocalDensity.current) { 60.dp.toPx() }
+
val state =
remember(gridState, contentOffset, contentListState, autoScrollThreshold, scope) {
DragAndDropTargetState(
@@ -68,11 +72,9 @@ internal fun rememberDragAndDropTargetState(
scope = scope,
)
}
- LaunchedEffect(state) {
- for (diff in state.scrollChannel) {
- gridState.scrollBy(diff)
- }
- }
+
+ LaunchedEffect(state) { state.processScrollRequests(scope) }
+
return state
}
@@ -83,7 +85,7 @@ internal fun rememberDragAndDropTargetState(
* @see DragEvent
*/
@Composable
-internal fun Modifier.dragAndDropTarget(dragDropTargetState: DragAndDropTargetState): Modifier {
+fun Modifier.dragAndDropTarget(dragDropTargetState: DragAndDropTargetState): Modifier {
val state by rememberUpdatedState(dragDropTargetState)
return this then
@@ -132,13 +134,79 @@ internal fun Modifier.dragAndDropTarget(dragDropTargetState: DragAndDropTargetSt
* other activities. [GridDragDropState] on the other hand, handles dragging of existing items in
* the communal hub grid.
*/
-internal class DragAndDropTargetState(
+class DragAndDropTargetState(
+ state: LazyGridState,
+ contentOffset: Offset,
+ contentListState: ContentListState,
+ autoScrollThreshold: Float,
+ scope: CoroutineScope,
+) {
+ private val dragDropState: DragAndDropTargetStateInternal =
+ if (glanceableHubV2()) {
+ DragAndDropTargetStateV2(
+ state = state,
+ contentListState = contentListState,
+ scope = scope,
+ autoScrollThreshold = autoScrollThreshold,
+ contentOffset = contentOffset,
+ )
+ } else {
+ DragAndDropTargetStateV1(
+ state = state,
+ contentListState = contentListState,
+ scope = scope,
+ autoScrollThreshold = autoScrollThreshold,
+ contentOffset = contentOffset,
+ )
+ }
+
+ fun onStarted() = dragDropState.onStarted()
+
+ fun onMoved(event: DragAndDropEvent) = dragDropState.onMoved(event)
+
+ fun onDrop(event: DragAndDropEvent) = dragDropState.onDrop(event)
+
+ fun onEnded() = dragDropState.onEnded()
+
+ fun onExited() = dragDropState.onExited()
+
+ suspend fun processScrollRequests(coroutineScope: CoroutineScope) =
+ dragDropState.processScrollRequests(coroutineScope)
+}
+
+/**
+ * A private interface defining the API for handling drag-and-drop operations. There will be two
+ * implementations of this interface: V1 for devices that do not have the glanceable_hub_v2 flag
+ * enabled, and V2 for devices that do have that flag enabled.
+ *
+ * TODO(b/400789179): Remove this interface and the V1 implementation once glanceable_hub_v2 has
+ * shipped.
+ */
+private interface DragAndDropTargetStateInternal {
+ fun onStarted() = Unit
+
+ fun onMoved(event: DragAndDropEvent) = Unit
+
+ fun onDrop(event: DragAndDropEvent): Boolean = false
+
+ fun onEnded() = Unit
+
+ fun onExited() = Unit
+
+ suspend fun processScrollRequests(coroutineScope: CoroutineScope) = Unit
+}
+
+/**
+ * The V1 implementation of DragAndDropTargetStateInternal to be used when the glanceable_hub_v2
+ * flag is disabled.
+ */
+private class DragAndDropTargetStateV1(
private val state: LazyGridState,
private val contentOffset: Offset,
private val contentListState: ContentListState,
private val autoScrollThreshold: Float,
private val scope: CoroutineScope,
-) {
+) : DragAndDropTargetStateInternal {
/**
* The placeholder item that is treated as if it is being dragged across the grid. It is added
* to grid once drag and drop event is started and removed when event ends.
@@ -147,15 +215,21 @@ internal class DragAndDropTargetState(
private var placeHolderIndex: Int? = null
private var previousTargetItemKey: Any? = null
- internal val scrollChannel = Channel<Float>()
+ private val scrollChannel = Channel<Float>()
- fun onStarted() {
+ override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
+ for (diff in scrollChannel) {
+ state.scrollBy(diff)
+ }
+ }
+
+ override fun onStarted() {
// assume item will be added to the end.
contentListState.list.add(placeHolder)
placeHolderIndex = contentListState.list.size - 1
}
- fun onMoved(event: DragAndDropEvent) {
+ override fun onMoved(event: DragAndDropEvent) {
val dragOffset = event.toOffset()
val targetItem =
@@ -201,7 +275,7 @@ internal class DragAndDropTargetState(
}
}
- fun onDrop(event: DragAndDropEvent): Boolean {
+ override fun onDrop(event: DragAndDropEvent): Boolean {
return placeHolderIndex?.let { dropIndex ->
val widgetExtra = event.maybeWidgetExtra() ?: return false
val (componentName, user) = widgetExtra
@@ -219,13 +293,13 @@ internal class DragAndDropTargetState(
} ?: false
}
- fun onEnded() {
+ override fun onEnded() {
placeHolderIndex = null
previousTargetItemKey = null
contentListState.list.remove(placeHolder)
}
- fun onExited() {
+ override fun onExited() {
onEnded()
}
@@ -257,16 +331,186 @@ internal class DragAndDropTargetState(
contentListState.onMove(currentIndex, index)
}
}
+}
+/**
+ * The V2 implementation of DragAndDropTargetStateInternal to be used when the glanceable_hub_v2
+ * flag is enabled.
+ */
+private class DragAndDropTargetStateV2(
+ private val state: LazyGridState,
+ private val contentOffset: Offset,
+ private val contentListState: ContentListState,
+ private val autoScrollThreshold: Float,
+ private val scope: CoroutineScope,
+) : DragAndDropTargetStateInternal {
/**
- * Parses and returns the intent extra associated with the widget that is dropped into the grid.
- *
- * Returns null if the drop event didn't include intent information.
+ * The placeholder item that is treated as if it is being dragged across the grid. It is added
+ * to grid once drag and drop event is started and removed when event ends.
*/
- private fun DragAndDropEvent.maybeWidgetExtra(): WidgetPickerIntentUtils.WidgetExtra? {
- val clipData = this.toAndroidDragEvent().clipData.takeIf { it.itemCount != 0 }
- return clipData?.getItemAt(0)?.intent?.let { intent -> getWidgetExtraFromIntent(intent) }
+ private var placeHolder = CommunalContentModel.WidgetPlaceholder()
+ private var placeHolderIndex: Int? = null
+ private var previousTargetItemKey: Any? = null
+ private var dragOffset = Offset.Zero
+ private var columnWidth = 0
+
+ private val scrollChannel = Channel<Float>()
+
+ override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
+ while (true) {
+ val amount = scrollChannel.receive()
+
+ if (state.isScrollInProgress) {
+ // Ignore overscrolling if a scroll is already in progress (but we still want to
+ // consume the scroll event so that we don't end up processing a bunch of old
+ // events after scrolling has finished).
+ continue
+ }
+
+ // Perform the rest of the drag operation after scrolling has finished (or immediately
+ // if there will be no scrolling).
+ if (amount != 0f) {
+ scope.launch {
+ state.animateScrollBy(amount, tween(delayMillis = 250, durationMillis = 1000))
+ performDragAction()
+ }
+ } else {
+ performDragAction()
+ }
+ }
+ }
+
+ override fun onStarted() {
+ // assume item will be added to the end.
+ contentListState.list.add(placeHolder)
+ placeHolderIndex = contentListState.list.size - 1
+
+ // Use the width of the first item as the column width.
+ columnWidth =
+ state.layoutInfo.visibleItemsInfo.first().size.width +
+ state.layoutInfo.beforeContentPadding +
+ state.layoutInfo.afterContentPadding
}
- private fun DragAndDropEvent.toOffset() = this.toAndroidDragEvent().run { Offset(x, y) }
+ override fun onMoved(event: DragAndDropEvent) {
+ dragOffset = event.toOffset()
+ scrollChannel.trySend(computeAutoscroll(dragOffset))
+ }
+
+ override fun onDrop(event: DragAndDropEvent): Boolean {
+ return placeHolderIndex?.let { dropIndex ->
+ val widgetExtra = event.maybeWidgetExtra() ?: return false
+ val (componentName, user) = widgetExtra
+ if (componentName != null && user != null) {
+ // Placeholder isn't removed yet to allow the setting the right rank for items
+ // before adding in the new item.
+ contentListState.onSaveList(
+ newItemComponentName = componentName,
+ newItemUser = user,
+ newItemIndex = dropIndex,
+ )
+ return@let true
+ }
+ return false
+ } ?: false
+ }
+
+ override fun onEnded() {
+ placeHolderIndex = null
+ previousTargetItemKey = null
+ contentListState.list.remove(placeHolder)
+ }
+
+ override fun onExited() {
+ onEnded()
+ }
+
+ private fun performDragAction() {
+ val targetItem =
+ state.layoutInfo.visibleItemsInfo
+ .asSequence()
+ .filter { item -> contentListState.isItemEditable(item.index) }
+ .firstItemAtOffset(dragOffset - contentOffset)
+
+ if (
+ targetItem != null &&
+ (!communalWidgetResizing() || targetItem.key != previousTargetItemKey)
+ ) {
+ if (communalWidgetResizing()) {
+ // Keep track of the previous target item, to avoid rapidly oscillating between
+ // items if the target item doesn't visually move as a result of the index change.
+ // In this case, even after the index changes, we'd still be colliding with the
+ // element, so it would be selected as the target item the next time this function
+ // runs again, which would trigger us to revert the index change we recently made.
+ previousTargetItemKey = targetItem.key
+ }
+
+ val scrollToIndex =
+ if (targetItem.index == state.firstVisibleItemIndex) {
+ placeHolderIndex
+ } else if (placeHolderIndex == state.firstVisibleItemIndex) {
+ targetItem.index
+ } else {
+ null
+ }
+
+ if (scrollToIndex != null) {
+ scope.launch {
+ state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
+ movePlaceholderTo(targetItem.index)
+ }
+ } else {
+ movePlaceholderTo(targetItem.index)
+ }
+
+ placeHolderIndex = targetItem.index
+ } else if (targetItem == null) {
+ previousTargetItemKey = null
+ }
+ }
+
+ private fun computeAutoscroll(dragOffset: Offset): Float {
+ val orientation = state.layoutInfo.orientation
+ val distanceFromStart =
+ if (orientation == Orientation.Horizontal) {
+ dragOffset.x
+ } else {
+ dragOffset.y
+ }
+ val distanceFromEnd =
+ if (orientation == Orientation.Horizontal) {
+ state.layoutInfo.viewportEndOffset - dragOffset.x
+ } else {
+ state.layoutInfo.viewportEndOffset - dragOffset.y
+ }
+
+ return when {
+ distanceFromEnd < autoScrollThreshold -> {
+ (columnWidth - state.layoutInfo.beforeContentPadding).toFloat()
+ }
+ distanceFromStart < autoScrollThreshold -> {
+ -(columnWidth - state.layoutInfo.afterContentPadding).toFloat()
+ }
+ else -> 0f
+ }
+ }
+
+ private fun movePlaceholderTo(index: Int) {
+ val currentIndex = contentListState.list.indexOf(placeHolder)
+ if (currentIndex != index) {
+ contentListState.swapItems(currentIndex, index)
+ }
+ }
}
+
+/**
+ * Parses and returns the intent extra associated with the widget that is dropped into the grid.
+ *
+ * Returns null if the drop event didn't include intent information.
+ */
+private fun DragAndDropEvent.maybeWidgetExtra(): WidgetPickerIntentUtils.WidgetExtra? {
+ val clipData = this.toAndroidDragEvent().clipData.takeIf { it.itemCount != 0 }
+ return clipData?.getItemAt(0)?.intent?.let { intent -> getWidgetExtraFromIntent(intent) }
+}
+
+private fun DragAndDropEvent.toOffset() = this.toAndroidDragEvent().run { Offset(x, y) }
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/GridDragDropState.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/GridDragDropState.kt
index c972d3e3cf15..2a5addeb4951 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/GridDragDropState.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/GridDragDropState.kt
@@ -19,7 +19,10 @@ package com.android.systemui.communal.ui.compose
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
@@ -37,13 +40,16 @@ 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.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import com.android.systemui.Flags.communalWidgetResizing
+import com.android.systemui.Flags.glanceableHubV2
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.shared.model.CommunalContentSize
import com.android.systemui.communal.ui.compose.extensions.firstItemAtOffset
@@ -62,22 +68,22 @@ fun rememberGridDragDropState(
contentListState: ContentListState,
updateDragPositionForRemove: (boundingBox: IntRect) -> Boolean,
): GridDragDropState {
- val scope = rememberCoroutineScope()
+ val coroutineScope = rememberCoroutineScope()
+ val autoScrollThreshold = with(LocalDensity.current) { 60.dp.toPx() }
+
val state =
remember(gridState, contentListState, updateDragPositionForRemove) {
GridDragDropState(
- state = gridState,
+ gridState = gridState,
contentListState = contentListState,
- scope = scope,
+ coroutineScope = coroutineScope,
+ autoScrollThreshold = autoScrollThreshold,
updateDragPositionForRemove = updateDragPositionForRemove,
)
}
- LaunchedEffect(state) {
- while (true) {
- val diff = state.scrollChannel.receive()
- gridState.scrollBy(diff)
- }
- }
+
+ LaunchedEffect(state) { state.processScrollRequests(coroutineScope) }
+
return state
}
@@ -89,36 +95,86 @@ fun rememberGridDragDropState(
* to remove the dragged item if condition met and call [ContentListState.onSaveList] to persist any
* change in ordering.
*/
-class GridDragDropState
-internal constructor(
- private val state: LazyGridState,
- private val contentListState: ContentListState,
- private val scope: CoroutineScope,
+class GridDragDropState(
+ val gridState: LazyGridState,
+ contentListState: ContentListState,
+ coroutineScope: CoroutineScope,
+ autoScrollThreshold: Float,
private val updateDragPositionForRemove: (draggingBoundingBox: IntRect) -> Boolean,
) {
- var draggingItemKey by mutableStateOf<String?>(null)
- private set
+ private val dragDropState: GridDragDropStateInternal =
+ if (glanceableHubV2()) {
+ GridDragDropStateV2(
+ gridState = gridState,
+ contentListState = contentListState,
+ scope = coroutineScope,
+ autoScrollThreshold = autoScrollThreshold,
+ updateDragPositionForRemove = updateDragPositionForRemove,
+ )
+ } else {
+ GridDragDropStateV1(
+ gridState = gridState,
+ contentListState = contentListState,
+ scope = coroutineScope,
+ updateDragPositionForRemove = updateDragPositionForRemove,
+ )
+ }
- var isDraggingToRemove by mutableStateOf(false)
- private set
+ val draggingItemKey: String?
+ get() = dragDropState.draggingItemKey
- internal val scrollChannel = Channel<Float>()
+ val isDraggingToRemove: Boolean
+ get() = dragDropState.isDraggingToRemove
- private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
- private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
+ val draggingItemOffset: Offset
+ get() = dragDropState.draggingItemOffset
- private val spacer = CommunalContentModel.Spacer(CommunalContentSize.Responsive(1))
- private var spacerIndex: Int? = null
+ /**
+ * Called when dragging is initiated.
+ *
+ * @return {@code True} if dragging a grid item, {@code False} otherwise.
+ */
+ fun onDragStart(
+ offset: Offset,
+ screenWidth: Int,
+ layoutDirection: LayoutDirection,
+ contentOffset: Offset,
+ ): Boolean = dragDropState.onDragStart(offset, screenWidth, layoutDirection, contentOffset)
- private var previousTargetItemKey: Any? = null
+ fun onDragInterrupted() = dragDropState.onDragInterrupted()
+
+ fun onDrag(offset: Offset, layoutDirection: LayoutDirection) =
+ dragDropState.onDrag(offset, layoutDirection)
+
+ suspend fun processScrollRequests(coroutineScope: CoroutineScope) =
+ dragDropState.processScrollRequests(coroutineScope)
+}
+
+/**
+ * A private base class defining the API for handling drag-and-drop operations. There will be two
+ * implementations of this class: V1 for devices that do not have the glanceable_hub_v2 flag
+ * enabled, and V2 for devices that do have that flag enabled.
+ *
+ * TODO(b/400789179): Remove this class and the V1 implementation once glanceable_hub_v2 has
+ * shipped.
+ */
+private open class GridDragDropStateInternal(protected val state: LazyGridState) {
+ var draggingItemKey by mutableStateOf<String?>(null)
+ protected set
- internal val draggingItemOffset: Offset
+ var isDraggingToRemove by mutableStateOf(false)
+ protected set
+
+ var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
+ var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
+
+ val draggingItemOffset: Offset
get() =
draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
} ?: Offset.Zero
- private val draggingItemLayoutInfo: LazyGridItemInfo?
+ val draggingItemLayoutInfo: LazyGridItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey }
/**
@@ -126,7 +182,45 @@ internal constructor(
*
* @return {@code True} if dragging a grid item, {@code False} otherwise.
*/
- internal fun onDragStart(
+ open fun onDragStart(
+ offset: Offset,
+ screenWidth: Int,
+ layoutDirection: LayoutDirection,
+ contentOffset: Offset,
+ ): Boolean = false
+
+ open fun onDragInterrupted() = Unit
+
+ open fun onDrag(offset: Offset, layoutDirection: LayoutDirection) = Unit
+
+ open suspend fun processScrollRequests(coroutineScope: CoroutineScope) = Unit
+}
+
+/**
+ * The V1 implementation of GridDragDropStateInternal to be used when the glanceable_hub_v2 flag is
+ * disabled.
+ */
+private class GridDragDropStateV1(
+ val gridState: LazyGridState,
+ private val contentListState: ContentListState,
+ private val scope: CoroutineScope,
+ private val updateDragPositionForRemove: (draggingBoundingBox: IntRect) -> Boolean,
+) : GridDragDropStateInternal(gridState) {
+ private val scrollChannel = Channel<Float>()
+
+ private val spacer = CommunalContentModel.Spacer(CommunalContentSize.Responsive(1))
+ private var spacerIndex: Int? = null
+
+ private var previousTargetItemKey: Any? = null
+
+ override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
+ while (true) {
+ val diff = scrollChannel.receive()
+ state.scrollBy(diff)
+ }
+ }
+
+ override fun onDragStart(
offset: Offset,
screenWidth: Int,
layoutDirection: LayoutDirection,
@@ -162,7 +256,7 @@ internal constructor(
return false
}
- internal fun onDragInterrupted() {
+ override fun onDragInterrupted() {
draggingItemKey?.let {
if (isDraggingToRemove) {
contentListState.onRemove(
@@ -185,7 +279,7 @@ internal constructor(
}
}
- internal fun onDrag(offset: Offset, layoutDirection: LayoutDirection) {
+ override fun onDrag(offset: Offset, layoutDirection: LayoutDirection) {
// Adjust offset to match the layout direction
draggingItemDraggedDelta +=
Offset(offset.x.directional(LayoutDirection.Ltr, layoutDirection), offset.y)
@@ -282,6 +376,249 @@ internal constructor(
}
}
+/**
+ * The V2 implementation of GridDragDropStateInternal to be used when the glanceable_hub_v2 flag is
+ * enabled.
+ */
+private class GridDragDropStateV2(
+ val gridState: LazyGridState,
+ private val contentListState: ContentListState,
+ private val scope: CoroutineScope,
+ private val autoScrollThreshold: Float,
+ private val updateDragPositionForRemove: (draggingBoundingBox: IntRect) -> Boolean,
+) : GridDragDropStateInternal(gridState) {
+
+ private val scrollChannel = Channel<Float>(Channel.UNLIMITED)
+
+ // Used to keep track of the dragging item during scrolling (because it might be off screen
+ // and no longer in the list of visible items).
+ private var draggingItemWhileScrolling: LazyGridItemInfo? by mutableStateOf(null)
+
+ private val spacer = CommunalContentModel.Spacer(CommunalContentSize.Responsive(1))
+ private var spacerIndex: Int? = null
+
+ private var previousTargetItemKey: Any? = null
+
+ // Basically, the location of the user's finger on the screen.
+ private var currentDragPositionOnScreen by mutableStateOf(Offset.Zero)
+ // The offset of the grid from the top of the screen.
+ private var contentOffset = Offset.Zero
+
+ // The width of one column in the grid (needed in order to auto-scroll one column at a time).
+ private var columnWidth = 0
+
+ override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
+ while (true) {
+ val amount = scrollChannel.receive()
+
+ if (state.isScrollInProgress) {
+ // Ignore overscrolling if a scroll is already in progress (but we still want to
+ // consume the scroll event so that we don't end up processing a bunch of old
+ // events after scrolling has finished).
+ continue
+ }
+
+ // We perform the rest of the drag action after scrolling has finished (or immediately
+ // if there will be no scrolling).
+ if (amount != 0f) {
+ coroutineScope.launch {
+ state.animateScrollBy(amount, tween(delayMillis = 250, durationMillis = 1000))
+ performDragAction()
+ }
+ } else {
+ performDragAction()
+ }
+ }
+ }
+
+ override fun onDragStart(
+ offset: Offset,
+ screenWidth: Int,
+ layoutDirection: LayoutDirection,
+ contentOffset: Offset,
+ ): Boolean {
+ val normalizedOffset =
+ Offset(
+ if (layoutDirection == LayoutDirection.Ltr) offset.x else screenWidth - offset.x,
+ offset.y,
+ )
+
+ currentDragPositionOnScreen = normalizedOffset
+ this.contentOffset = contentOffset
+
+ state.layoutInfo.visibleItemsInfo
+ .filter { item -> contentListState.isItemEditable(item.index) }
+ // grid item offset is based off grid content container so we need to deduct
+ // before content padding from the initial pointer position
+ .firstItemAtOffset(normalizedOffset - contentOffset)
+ ?.apply {
+ draggingItemKey = key as String
+ draggingItemWhileScrolling = this
+ draggingItemInitialOffset = this.offset.toOffset()
+ columnWidth =
+ this.size.width +
+ state.layoutInfo.beforeContentPadding +
+ state.layoutInfo.afterContentPadding
+ // Add a spacer after the last widget if it is larger than the dragging widget.
+ // This allows overscrolling, enabling the dragging widget to be placed beyond it.
+ val lastWidget = contentListState.list.lastOrNull { it.isWidgetContent() }
+ if (
+ lastWidget != null &&
+ draggingItemLayoutInfo != null &&
+ lastWidget.size.span > draggingItemLayoutInfo!!.span
+ ) {
+ contentListState.list.add(spacer)
+ spacerIndex = contentListState.list.size - 1
+ }
+ return true
+ }
+
+ return false
+ }
+
+ override fun onDragInterrupted() {
+ draggingItemKey?.let {
+ if (isDraggingToRemove) {
+ contentListState.onRemove(
+ contentListState.list.indexOfFirst { it.key == draggingItemKey }
+ )
+ isDraggingToRemove = false
+ updateDragPositionForRemove(IntRect.Zero)
+ }
+ // persist list editing changes on dragging ends
+ contentListState.onSaveList()
+ draggingItemKey = null
+ }
+ previousTargetItemKey = null
+ draggingItemDraggedDelta = Offset.Zero
+ draggingItemInitialOffset = Offset.Zero
+ currentDragPositionOnScreen = Offset.Zero
+ draggingItemWhileScrolling = null
+ // Remove spacer, if any, when a drag gesture finishes.
+ spacerIndex?.let {
+ contentListState.list.removeAt(it)
+ spacerIndex = null
+ }
+ }
+
+ override fun onDrag(offset: Offset, layoutDirection: LayoutDirection) {
+ // Adjust offset to match the layout direction
+ val delta = Offset(offset.x.directional(LayoutDirection.Ltr, layoutDirection), offset.y)
+ draggingItemDraggedDelta += delta
+ currentDragPositionOnScreen += delta
+
+ scrollChannel.trySend(computeAutoscroll(currentDragPositionOnScreen))
+ }
+
+ fun performDragAction() {
+ val draggingItem = draggingItemLayoutInfo ?: draggingItemWhileScrolling
+ if (draggingItem == null) {
+ return
+ }
+
+ val draggingBoundingBox =
+ IntRect(draggingItem.offset + draggingItemOffset.round(), draggingItem.size)
+ val curDragPositionInGrid = (currentDragPositionOnScreen - contentOffset)
+
+ val targetItem =
+ if (communalWidgetResizing()) {
+ val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.last().index
+ state.layoutInfo.visibleItemsInfo.findLast(
+ fun(item): Boolean {
+ val itemBoundingBox = IntRect(item.offset, item.size)
+ return draggingItemKey != item.key &&
+ contentListState.isItemEditable(item.index) &&
+ itemBoundingBox.contains(curDragPositionInGrid.round()) &&
+ // If we swap with the last visible item, and that item doesn't fit
+ // in the gap created by moving the current item, then the current item
+ // will get placed after the last visible item. In this case, it gets
+ // placed outside of the viewport. We avoid this here, so the user
+ // has to scroll first before the swap can happen.
+ (item.index != lastVisibleItemIndex || item.span <= draggingItem.span)
+ }
+ )
+ } else {
+ state.layoutInfo.visibleItemsInfo
+ .asSequence()
+ .filter { item -> contentListState.isItemEditable(item.index) }
+ .filter { item -> draggingItem.index != item.index }
+ .firstItemAtOffset(curDragPositionInGrid)
+ }
+
+ if (
+ targetItem != null &&
+ (!communalWidgetResizing() || targetItem.key != previousTargetItemKey)
+ ) {
+ val scrollToIndex =
+ if (targetItem.index == state.firstVisibleItemIndex) {
+ draggingItem.index
+ } else if (draggingItem.index == state.firstVisibleItemIndex) {
+ targetItem.index
+ } else {
+ null
+ }
+ if (communalWidgetResizing()) {
+ // Keep track of the previous target item, to avoid rapidly oscillating between
+ // items if the target item doesn't visually move as a result of the index change.
+ // In this case, even after the index changes, we'd still be colliding with the
+ // element, so it would be selected as the target item the next time this function
+ // runs again, which would trigger us to revert the index change we recently made.
+ previousTargetItemKey = targetItem.key
+ }
+ if (scrollToIndex != null) {
+ scope.launch {
+ // this is needed to neutralize automatic keeping the first item first.
+ state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
+ contentListState.swapItems(draggingItem.index, targetItem.index)
+ }
+ } else {
+ contentListState.swapItems(draggingItem.index, targetItem.index)
+ }
+ draggingItemWhileScrolling = targetItem
+ isDraggingToRemove = false
+ } else if (targetItem == null) {
+ isDraggingToRemove = checkForRemove(draggingBoundingBox)
+ previousTargetItemKey = null
+ }
+ }
+
+ /** Calculate the amount dragged out of bound on both sides. Returns 0f if not overscrolled. */
+ private fun computeAutoscroll(dragOffset: Offset): Float {
+ val orientation = state.layoutInfo.orientation
+ val distanceFromStart =
+ if (orientation == Orientation.Horizontal) {
+ dragOffset.x
+ } else {
+ dragOffset.y
+ }
+ val distanceFromEnd =
+ if (orientation == Orientation.Horizontal) {
+ state.layoutInfo.viewportEndOffset - dragOffset.x
+ } else {
+ state.layoutInfo.viewportEndOffset - dragOffset.y
+ }
+
+ return when {
+ distanceFromEnd < autoScrollThreshold -> {
+ (columnWidth - state.layoutInfo.beforeContentPadding).toFloat()
+ }
+ distanceFromStart < autoScrollThreshold -> {
+ -(columnWidth - state.layoutInfo.afterContentPadding).toFloat()
+ }
+ else -> 0f
+ }
+ }
+
+ /** Calls the callback with the updated drag position and returns whether to remove the item. */
+ private fun checkForRemove(draggingItemBoundingBox: IntRect): Boolean {
+ return if (draggingItemDraggedDelta.y < 0) {
+ updateDragPositionForRemove(draggingItemBoundingBox)
+ } else {
+ false
+ }
+ }
+}
+
fun Modifier.dragContainer(
dragDropState: GridDragDropState,
layoutDirection: LayoutDirection,