Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 91e3b02d authored by Will Leshner's avatar Will Leshner
Browse files

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
parent 2dd064df
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -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()) {
+266 −22
Original line number Diff line number Diff line
@@ -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>()

    override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
        for (diff in scrollChannel) {
            state.scrollBy(diff)
        }
    }

    fun onStarted() {
    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,6 +331,177 @@ 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 {
    /**
     * 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 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
    }

    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.
@@ -269,4 +514,3 @@ internal class DragAndDropTargetState(
}

private fun DragAndDropEvent.toOffset() = this.toAndroidDragEvent().run { Offset(x, y) }
}
+366 −29

File changed.

Preview size limit exceeded, changes collapsed.