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 android.content.ClipDescription
20 import android.view.DragEvent
21 import androidx.compose.animation.core.tween
22 import androidx.compose.foundation.draganddrop.dragAndDropTarget
23 import androidx.compose.foundation.gestures.Orientation
24 import androidx.compose.foundation.gestures.animateScrollBy
25 import androidx.compose.foundation.gestures.scrollBy
26 import androidx.compose.foundation.lazy.grid.LazyGridState
27 import androidx.compose.runtime.Composable
28 import androidx.compose.runtime.LaunchedEffect
29 import androidx.compose.runtime.getValue
30 import androidx.compose.runtime.remember
31 import androidx.compose.runtime.rememberCoroutineScope
32 import androidx.compose.runtime.rememberUpdatedState
33 import androidx.compose.ui.Modifier
34 import androidx.compose.ui.draganddrop.DragAndDropEvent
35 import androidx.compose.ui.draganddrop.DragAndDropTarget
36 import androidx.compose.ui.draganddrop.mimeTypes
37 import androidx.compose.ui.draganddrop.toAndroidDragEvent
38 import androidx.compose.ui.geometry.Offset
39 import androidx.compose.ui.platform.LocalDensity
40 import androidx.compose.ui.unit.dp
41 import com.android.systemui.Flags.communalWidgetResizing
42 import com.android.systemui.Flags.glanceableHubV2
43 import com.android.systemui.communal.domain.model.CommunalContentModel
44 import com.android.systemui.communal.ui.compose.extensions.firstItemAtOffset
45 import com.android.systemui.communal.util.WidgetPickerIntentUtils
46 import com.android.systemui.communal.util.WidgetPickerIntentUtils.getWidgetExtraFromIntent
47 import kotlinx.coroutines.CoroutineScope
48 import kotlinx.coroutines.channels.Channel
49 import kotlinx.coroutines.launch
50
51 /**
52 * Holds state associated with dragging and dropping items from other activities into the lazy grid.
53 *
54 * @see dragAndDropTarget
55 */
56 @Composable
57 fun rememberDragAndDropTargetState(
58 gridState: LazyGridState,
59 contentOffset: Offset,
60 contentListState: ContentListState,
61 ): DragAndDropTargetState {
62 val scope = rememberCoroutineScope()
63 val autoScrollThreshold = with(LocalDensity.current) { 60.dp.toPx() }
64
65 val state =
66 remember(gridState, contentOffset, contentListState, autoScrollThreshold, scope) {
67 DragAndDropTargetState(
68 state = gridState,
69 contentOffset = contentOffset,
70 contentListState = contentListState,
71 autoScrollThreshold = autoScrollThreshold,
72 scope = scope,
73 )
74 }
75
76 LaunchedEffect(state) { state.processScrollRequests(scope) }
77
78 return state
79 }
80
81 /**
82 * Attaches a listener for drag and drop events from other activities.
83 *
84 * @see androidx.compose.foundation.draganddrop.dragAndDropTarget
85 * @see DragEvent
86 */
87 @Composable
dragAndDropTargetnull88 fun Modifier.dragAndDropTarget(dragDropTargetState: DragAndDropTargetState): Modifier {
89 val state by rememberUpdatedState(dragDropTargetState)
90
91 return this then
92 Modifier.dragAndDropTarget(
93 shouldStartDragAndDrop = accept@{ startEvent ->
94 startEvent.mimeTypes().any { it == ClipDescription.MIMETYPE_TEXT_INTENT }
95 },
96 target =
97 object : DragAndDropTarget {
98 override fun onStarted(event: DragAndDropEvent) {
99 state.onStarted()
100 }
101
102 override fun onMoved(event: DragAndDropEvent) {
103 state.onMoved(event)
104 }
105
106 override fun onDrop(event: DragAndDropEvent): Boolean {
107 return state.onDrop(event)
108 }
109
110 override fun onExited(event: DragAndDropEvent) {
111 state.onExited()
112 }
113
114 override fun onEnded(event: DragAndDropEvent) {
115 state.onEnded()
116 }
117 },
118 )
119 }
120
121 /**
122 * Handles dropping of an item coming from a different activity (e.g. widget picker) in to the grid
123 * corresponding to the provided [LazyGridState].
124 *
125 * Adds a placeholder container to highlight the anticipated location the widget will be dropped to.
126 * When the item is held over an empty area, the placeholder appears at the end of the grid if one
127 * didn't exist already. As user moves the item over an existing item, the placeholder appears in
128 * place of that existing item. And then, the existing item is pushed over as part of re-ordering.
129 *
130 * Once item is dropped, new ordering along with the dropped item is persisted. See
131 * [ContentListState.onSaveList].
132 *
133 * Difference between this and [GridDragDropState] is that, this is used for listening to drops from
134 * other activities. [GridDragDropState] on the other hand, handles dragging of existing items in
135 * the communal hub grid.
136 */
137 class DragAndDropTargetState(
138 state: LazyGridState,
139 contentOffset: Offset,
140 contentListState: ContentListState,
141 autoScrollThreshold: Float,
142 scope: CoroutineScope,
143 ) {
144 private val dragDropState: DragAndDropTargetStateInternal =
145 if (glanceableHubV2()) {
146 DragAndDropTargetStateV2(
147 state = state,
148 contentListState = contentListState,
149 scope = scope,
150 autoScrollThreshold = autoScrollThreshold,
151 contentOffset = contentOffset,
152 )
153 } else {
154 DragAndDropTargetStateV1(
155 state = state,
156 contentListState = contentListState,
157 scope = scope,
158 autoScrollThreshold = autoScrollThreshold,
159 contentOffset = contentOffset,
160 )
161 }
162
onStartednull163 fun onStarted() = dragDropState.onStarted()
164
165 fun onMoved(event: DragAndDropEvent) = dragDropState.onMoved(event)
166
167 fun onDrop(event: DragAndDropEvent) = dragDropState.onDrop(event)
168
169 fun onEnded() = dragDropState.onEnded()
170
171 fun onExited() = dragDropState.onExited()
172
173 suspend fun processScrollRequests(coroutineScope: CoroutineScope) =
174 dragDropState.processScrollRequests(coroutineScope)
175 }
176
177 /**
178 * A private interface defining the API for handling drag-and-drop operations. There will be two
179 * implementations of this interface: V1 for devices that do not have the glanceable_hub_v2 flag
180 * enabled, and V2 for devices that do have that flag enabled.
181 *
182 * TODO(b/400789179): Remove this interface and the V1 implementation once glanceable_hub_v2 has
183 * shipped.
184 */
185 private interface DragAndDropTargetStateInternal {
186 fun onStarted() = Unit
187
188 fun onMoved(event: DragAndDropEvent) = Unit
189
190 fun onDrop(event: DragAndDropEvent): Boolean = false
191
192 fun onEnded() = Unit
193
194 fun onExited() = Unit
195
196 suspend fun processScrollRequests(coroutineScope: CoroutineScope) = Unit
197 }
198
199 /**
200 * The V1 implementation of DragAndDropTargetStateInternal to be used when the glanceable_hub_v2
201 * flag is disabled.
202 */
203 private class DragAndDropTargetStateV1(
204 private val state: LazyGridState,
205 private val contentOffset: Offset,
206 private val contentListState: ContentListState,
207 private val autoScrollThreshold: Float,
208 private val scope: CoroutineScope,
209 ) : DragAndDropTargetStateInternal {
210 /**
211 * The placeholder item that is treated as if it is being dragged across the grid. It is added
212 * to grid once drag and drop event is started and removed when event ends.
213 */
214 private var placeHolder = CommunalContentModel.WidgetPlaceholder()
215 private var placeHolderIndex: Int? = null
216 private var previousTargetItemKey: Any? = null
217
218 private val scrollChannel = Channel<Float>()
219
processScrollRequestsnull220 override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
221 for (diff in scrollChannel) {
222 state.scrollBy(diff)
223 }
224 }
225
onStartednull226 override fun onStarted() {
227 // assume item will be added to the end.
228 contentListState.list.add(placeHolder)
229 placeHolderIndex = contentListState.list.size - 1
230 }
231
onMovednull232 override fun onMoved(event: DragAndDropEvent) {
233 val dragOffset = event.toOffset()
234
235 val targetItem =
236 state.layoutInfo.visibleItemsInfo
237 .asSequence()
238 .filter { item -> contentListState.isItemEditable(item.index) }
239 .firstItemAtOffset(dragOffset - contentOffset)
240
241 if (
242 targetItem != null &&
243 (!communalWidgetResizing() || targetItem.key != previousTargetItemKey)
244 ) {
245 if (communalWidgetResizing()) {
246 // Keep track of the previous target item, to avoid rapidly oscillating between
247 // items if the target item doesn't visually move as a result of the index change.
248 // In this case, even after the index changes, we'd still be colliding with the
249 // element, so it would be selected as the target item the next time this function
250 // runs again, which would trigger us to revert the index change we recently made.
251 previousTargetItemKey = targetItem.key
252 }
253
254 var scrollIndex: Int? = null
255 var scrollOffset: Int? = null
256 if (placeHolderIndex == state.firstVisibleItemIndex) {
257 // Save info about the first item before the move, to neutralize the automatic
258 // keeping first item first.
259 scrollIndex = placeHolderIndex
260 scrollOffset = state.firstVisibleItemScrollOffset
261 }
262
263 if (contentListState.isItemEditable(targetItem.index)) {
264 movePlaceholderTo(targetItem.index)
265 placeHolderIndex = targetItem.index
266 }
267
268 if (scrollIndex != null && scrollOffset != null) {
269 // this is needed to neutralize automatic keeping the first item first.
270 scope.launch { state.scrollToItem(scrollIndex, scrollOffset) }
271 }
272 } else if (targetItem == null) {
273 computeAutoscroll(dragOffset).takeIf { it != 0f }?.let { scrollChannel.trySend(it) }
274 previousTargetItemKey = null
275 }
276 }
277
onDropnull278 override fun onDrop(event: DragAndDropEvent): Boolean {
279 return placeHolderIndex?.let { dropIndex ->
280 val widgetExtra = event.maybeWidgetExtra() ?: return false
281 val (componentName, user) = widgetExtra
282 if (componentName != null && user != null) {
283 // Placeholder isn't removed yet to allow the setting the right rank for items
284 // before adding in the new item.
285 contentListState.onSaveList(
286 newItemComponentName = componentName,
287 newItemUser = user,
288 newItemIndex = dropIndex,
289 )
290 return@let true
291 }
292 return false
293 } ?: false
294 }
295
onEndednull296 override fun onEnded() {
297 placeHolderIndex = null
298 previousTargetItemKey = null
299 contentListState.list.remove(placeHolder)
300 }
301
onExitednull302 override fun onExited() {
303 onEnded()
304 }
305
computeAutoscrollnull306 private fun computeAutoscroll(dragOffset: Offset): Float {
307 val orientation = state.layoutInfo.orientation
308 val distanceFromStart =
309 if (orientation == Orientation.Horizontal) {
310 dragOffset.x
311 } else {
312 dragOffset.y
313 }
314 val distanceFromEnd =
315 if (orientation == Orientation.Horizontal) {
316 state.layoutInfo.viewportEndOffset - dragOffset.x
317 } else {
318 state.layoutInfo.viewportEndOffset - dragOffset.y
319 }
320
321 return when {
322 distanceFromEnd < autoScrollThreshold -> autoScrollThreshold - distanceFromEnd
323 distanceFromStart < autoScrollThreshold -> distanceFromStart - autoScrollThreshold
324 else -> 0f
325 }
326 }
327
movePlaceholderTonull328 private fun movePlaceholderTo(index: Int) {
329 val currentIndex = contentListState.list.indexOf(placeHolder)
330 if (currentIndex != index) {
331 contentListState.onMove(currentIndex, index)
332 }
333 }
334 }
335
336 /**
337 * The V2 implementation of DragAndDropTargetStateInternal to be used when the glanceable_hub_v2
338 * flag is enabled.
339 */
340 private class DragAndDropTargetStateV2(
341 private val state: LazyGridState,
342 private val contentOffset: Offset,
343 private val contentListState: ContentListState,
344 private val autoScrollThreshold: Float,
345 private val scope: CoroutineScope,
346 ) : DragAndDropTargetStateInternal {
347 /**
348 * The placeholder item that is treated as if it is being dragged across the grid. It is added
349 * to grid once drag and drop event is started and removed when event ends.
350 */
351 private var placeHolder = CommunalContentModel.WidgetPlaceholder()
352 private var placeHolderIndex: Int? = null
353 private var previousTargetItemKey: Any? = null
354 private var dragOffset = Offset.Zero
355 private var columnWidth = 0
356
357 private val scrollChannel = Channel<Float>()
358
processScrollRequestsnull359 override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
360 while (true) {
361 val amount = scrollChannel.receive()
362
363 if (state.isScrollInProgress) {
364 // Ignore overscrolling if a scroll is already in progress (but we still want to
365 // consume the scroll event so that we don't end up processing a bunch of old
366 // events after scrolling has finished).
367 continue
368 }
369
370 // Perform the rest of the drag operation after scrolling has finished (or immediately
371 // if there will be no scrolling).
372 if (amount != 0f) {
373 scope.launch {
374 state.animateScrollBy(amount, tween(delayMillis = 250, durationMillis = 1000))
375 performDragAction()
376 }
377 } else {
378 performDragAction()
379 }
380 }
381 }
382
onStartednull383 override fun onStarted() {
384 // assume item will be added to the end.
385 contentListState.list.add(placeHolder)
386 placeHolderIndex = contentListState.list.size - 1
387
388 // Use the width of the first item as the column width.
389 columnWidth =
390 state.layoutInfo.visibleItemsInfo.first().size.width +
391 state.layoutInfo.beforeContentPadding +
392 state.layoutInfo.afterContentPadding
393 }
394
onMovednull395 override fun onMoved(event: DragAndDropEvent) {
396 dragOffset = event.toOffset()
397 scrollChannel.trySend(computeAutoscroll(dragOffset))
398 }
399
onDropnull400 override fun onDrop(event: DragAndDropEvent): Boolean {
401 return placeHolderIndex?.let { dropIndex ->
402 val widgetExtra = event.maybeWidgetExtra() ?: return false
403 val (componentName, user) = widgetExtra
404 if (componentName != null && user != null) {
405 // Placeholder isn't removed yet to allow the setting the right rank for items
406 // before adding in the new item.
407 contentListState.onSaveList(
408 newItemComponentName = componentName,
409 newItemUser = user,
410 newItemIndex = dropIndex,
411 )
412 return@let true
413 }
414 return false
415 } ?: false
416 }
417
onEndednull418 override fun onEnded() {
419 placeHolderIndex = null
420 previousTargetItemKey = null
421 contentListState.list.remove(placeHolder)
422 }
423
onExitednull424 override fun onExited() {
425 onEnded()
426 }
427
performDragActionnull428 private fun performDragAction() {
429 val targetItem =
430 state.layoutInfo.visibleItemsInfo
431 .asSequence()
432 .filter { item -> contentListState.isItemEditable(item.index) }
433 .firstItemAtOffset(dragOffset - contentOffset)
434
435 if (
436 targetItem != null &&
437 (!communalWidgetResizing() || targetItem.key != previousTargetItemKey)
438 ) {
439 if (communalWidgetResizing()) {
440 // Keep track of the previous target item, to avoid rapidly oscillating between
441 // items if the target item doesn't visually move as a result of the index change.
442 // In this case, even after the index changes, we'd still be colliding with the
443 // element, so it would be selected as the target item the next time this function
444 // runs again, which would trigger us to revert the index change we recently made.
445 previousTargetItemKey = targetItem.key
446 }
447
448 val scrollToIndex =
449 if (targetItem.index == state.firstVisibleItemIndex) {
450 placeHolderIndex
451 } else if (placeHolderIndex == state.firstVisibleItemIndex) {
452 targetItem.index
453 } else {
454 null
455 }
456
457 if (scrollToIndex != null) {
458 scope.launch {
459 state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
460 movePlaceholderTo(targetItem.index)
461 }
462 } else {
463 movePlaceholderTo(targetItem.index)
464 }
465
466 placeHolderIndex = targetItem.index
467 } else if (targetItem == null) {
468 previousTargetItemKey = null
469 }
470 }
471
computeAutoscrollnull472 private fun computeAutoscroll(dragOffset: Offset): Float {
473 val orientation = state.layoutInfo.orientation
474 val distanceFromStart =
475 if (orientation == Orientation.Horizontal) {
476 dragOffset.x
477 } else {
478 dragOffset.y
479 }
480 val distanceFromEnd =
481 if (orientation == Orientation.Horizontal) {
482 state.layoutInfo.viewportEndOffset - dragOffset.x
483 } else {
484 state.layoutInfo.viewportEndOffset - dragOffset.y
485 }
486
487 return when {
488 distanceFromEnd < autoScrollThreshold -> {
489 (columnWidth - state.layoutInfo.beforeContentPadding).toFloat()
490 }
491 distanceFromStart < autoScrollThreshold -> {
492 -(columnWidth - state.layoutInfo.afterContentPadding).toFloat()
493 }
494 else -> 0f
495 }
496 }
497
movePlaceholderTonull498 private fun movePlaceholderTo(index: Int) {
499 val currentIndex = contentListState.list.indexOf(placeHolder)
500 if (currentIndex != index) {
501 contentListState.swapItems(currentIndex, index)
502 }
503 }
504 }
505
506 /**
507 * Parses and returns the intent extra associated with the widget that is dropped into the grid.
508 *
509 * Returns null if the drop event didn't include intent information.
510 */
DragAndDropEventnull511 private fun DragAndDropEvent.maybeWidgetExtra(): WidgetPickerIntentUtils.WidgetExtra? {
512 val clipData = this.toAndroidDragEvent().clipData.takeIf { it.itemCount != 0 }
513 return clipData?.getItemAt(0)?.intent?.let { intent -> getWidgetExtraFromIntent(intent) }
514 }
515
<lambda>null516 private fun DragAndDropEvent.toOffset() = this.toAndroidDragEvent().run { Offset(x, y) }
517