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.foundation.ExperimentalFoundationApi
22 import androidx.compose.foundation.draganddrop.dragAndDropTarget
23 import androidx.compose.foundation.gestures.Orientation
24 import androidx.compose.foundation.gestures.scrollBy
25 import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
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.MutableState
30 import androidx.compose.runtime.getValue
31 import androidx.compose.runtime.mutableFloatStateOf
32 import androidx.compose.runtime.remember
33 import androidx.compose.runtime.rememberCoroutineScope
34 import androidx.compose.runtime.rememberUpdatedState
35 import androidx.compose.ui.Modifier
36 import androidx.compose.ui.draganddrop.DragAndDropEvent
37 import androidx.compose.ui.draganddrop.DragAndDropTarget
38 import androidx.compose.ui.draganddrop.mimeTypes
39 import androidx.compose.ui.draganddrop.toAndroidDragEvent
40 import androidx.compose.ui.geometry.Offset
41 import androidx.compose.ui.platform.LocalDensity
42 import androidx.compose.ui.unit.dp
43 import com.android.systemui.communal.domain.model.CommunalContentModel
44 import com.android.systemui.communal.ui.compose.extensions.plus
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.delay
49 import kotlinx.coroutines.isActive
50 import kotlinx.coroutines.launch
51 
52 /**
53  * Holds state associated with dragging and dropping items from other activities into the lazy grid.
54  *
55  * @see dragAndDropTarget
56  */
57 @Composable
58 internal fun rememberDragAndDropTargetState(
59     gridState: LazyGridState,
60     contentListState: ContentListState,
61     updateDragPositionForRemove: (offset: Offset) -> Boolean,
62 ): DragAndDropTargetState {
63     val scope = rememberCoroutineScope()
64     val autoScrollSpeed = remember { mutableFloatStateOf(0f) }
65     // Threshold of distance from edges that should start auto-scroll - chosen to be a narrow value
66     // that allows differentiating intention of scrolling from intention of dragging over the first
67     // visible item.
68     val autoScrollThreshold = with(LocalDensity.current) { 60.dp.toPx() }
69     val state =
70         remember(gridState, contentListState) {
71             DragAndDropTargetState(
72                 state = gridState,
73                 contentListState = contentListState,
74                 scope = scope,
75                 autoScrollSpeed = autoScrollSpeed,
76                 autoScrollThreshold = autoScrollThreshold,
77                 updateDragPositionForRemove = updateDragPositionForRemove,
78             )
79         }
80     LaunchedEffect(autoScrollSpeed.floatValue) {
81         if (autoScrollSpeed.floatValue != 0f) {
82             while (isActive) {
83                 gridState.scrollBy(autoScrollSpeed.floatValue)
84                 delay(10)
85             }
86         }
87     }
88     return state
89 }
90 
91 /**
92  * Attaches a listener for drag and drop events from other activities.
93  *
94  * @see androidx.compose.foundation.draganddrop.dragAndDropTarget
95  * @see DragEvent
96  */
97 @OptIn(ExperimentalFoundationApi::class)
98 @Composable
dragAndDropTargetnull99 internal fun Modifier.dragAndDropTarget(
100     dragDropTargetState: DragAndDropTargetState,
101 ): Modifier {
102     val state by rememberUpdatedState(dragDropTargetState)
103 
104     return this then
105         Modifier.dragAndDropTarget(
106             shouldStartDragAndDrop = accept@{ startEvent ->
107                     startEvent.mimeTypes().any { it == ClipDescription.MIMETYPE_TEXT_INTENT }
108                 },
109             target =
110                 object : DragAndDropTarget {
111                     override fun onStarted(event: DragAndDropEvent) {
112                         state.onStarted()
113                     }
114 
115                     override fun onMoved(event: DragAndDropEvent) {
116                         state.onMoved(event)
117                     }
118 
119                     override fun onDrop(event: DragAndDropEvent): Boolean {
120                         return state.onDrop(event)
121                     }
122 
123                     override fun onEnded(event: DragAndDropEvent) {
124                         state.onEnded()
125                     }
126                 }
127         )
128 }
129 
130 /**
131  * Handles dropping of an item coming from a different activity (e.g. widget picker) in to the grid
132  * corresponding to the provided [LazyGridState].
133  *
134  * Adds a placeholder container to highlight the anticipated location the widget will be dropped to.
135  * When the item is held over an empty area, the placeholder appears at the end of the grid if one
136  * didn't exist already. As user moves the item over an existing item, the placeholder appears in
137  * place of that existing item. And then, the existing item is pushed over as part of re-ordering.
138  *
139  * Once item is dropped, new ordering along with the dropped item is persisted. See
140  * [ContentListState.onSaveList].
141  *
142  * Difference between this and [GridDragDropState] is that, this is used for listening to drops from
143  * other activities. [GridDragDropState] on the other hand, handles dragging of existing items in
144  * the communal hub grid.
145  */
146 internal class DragAndDropTargetState(
147     private val state: LazyGridState,
148     private val contentListState: ContentListState,
149     private val scope: CoroutineScope,
150     private val autoScrollSpeed: MutableState<Float>,
151     private val autoScrollThreshold: Float,
152     private val updateDragPositionForRemove: (offset: Offset) -> Boolean,
153 ) {
154     /**
155      * The placeholder item that is treated as if it is being dragged across the grid. It is added
156      * to grid once drag and drop event is started and removed when event ends.
157      */
158     private var placeHolder = CommunalContentModel.WidgetPlaceholder()
159 
160     private var placeHolderIndex: Int? = null
161     private var isOnRemoveButton = false
162 
onStartednull163     fun onStarted() {
164         // assume item will be added to the end.
165         contentListState.list.add(placeHolder)
166         placeHolderIndex = contentListState.list.size - 1
167     }
168 
onMovednull169     fun onMoved(event: DragAndDropEvent) {
170         val dragEvent = event.toAndroidDragEvent()
171         isOnRemoveButton = updateDragPositionForRemove(Offset(dragEvent.x, dragEvent.y))
172         if (!isOnRemoveButton) {
173             findTargetItem(dragEvent)?.apply {
174                 var scrollIndex: Int? = null
175                 var scrollOffset: Int? = null
176                 if (placeHolderIndex == state.firstVisibleItemIndex) {
177                     // Save info about the first item before the move, to neutralize the automatic
178                     // keeping first item first.
179                     scrollIndex = placeHolderIndex
180                     scrollOffset = state.firstVisibleItemScrollOffset
181                 }
182 
183                 autoScrollIfNearEdges(dragEvent)
184 
185                 if (contentListState.isItemEditable(this.index)) {
186                     movePlaceholderTo(this.index)
187                     placeHolderIndex = this.index
188                 }
189 
190                 if (scrollIndex != null && scrollOffset != null) {
191                     // this is needed to neutralize automatic keeping the first item first.
192                     scope.launch { state.scrollToItem(scrollIndex, scrollOffset) }
193                 }
194             }
195         }
196     }
197 
onDropnull198     fun onDrop(event: DragAndDropEvent): Boolean {
199         autoScrollSpeed.value = 0f
200         if (isOnRemoveButton) {
201             return false
202         }
203         return placeHolderIndex?.let { dropIndex ->
204             val widgetExtra = event.maybeWidgetExtra() ?: return false
205             val (componentName, user) = widgetExtra
206             if (componentName != null && user != null) {
207                 // Placeholder isn't removed yet to allow the setting the right priority for items
208                 // before adding in the new item.
209                 contentListState.onSaveList(
210                     newItemComponentName = componentName,
211                     newItemUser = user,
212                     newItemIndex = dropIndex
213                 )
214                 return@let true
215             }
216             return false
217         }
218             ?: false
219     }
220 
onEndednull221     fun onEnded() {
222         autoScrollSpeed.value = 0f
223         placeHolderIndex = null
224         contentListState.list.remove(placeHolder)
225         isOnRemoveButton = updateDragPositionForRemove(Offset.Zero)
226     }
227 
autoScrollIfNearEdgesnull228     private fun autoScrollIfNearEdges(dragEvent: DragEvent) {
229         val orientation = state.layoutInfo.orientation
230         val distanceFromStart =
231             if (orientation == Orientation.Horizontal) {
232                 dragEvent.x
233             } else {
234                 dragEvent.y
235             }
236         val distanceFromEnd =
237             if (orientation == Orientation.Horizontal) {
238                 state.layoutInfo.viewportSize.width - dragEvent.x
239             } else {
240                 state.layoutInfo.viewportSize.height - dragEvent.y
241             }
242         autoScrollSpeed.value =
243             when {
244                 distanceFromEnd < autoScrollThreshold -> autoScrollThreshold - distanceFromEnd
245                 distanceFromStart < autoScrollThreshold ->
246                     -(autoScrollThreshold - distanceFromStart)
247                 else -> 0f
248             }
249     }
250 
findTargetItemnull251     private fun findTargetItem(dragEvent: DragEvent): LazyGridItemInfo? =
252         state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
253             dragEvent.x.toInt() in item.offset.x..(item.offset + item.size).x &&
254                 dragEvent.y.toInt() in item.offset.y..(item.offset + item.size).y
255         }
256 
movePlaceholderTonull257     private fun movePlaceholderTo(index: Int) {
258         val currentIndex = contentListState.list.indexOf(placeHolder)
259         if (currentIndex != index) {
260             contentListState.onMove(currentIndex, index)
261         }
262     }
263 
264     /**
265      * Parses and returns the intent extra associated with the widget that is dropped into the grid.
266      *
267      * Returns null if the drop event didn't include intent information.
268      */
DragAndDropEventnull269     private fun DragAndDropEvent.maybeWidgetExtra(): WidgetPickerIntentUtils.WidgetExtra? {
270         val clipData = this.toAndroidDragEvent().clipData.takeIf { it.itemCount != 0 }
271         return clipData?.getItemAt(0)?.intent?.let { intent -> getWidgetExtraFromIntent(intent) }
272     }
273 }
274