| /* |
| * 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 androidx.compose.foundation.ExperimentalFoundationApi |
| import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress |
| import androidx.compose.foundation.gestures.scrollBy |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.lazy.grid.LazyGridItemInfo |
| import androidx.compose.foundation.lazy.grid.LazyGridItemScope |
| import androidx.compose.foundation.lazy.grid.LazyGridState |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.LaunchedEffect |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| 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 kotlinx.coroutines.CoroutineScope |
| import kotlinx.coroutines.channels.Channel |
| import kotlinx.coroutines.launch |
| |
| @Composable |
| fun rememberGridDragDropState( |
| gridState: LazyGridState, |
| contentListState: ContentListState, |
| updateDragPositionForRemove: (offset: Offset) -> Boolean, |
| ): GridDragDropState { |
| val scope = rememberCoroutineScope() |
| val state = |
| remember(gridState, contentListState) { |
| GridDragDropState( |
| state = gridState, |
| contentListState = contentListState, |
| scope = scope, |
| updateDragPositionForRemove = updateDragPositionForRemove |
| ) |
| } |
| LaunchedEffect(state) { |
| while (true) { |
| val diff = state.scrollChannel.receive() |
| gridState.scrollBy(diff) |
| } |
| } |
| return state |
| } |
| |
| /** |
| * Handles drag and drop cards in the glanceable hub. While dragging to move, other items that are |
| * affected will dynamically get positioned and the state is tracked by [ContentListState]. When |
| * dragging to remove, affected cards will be moved and [updateDragPositionForRemove] is called to |
| * check whether the dragged item can be removed. On dragging ends, call [ContentListState.onRemove] |
| * 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, |
| private val updateDragPositionForRemove: (offset: Offset) -> Boolean |
| ) { |
| var draggingItemIndex by mutableStateOf<Int?>(null) |
| private set |
| |
| var isDraggingToRemove by mutableStateOf(false) |
| private set |
| |
| internal val scrollChannel = Channel<Float>() |
| |
| private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero) |
| private var draggingItemInitialOffset by mutableStateOf(Offset.Zero) |
| private var dragStartPointerOffset by mutableStateOf(Offset.Zero) |
| |
| internal val draggingItemOffset: Offset |
| get() = |
| draggingItemLayoutInfo?.let { item -> |
| draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset() |
| } |
| ?: Offset.Zero |
| |
| private val draggingItemLayoutInfo: LazyGridItemInfo? |
| get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } |
| |
| internal fun onDragStart(offset: Offset, contentOffset: Offset) { |
| state.layoutInfo.visibleItemsInfo |
| .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 && |
| (offset.x - contentOffset.x).toInt() in item.offset.x..item.offsetEnd.x && |
| (offset.y - contentOffset.y).toInt() in item.offset.y..item.offsetEnd.y |
| } |
| ?.apply { |
| dragStartPointerOffset = offset - this.offset.toOffset() |
| draggingItemIndex = index |
| draggingItemInitialOffset = this.offset.toOffset() |
| } |
| } |
| |
| internal fun onDragInterrupted() { |
| draggingItemIndex?.let { |
| if (isDraggingToRemove) { |
| contentListState.onRemove(it) |
| isDraggingToRemove = false |
| updateDragPositionForRemove(Offset.Zero) |
| } |
| // persist list editing changes on dragging ends |
| contentListState.onSaveList() |
| draggingItemIndex = null |
| } |
| draggingItemDraggedDelta = Offset.Zero |
| draggingItemInitialOffset = Offset.Zero |
| dragStartPointerOffset = Offset.Zero |
| } |
| |
| internal fun onDrag(offset: Offset) { |
| draggingItemDraggedDelta += offset |
| |
| val draggingItem = draggingItemLayoutInfo ?: return |
| val startOffset = draggingItem.offset.toOffset() + draggingItemOffset |
| val endOffset = startOffset + draggingItem.size.toSize() |
| val middleOffset = startOffset + (endOffset - startOffset) / 2f |
| |
| val targetItem = |
| state.layoutInfo.visibleItemsInfo.find { item -> |
| item.isEditable && |
| middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x && |
| middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y && |
| draggingItem.index != item.index |
| } |
| |
| if (targetItem != null) { |
| val scrollToIndex = |
| if (targetItem.index == state.firstVisibleItemIndex) { |
| draggingItem.index |
| } else if (draggingItem.index == state.firstVisibleItemIndex) { |
| targetItem.index |
| } else { |
| null |
| } |
| if (scrollToIndex != null) { |
| scope.launch { |
| // this is needed to neutralize automatic keeping the first item first. |
| state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) |
| contentListState.onMove(draggingItem.index, targetItem.index) |
| } |
| } else { |
| contentListState.onMove(draggingItem.index, targetItem.index) |
| } |
| draggingItemIndex = targetItem.index |
| isDraggingToRemove = false |
| } else { |
| val overscroll = checkForOverscroll(startOffset, endOffset) |
| if (overscroll != 0f) { |
| scrollChannel.trySend(overscroll) |
| } |
| isDraggingToRemove = checkForRemove(startOffset) |
| } |
| } |
| |
| 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 { |
| draggingItemDraggedDelta.x > 0 -> |
| (endOffset.x - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) |
| draggingItemDraggedDelta.x < 0 -> |
| (startOffset.x - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) |
| else -> 0f |
| } |
| } |
| |
| /** Calls the callback with the updated drag position and returns whether to remove the item. */ |
| private fun checkForRemove(startOffset: Offset): Boolean { |
| return if (draggingItemDraggedDelta.y < 0) |
| updateDragPositionForRemove(startOffset + dragStartPointerOffset) |
| else false |
| } |
| } |
| |
| 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 |
| ): Modifier { |
| return pointerInput(dragDropState, beforeContentPadding) { |
| detectDragGesturesAfterLongPress( |
| onDrag = { change, offset -> |
| change.consume() |
| dragDropState.onDrag(offset = offset) |
| }, |
| onDragStart = { offset -> |
| dragDropState.onDragStart( |
| offset, |
| Offset(beforeContentPadding.startPadding, beforeContentPadding.topPadding) |
| ) |
| }, |
| onDragEnd = { dragDropState.onDragInterrupted() }, |
| onDragCancel = { dragDropState.onDragInterrupted() } |
| ) |
| } |
| } |
| |
| /** Wrap LazyGrid item with additional modifier needed for drag and drop. */ |
| @ExperimentalFoundationApi |
| @Composable |
| fun LazyGridItemScope.DraggableItem( |
| dragDropState: GridDragDropState, |
| index: Int, |
| enabled: Boolean, |
| modifier: Modifier = Modifier, |
| content: @Composable (isDragging: Boolean) -> Unit |
| ) { |
| if (!enabled) { |
| return Box(modifier = modifier) { content(false) } |
| } |
| val dragging = index == dragDropState.draggingItemIndex |
| val draggingModifier = |
| if (dragging) { |
| Modifier.zIndex(1f).graphicsLayer { |
| translationX = dragDropState.draggingItemOffset.x |
| translationY = dragDropState.draggingItemOffset.y |
| alpha = if (dragDropState.isDraggingToRemove) 0.5f else 1f |
| } |
| } else { |
| Modifier.animateItemPlacement() |
| } |
| Box(modifier = modifier.then(draggingModifier), propagateMinConstraints = true) { |
| content(dragging) |
| } |
| } |