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