diff options
| author | 2025-02-23 20:08:21 -0800 | |
|---|---|---|
| committer | 2025-03-05 10:17:18 -0800 | |
| commit | 91e3b02da70c3cad058b9d06bd8fe9a8287158cc (patch) | |
| tree | 9c3f731f310931068fc205664c5440bc98a96152 | |
| parent | 2dd064df6db21344e067ff9280926950a16e1431 (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
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, |