• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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