1 /*
<lambda>null2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.communal.ui.compose
18 
19 import androidx.compose.animation.AnimatedVisibility
20 import androidx.compose.animation.core.animateFloatAsState
21 import androidx.compose.animation.fadeIn
22 import androidx.compose.animation.fadeOut
23 import androidx.compose.foundation.ExperimentalFoundationApi
24 import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
25 import androidx.compose.foundation.gestures.scrollBy
26 import androidx.compose.foundation.layout.Box
27 import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
28 import androidx.compose.foundation.lazy.grid.LazyGridItemScope
29 import androidx.compose.foundation.lazy.grid.LazyGridState
30 import androidx.compose.runtime.Composable
31 import androidx.compose.runtime.LaunchedEffect
32 import androidx.compose.runtime.getValue
33 import androidx.compose.runtime.mutableStateOf
34 import androidx.compose.runtime.remember
35 import androidx.compose.runtime.rememberCoroutineScope
36 import androidx.compose.runtime.setValue
37 import androidx.compose.ui.ExperimentalComposeUiApi
38 import androidx.compose.ui.Modifier
39 import androidx.compose.ui.geometry.Offset
40 import androidx.compose.ui.graphics.graphicsLayer
41 import androidx.compose.ui.input.pointer.pointerInput
42 import androidx.compose.ui.input.pointer.pointerInteropFilter
43 import androidx.compose.ui.semantics.clearAndSetSemantics
44 import androidx.compose.ui.unit.IntOffset
45 import androidx.compose.ui.unit.toOffset
46 import androidx.compose.ui.unit.toSize
47 import com.android.systemui.communal.ui.compose.extensions.firstItemAtOffset
48 import com.android.systemui.communal.ui.compose.extensions.plus
49 import com.android.systemui.communal.ui.viewmodel.BaseCommunalViewModel
50 import kotlinx.coroutines.CoroutineScope
51 import kotlinx.coroutines.channels.Channel
52 import kotlinx.coroutines.launch
53 
54 @Composable
55 fun rememberGridDragDropState(
56     gridState: LazyGridState,
57     contentListState: ContentListState,
58     updateDragPositionForRemove: (offset: Offset) -> Boolean,
59 ): GridDragDropState {
60     val scope = rememberCoroutineScope()
61     val state =
62         remember(gridState, contentListState) {
63             GridDragDropState(
64                 state = gridState,
65                 contentListState = contentListState,
66                 scope = scope,
67                 updateDragPositionForRemove = updateDragPositionForRemove
68             )
69         }
70     LaunchedEffect(state) {
71         while (true) {
72             val diff = state.scrollChannel.receive()
73             gridState.scrollBy(diff)
74         }
75     }
76     return state
77 }
78 
79 /**
80  * Handles drag and drop cards in the glanceable hub. While dragging to move, other items that are
81  * affected will dynamically get positioned and the state is tracked by [ContentListState]. When
82  * dragging to remove, affected cards will be moved and [updateDragPositionForRemove] is called to
83  * check whether the dragged item can be removed. On dragging ends, call [ContentListState.onRemove]
84  * to remove the dragged item if condition met and call [ContentListState.onSaveList] to persist any
85  * change in ordering.
86  */
87 class GridDragDropState
88 internal constructor(
89     private val state: LazyGridState,
90     private val contentListState: ContentListState,
91     private val scope: CoroutineScope,
92     private val updateDragPositionForRemove: (offset: Offset) -> Boolean
93 ) {
94     var draggingItemIndex by mutableStateOf<Int?>(null)
95         private set
96 
97     var isDraggingToRemove by mutableStateOf(false)
98         private set
99 
100     internal val scrollChannel = Channel<Float>()
101 
102     private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
103     private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
104     private var dragStartPointerOffset by mutableStateOf(Offset.Zero)
105 
106     internal val draggingItemOffset: Offset
107         get() =
itemnull108             draggingItemLayoutInfo?.let { item ->
109                 draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
110             }
111                 ?: Offset.Zero
112 
113     private val draggingItemLayoutInfo: LazyGridItemInfo?
<lambda>null114         get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
115 
onDragStartnull116     internal fun onDragStart(offset: Offset, contentOffset: Offset) {
117         state.layoutInfo.visibleItemsInfo
118             .filter { item -> contentListState.isItemEditable(item.index) }
119             // grid item offset is based off grid content container so we need to deduct
120             // before content padding from the initial pointer position
121             .firstItemAtOffset(offset - contentOffset)
122             ?.apply {
123                 dragStartPointerOffset = offset - this.offset.toOffset()
124                 draggingItemIndex = index
125                 draggingItemInitialOffset = this.offset.toOffset()
126             }
127     }
128 
onDragInterruptednull129     internal fun onDragInterrupted() {
130         draggingItemIndex?.let {
131             if (isDraggingToRemove) {
132                 contentListState.onRemove(it)
133                 isDraggingToRemove = false
134                 updateDragPositionForRemove(Offset.Zero)
135             }
136             // persist list editing changes on dragging ends
137             contentListState.onSaveList()
138             draggingItemIndex = null
139         }
140         draggingItemDraggedDelta = Offset.Zero
141         draggingItemInitialOffset = Offset.Zero
142         dragStartPointerOffset = Offset.Zero
143     }
144 
onDragnull145     internal fun onDrag(offset: Offset) {
146         draggingItemDraggedDelta += offset
147 
148         val draggingItem = draggingItemLayoutInfo ?: return
149         val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
150         val endOffset = startOffset + draggingItem.size.toSize()
151         val middleOffset = startOffset + (endOffset - startOffset) / 2f
152 
153         val targetItem =
154             state.layoutInfo.visibleItemsInfo
155                 .asSequence()
156                 .filter { item -> contentListState.isItemEditable(item.index) }
157                 .filter { item -> draggingItem.index != item.index }
158                 .firstItemAtOffset(middleOffset)
159 
160         if (targetItem != null) {
161             val scrollToIndex =
162                 if (targetItem.index == state.firstVisibleItemIndex) {
163                     draggingItem.index
164                 } else if (draggingItem.index == state.firstVisibleItemIndex) {
165                     targetItem.index
166                 } else {
167                     null
168                 }
169             if (scrollToIndex != null) {
170                 scope.launch {
171                     // this is needed to neutralize automatic keeping the first item first.
172                     state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
173                     contentListState.onMove(draggingItem.index, targetItem.index)
174                 }
175             } else {
176                 contentListState.onMove(draggingItem.index, targetItem.index)
177             }
178             draggingItemIndex = targetItem.index
179             isDraggingToRemove = false
180         } else {
181             val overscroll = checkForOverscroll(startOffset, endOffset)
182             if (overscroll != 0f) {
183                 scrollChannel.trySend(overscroll)
184             }
185             isDraggingToRemove = checkForRemove(startOffset)
186         }
187     }
188 
189     private val LazyGridItemInfo.offsetEnd: IntOffset
190         get() = this.offset + this.size
191 
192     /** Calculate the amount dragged out of bound on both sides. Returns 0f if not overscrolled */
checkForOverscrollnull193     private fun checkForOverscroll(startOffset: Offset, endOffset: Offset): Float {
194         return when {
195             draggingItemDraggedDelta.x > 0 ->
196                 (endOffset.x - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
197             draggingItemDraggedDelta.x < 0 ->
198                 (startOffset.x - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
199             else -> 0f
200         }
201     }
202 
203     /** Calls the callback with the updated drag position and returns whether to remove the item. */
checkForRemovenull204     private fun checkForRemove(startOffset: Offset): Boolean {
205         return if (draggingItemDraggedDelta.y < 0)
206             updateDragPositionForRemove(startOffset + dragStartPointerOffset)
207         else false
208     }
209 }
210 
dragContainernull211 fun Modifier.dragContainer(
212     dragDropState: GridDragDropState,
213     contentOffset: Offset,
214     viewModel: BaseCommunalViewModel,
215 ): Modifier {
216     return this.then(
217         pointerInput(dragDropState, contentOffset) {
218             detectDragGesturesAfterLongPress(
219                 onDrag = { change, offset ->
220                     change.consume()
221                     dragDropState.onDrag(offset = offset)
222                 },
223                 onDragStart = { offset ->
224                     dragDropState.onDragStart(offset, contentOffset)
225                     viewModel.onReorderWidgetStart()
226                 },
227                 onDragEnd = {
228                     dragDropState.onDragInterrupted()
229                     viewModel.onReorderWidgetEnd()
230                 },
231                 onDragCancel = {
232                     dragDropState.onDragInterrupted()
233                     viewModel.onReorderWidgetCancel()
234                 }
235             )
236         }
237     )
238 }
239 
240 /** Wrap LazyGrid item with additional modifier needed for drag and drop. */
241 @OptIn(ExperimentalComposeUiApi::class)
242 @ExperimentalFoundationApi
243 @Composable
DraggableItemnull244 fun LazyGridItemScope.DraggableItem(
245     dragDropState: GridDragDropState,
246     index: Int,
247     enabled: Boolean,
248     selected: Boolean,
249     modifier: Modifier = Modifier,
250     content: @Composable (isDragging: Boolean) -> Unit
251 ) {
252     if (!enabled) {
253         return content(false)
254     }
255 
256     val dragging = index == dragDropState.draggingItemIndex
257     val itemAlpha: Float by
258         animateFloatAsState(
259             targetValue = if (dragDropState.isDraggingToRemove) 0.5f else 1f,
260             label = "DraggableItemAlpha"
261         )
262     val draggingModifier =
263         if (dragging) {
264             Modifier.graphicsLayer {
265                 translationX = dragDropState.draggingItemOffset.x
266                 translationY = dragDropState.draggingItemOffset.y
267                 alpha = itemAlpha
268             }
269         } else {
270             Modifier.animateItemPlacement()
271         }
272 
273     Box(modifier) {
274         Box(draggingModifier) { content(dragging) }
275         AnimatedVisibility(
276             modifier =
277                 Modifier.matchParentSize()
278                     // Avoid taking focus away from the content when using explore-by-touch with
279                     // accessibility tools.
280                     .clearAndSetSemantics {}
281                     // Do not consume motion events in the highlighted item and pass them down to
282                     // the content.
283                     .pointerInteropFilter { false },
284             visible = (dragging || selected) && !dragDropState.isDraggingToRemove,
285             enter = fadeIn(),
286             exit = fadeOut()
287         ) {
288             HighlightedItem(Modifier.matchParentSize())
289         }
290     }
291 }
292