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

Commit 70273c68 authored by Shamali Patwa's avatar Shamali Patwa Committed by Android (Google) Code Review
Browse files

Merge changes from topic "drag-drop-l3" into main

* changes:
  Handle drag and drops from widget picker into communal grid.
  Update the reorder function to also support adding item during reorder.
parents 342c33d3 47d5c3d7
Loading
Loading
Loading
Loading
+32 −4
Original line number Diff line number Diff line
@@ -20,10 +20,12 @@ import android.os.Bundle
import android.util.SizeF
import android.widget.FrameLayout
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -31,11 +33,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
@@ -98,7 +102,6 @@ fun CommunalHub(
        modifier = modifier.fillMaxSize().background(Color.White),
    ) {
        CommunalHubLazyGrid(
            modifier = Modifier.align(Alignment.CenterStart),
            communalContent = communalContent,
            viewModel = viewModel,
            contentPadding = gridContentPadding(viewModel.isEditMode, toolbarSize),
@@ -138,21 +141,21 @@ fun CommunalHub(

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CommunalHubLazyGrid(
private fun BoxScope.CommunalHubLazyGrid(
    communalContent: List<CommunalContentModel>,
    viewModel: BaseCommunalViewModel,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues,
    setGridCoordinates: (coordinates: LayoutCoordinates) -> Unit,
    updateDragPositionForRemove: (offset: Offset) -> Boolean,
) {
    var gridModifier = modifier
    var gridModifier = Modifier.align(Alignment.CenterStart)
    val gridState = rememberLazyGridState()
    var list = communalContent
    var dragDropState: GridDragDropState? = null
    if (viewModel.isEditMode && viewModel is CommunalEditModeViewModel) {
        val contentListState = rememberContentListState(communalContent, viewModel)
        list = contentListState.list
        // for drag & drop operations within the communal hub grid
        dragDropState =
            rememberGridDragDropState(
                gridState = gridState,
@@ -164,9 +167,22 @@ private fun CommunalHubLazyGrid(
                .fillMaxSize()
                .dragContainer(dragDropState, beforeContentPadding(contentPadding))
                .onGloballyPositioned { setGridCoordinates(it) }
        // for widgets dropped from other activities
        val dragAndDropTargetState =
            rememberDragAndDropTargetState(
                gridState = gridState,
                contentListState = contentListState,
                updateDragPositionForRemove = updateDragPositionForRemove
            )

        // A full size box in background that listens to widget drops from the picker.
        // Since the grid has its own listener for in-grid drag events, we use a separate element
        // for android drag events.
        Box(Modifier.fillMaxSize().dragAndDropTarget(dragAndDropTargetState)) {}
    } else {
        gridModifier = gridModifier.height(Dimensions.GridHeight)
    }

    LazyHorizontalGrid(
        modifier = gridModifier,
        state = gridState,
@@ -309,12 +325,24 @@ private fun CommunalContent(
) {
    when (model) {
        is CommunalContentModel.Widget -> WidgetContent(model, size, elevation, modifier)
        is CommunalContentModel.WidgetPlaceholder -> WidgetPlaceholderContent(size)
        is CommunalContentModel.Smartspace -> SmartspaceContent(model, modifier)
        is CommunalContentModel.Tutorial -> TutorialContent(modifier)
        is CommunalContentModel.Umo -> Umo(viewModel, modifier)
    }
}

/** Presents a placeholder card for the new widget being dragged and dropping into the grid. */
@Composable
fun WidgetPlaceholderContent(size: SizeF) {
    Card(
        modifier = Modifier.size(Dp(size.width), Dp(size.height)),
        colors = CardDefaults.cardColors(containerColor = Color.Transparent),
        border = BorderStroke(3.dp, LocalAndroidColorScheme.current.tertiaryFixed),
        shape = RoundedCornerShape(16.dp)
    ) {}
}

@Composable
private fun WidgetContent(
    model: CommunalContentModel.Widget,
+39 −12
Original line number Diff line number Diff line
@@ -16,11 +16,10 @@

package com.android.systemui.communal.ui.compose

import android.content.ComponentName
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.ui.viewmodel.CommunalEditModeViewModel

@@ -32,6 +31,7 @@ fun rememberContentListState(
    return remember(communalContent) {
        ContentListState(
            communalContent,
            viewModel::onAddWidget,
            viewModel::onDeleteWidget,
            viewModel::onReorderWidgets,
        )
@@ -46,30 +46,57 @@ fun rememberContentListState(
class ContentListState
internal constructor(
    communalContent: List<CommunalContentModel>,
    private val onAddWidget: (componentName: ComponentName, priority: Int) -> Unit,
    private val onDeleteWidget: (id: Int) -> Unit,
    private val onReorderWidgets: (ids: List<Int>) -> Unit,
    private val onReorderWidgets: (widgetIdToPriorityMap: Map<Int, Int>) -> Unit,
) {
    var list by mutableStateOf(communalContent)
    var list = communalContent.toMutableStateList()
        private set

    /** Move item to a new position in the list. */
    fun onMove(fromIndex: Int, toIndex: Int) {
        list = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
        list.apply { add(toIndex, removeAt(fromIndex)) }
    }

    /** Remove widget from the list and the database. */
    fun onRemove(indexToRemove: Int) {
        if (list[indexToRemove] is CommunalContentModel.Widget) {
            val widget = list[indexToRemove] as CommunalContentModel.Widget
            list = list.toMutableList().apply { removeAt(indexToRemove) }
            list.apply { removeAt(indexToRemove) }
            onDeleteWidget(widget.appWidgetId)
        }
    }

    /** Persist the new order with all the movements happened during dragging. */
    fun onSaveList() {
        val widgetIds: List<Int> =
            list.filterIsInstance<CommunalContentModel.Widget>().map { it.appWidgetId }
        onReorderWidgets(widgetIds)
    /**
     * Persists the new order with all the movements happened during drag operations & the new
     * widget drop (if applicable).
     *
     * @param newItemComponentName name of the new widget that was dropped into the list; null if no
     *   new widget was added.
     * @param newItemIndex index at which the a new widget was dropped into the list; null if no new
     *   widget was dropped.
     */
    fun onSaveList(newItemComponentName: ComponentName? = null, newItemIndex: Int? = null) {
        // filters placeholder, but, maintains the indices of the widgets as if the placeholder was
        // in the list. When persisted in DB, this leaves space for the new item (to be added) at
        // the correct priority.
        val widgetIdToPriorityMap: Map<Int, Int> =
            list
                .mapIndexedNotNull { index, item ->
                    if (item is CommunalContentModel.Widget) {
                        item.appWidgetId to list.size - index
                    } else {
                        null
                    }
                }
                .toMap()
        // reorder and then add the new widget
        onReorderWidgets(widgetIdToPriorityMap)
        if (newItemComponentName != null && newItemIndex != null) {
            onAddWidget(newItemComponentName, /*priority=*/ list.size - newItemIndex)
        }
    }

    /** Returns true if the item at given index is editable. */
    fun isItemEditable(index: Int) = list[index] is CommunalContentModel.Widget
}
+274 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.communal.ui.compose

import android.content.ClipDescription
import android.content.ComponentName
import android.content.Intent
import android.view.DragEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draganddrop.mimeTypes
import androidx.compose.ui.draganddrop.toAndroidDragEvent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.ui.compose.extensions.plus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
 * Holds state associated with dragging and dropping items from other activities into the lazy grid.
 *
 * @see dragAndDropTarget
 */
@Composable
internal fun rememberDragAndDropTargetState(
    gridState: LazyGridState,
    contentListState: ContentListState,
    updateDragPositionForRemove: (offset: Offset) -> Boolean,
): DragAndDropTargetState {
    val scope = rememberCoroutineScope()
    val autoScrollSpeed = remember { mutableFloatStateOf(0f) }
    // Threshold of distance from edges that should start auto-scroll - chosen to be a narrow value
    // that allows differentiating intention of scrolling from intention of dragging over the first
    // visible item.
    val autoScrollThreshold = with(LocalDensity.current) { 60.dp.toPx() }
    val state =
        remember(gridState, contentListState) {
            DragAndDropTargetState(
                state = gridState,
                contentListState = contentListState,
                scope = scope,
                autoScrollSpeed = autoScrollSpeed,
                autoScrollThreshold = autoScrollThreshold,
                updateDragPositionForRemove = updateDragPositionForRemove,
            )
        }
    LaunchedEffect(autoScrollSpeed.floatValue) {
        if (autoScrollSpeed.floatValue != 0f) {
            while (isActive) {
                gridState.scrollBy(autoScrollSpeed.floatValue)
                delay(10)
            }
        }
    }
    return state
}

/**
 * Attaches a listener for drag and drop events from other activities.
 *
 * @see androidx.compose.foundation.draganddrop.dragAndDropTarget
 * @see DragEvent
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun Modifier.dragAndDropTarget(
    dragDropTargetState: DragAndDropTargetState,
): Modifier {
    val state by rememberUpdatedState(dragDropTargetState)

    return this then
        Modifier.dragAndDropTarget(
            shouldStartDragAndDrop = accept@{ startEvent ->
                    startEvent.mimeTypes().any { it == ClipDescription.MIMETYPE_TEXT_INTENT }
                },
            target =
                object : DragAndDropTarget {
                    override fun onStarted(event: DragAndDropEvent) {
                        state.onStarted()
                    }

                    override fun onMoved(event: DragAndDropEvent) {
                        state.onMoved(event)
                    }

                    override fun onDrop(event: DragAndDropEvent): Boolean {
                        return state.onDrop(event)
                    }

                    override fun onEnded(event: DragAndDropEvent) {
                        state.onEnded()
                    }
                }
        )
}

/**
 * Handles dropping of an item coming from a different activity (e.g. widget picker) in to the grid
 * corresponding to the provided [LazyGridState].
 *
 * Adds a placeholder container to highlight the anticipated location the widget will be dropped to.
 * When the item is held over an empty area, the placeholder appears at the end of the grid if one
 * didn't exist already. As user moves the item over an existing item, the placeholder appears in
 * place of that existing item. And then, the existing item is pushed over as part of re-ordering.
 *
 * Once item is dropped, new ordering along with the dropped item is persisted. See
 * [ContentListState.onSaveList].
 *
 * Difference between this and [GridDragDropState] is that, this is used for listening to drops from
 * other activities. [GridDragDropState] on the other hand, handles dragging of existing items in
 * the communal hub grid.
 */
internal class DragAndDropTargetState(
    private val state: LazyGridState,
    private val contentListState: ContentListState,
    private val scope: CoroutineScope,
    private val autoScrollSpeed: MutableState<Float>,
    private val autoScrollThreshold: Float,
    private val updateDragPositionForRemove: (offset: Offset) -> Boolean,
) {
    /**
     * 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 isOnRemoveButton = false

    fun onStarted() {
        // assume item will be added to the end.
        contentListState.list.add(placeHolder)
        placeHolderIndex = contentListState.list.size - 1
    }

    fun onMoved(event: DragAndDropEvent) {
        val dragEvent = event.toAndroidDragEvent()
        isOnRemoveButton = updateDragPositionForRemove(Offset(dragEvent.x, dragEvent.y))
        if (!isOnRemoveButton) {
            findTargetItem(dragEvent)?.apply {
                var scrollIndex: Int? = null
                var scrollOffset: Int? = null
                if (placeHolderIndex == state.firstVisibleItemIndex) {
                    // Save info about the first item before the move, to neutralize the automatic
                    // keeping first item first.
                    scrollIndex = placeHolderIndex
                    scrollOffset = state.firstVisibleItemScrollOffset
                }

                autoScrollIfNearEdges(dragEvent)

                if (contentListState.isItemEditable(this.index)) {
                    movePlaceholderTo(this.index)
                    placeHolderIndex = this.index
                }

                if (scrollIndex != null && scrollOffset != null) {
                    // this is needed to neutralize automatic keeping the first item first.
                    scope.launch { state.scrollToItem(scrollIndex, scrollOffset) }
                }
            }
        }
    }

    fun onDrop(event: DragAndDropEvent): Boolean {
        autoScrollSpeed.value = 0f
        if (isOnRemoveButton) {
            return false
        }
        return placeHolderIndex?.let { dropIndex ->
            val componentName = event.maybeWidgetComponentName()
            if (componentName != null) {
                // Placeholder isn't removed yet to allow the setting the right priority for items
                // before adding in the new item.
                contentListState.onSaveList(
                    newItemComponentName = componentName,
                    newItemIndex = dropIndex
                )
                return@let true
            }
            return false
        }
            ?: false
    }

    fun onEnded() {
        autoScrollSpeed.value = 0f
        placeHolderIndex = null
        contentListState.list.remove(placeHolder)
        isOnRemoveButton = updateDragPositionForRemove(Offset.Zero)
    }

    private fun autoScrollIfNearEdges(dragEvent: DragEvent) {
        val orientation = state.layoutInfo.orientation
        val distanceFromStart =
            if (orientation == Orientation.Horizontal) {
                dragEvent.x
            } else {
                dragEvent.y
            }
        val distanceFromEnd =
            if (orientation == Orientation.Horizontal) {
                state.layoutInfo.viewportSize.width - dragEvent.x
            } else {
                state.layoutInfo.viewportSize.height - dragEvent.y
            }
        autoScrollSpeed.value =
            when {
                distanceFromEnd < autoScrollThreshold -> autoScrollThreshold - distanceFromEnd
                distanceFromStart < autoScrollThreshold ->
                    -(autoScrollThreshold - distanceFromStart)
                else -> 0f
            }
    }

    private fun findTargetItem(dragEvent: DragEvent): LazyGridItemInfo? =
        state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
            dragEvent.x.toInt() in item.offset.x..(item.offset + item.size).x &&
                dragEvent.y.toInt() in item.offset.y..(item.offset + item.size).y
        }

    private fun movePlaceholderTo(index: Int) {
        val currentIndex = contentListState.list.indexOf(placeHolder)
        if (currentIndex != index) {
            contentListState.onMove(currentIndex, index)
        }
    }

    /**
     * Parses and returns the component name of the widget that was dropped into the communal grid.
     *
     * Returns null if the drop event didn't include the widget information.
     */
    private fun DragAndDropEvent.maybeWidgetComponentName(): ComponentName? {
        val clipData = this.toAndroidDragEvent().clipData.takeIf { it.itemCount != 0 }
        return clipData
            ?.getItemAt(0)
            ?.intent
            ?.getParcelableExtra(Intent.EXTRA_COMPONENT_NAME, ComponentName::class.java)
    }
}
+3 −17
Original line number Diff line number Diff line
@@ -32,15 +32,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.zIndex
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.ui.compose.extensions.plus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
@@ -112,7 +110,7 @@ internal constructor(
            .firstOrNull { item ->
                // grid item offset is based off grid content container so we need to deduct
                // before content padding from the initial pointer position
                item.isEditable &&
                contentListState.isItemEditable(item.index) &&
                    (offset.x - contentOffset.x).toInt() in item.offset.x..item.offsetEnd.x &&
                    (offset.y - contentOffset.y).toInt() in item.offset.y..item.offsetEnd.y
            }
@@ -149,7 +147,7 @@ internal constructor(

        val targetItem =
            state.layoutInfo.visibleItemsInfo.find { item ->
                item.isEditable &&
                contentListState.isItemEditable(item.index) &&
                    middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x &&
                    middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y &&
                    draggingItem.index != item.index
@@ -187,10 +185,6 @@ internal constructor(
    private val LazyGridItemInfo.offsetEnd: IntOffset
        get() = this.offset + this.size

    /** Whether the grid item can be dragged or be a drop target. Only widget card is editable. */
    private val LazyGridItemInfo.isEditable: Boolean
        get() = contentListState.list[this.index] is CommunalContentModel.Widget

    /** Calculate the amount dragged out of bound on both sides. Returns 0f if not overscrolled */
    private fun checkForOverscroll(startOffset: Offset, endOffset: Offset): Float {
        return when {
@@ -210,14 +204,6 @@ internal constructor(
    }
}

private operator fun IntOffset.plus(size: IntSize): IntOffset {
    return IntOffset(x + size.width, y + size.height)
}

private operator fun Offset.plus(size: Size): Offset {
    return Offset(x + size.width, y + size.height)
}

fun Modifier.dragContainer(
    dragDropState: GridDragDropState,
    beforeContentPadding: ContentPaddingInPx
+32 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.communal.ui.compose.extensions

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize

/** Adds the given size to the x and y offsets in this [IntOffset] */
operator fun IntOffset.plus(size: IntSize): IntOffset {
    return IntOffset(x + size.width, y + size.height)
}

/** Adds the given size to the x and y offsets in this [Offset]. */
operator fun Offset.plus(size: Size): Offset {
    return Offset(x + size.width, y + size.height)
}
Loading