• 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 androidx.compose.animation.core.Spring
20 import androidx.compose.animation.core.animateFloatAsState
21 import androidx.compose.animation.core.spring
22 import androidx.compose.animation.core.tween
23 import androidx.compose.foundation.ExperimentalFoundationApi
24 import androidx.compose.foundation.gestures.Orientation
25 import androidx.compose.foundation.gestures.animateScrollBy
26 import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
27 import androidx.compose.foundation.gestures.scrollBy
28 import androidx.compose.foundation.layout.Box
29 import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
30 import androidx.compose.foundation.lazy.grid.LazyGridItemScope
31 import androidx.compose.foundation.lazy.grid.LazyGridState
32 import androidx.compose.runtime.Composable
33 import androidx.compose.runtime.LaunchedEffect
34 import androidx.compose.runtime.getValue
35 import androidx.compose.runtime.mutableStateOf
36 import androidx.compose.runtime.remember
37 import androidx.compose.runtime.rememberCoroutineScope
38 import androidx.compose.runtime.setValue
39 import androidx.compose.ui.Modifier
40 import androidx.compose.ui.geometry.Offset
41 import androidx.compose.ui.graphics.graphicsLayer
42 import androidx.compose.ui.input.pointer.pointerInput
43 import androidx.compose.ui.platform.LocalDensity
44 import androidx.compose.ui.platform.LocalLayoutDirection
45 import androidx.compose.ui.unit.IntRect
46 import androidx.compose.ui.unit.LayoutDirection
47 import androidx.compose.ui.unit.dp
48 import androidx.compose.ui.unit.round
49 import androidx.compose.ui.unit.toOffset
50 import androidx.compose.ui.unit.toSize
51 import com.android.systemui.Flags.communalWidgetResizing
52 import com.android.systemui.Flags.glanceableHubV2
53 import com.android.systemui.communal.domain.model.CommunalContentModel
54 import com.android.systemui.communal.shared.model.CommunalContentSize
55 import com.android.systemui.communal.ui.compose.extensions.firstItemAtOffset
56 import com.android.systemui.communal.ui.compose.extensions.plus
57 import com.android.systemui.communal.ui.viewmodel.BaseCommunalViewModel
58 import kotlinx.coroutines.CoroutineScope
59 import kotlinx.coroutines.channels.Channel
60 import kotlinx.coroutines.launch
61 
62 private fun Float.directional(origin: LayoutDirection, current: LayoutDirection): Float =
63     if (origin == current) this else -this
64 
65 @Composable
66 fun rememberGridDragDropState(
67     gridState: LazyGridState,
68     contentListState: ContentListState,
69     updateDragPositionForRemove: (boundingBox: IntRect) -> Boolean,
70 ): GridDragDropState {
71     val coroutineScope = rememberCoroutineScope()
72     val autoScrollThreshold = with(LocalDensity.current) { 60.dp.toPx() }
73 
74     val state =
75         remember(gridState, contentListState, updateDragPositionForRemove) {
76             GridDragDropState(
77                 gridState = gridState,
78                 contentListState = contentListState,
79                 coroutineScope = coroutineScope,
80                 autoScrollThreshold = autoScrollThreshold,
81                 updateDragPositionForRemove = updateDragPositionForRemove,
82             )
83         }
84 
85     LaunchedEffect(state) { state.processScrollRequests(coroutineScope) }
86 
87     return state
88 }
89 
90 /**
91  * Handles drag and drop cards in the glanceable hub. While dragging to move, other items that are
92  * affected will dynamically get positioned and the state is tracked by [ContentListState]. When
93  * dragging to remove, affected cards will be moved and [updateDragPositionForRemove] is called to
94  * check whether the dragged item can be removed. On dragging ends, call [ContentListState.onRemove]
95  * to remove the dragged item if condition met and call [ContentListState.onSaveList] to persist any
96  * change in ordering.
97  */
98 class GridDragDropState(
99     val gridState: LazyGridState,
100     contentListState: ContentListState,
101     coroutineScope: CoroutineScope,
102     autoScrollThreshold: Float,
103     private val updateDragPositionForRemove: (draggingBoundingBox: IntRect) -> Boolean,
104 ) {
105     private val dragDropState: GridDragDropStateInternal =
106         if (glanceableHubV2()) {
107             GridDragDropStateV2(
108                 gridState = gridState,
109                 contentListState = contentListState,
110                 scope = coroutineScope,
111                 autoScrollThreshold = autoScrollThreshold,
112                 updateDragPositionForRemove = updateDragPositionForRemove,
113             )
114         } else {
115             GridDragDropStateV1(
116                 gridState = gridState,
117                 contentListState = contentListState,
118                 scope = coroutineScope,
119                 updateDragPositionForRemove = updateDragPositionForRemove,
120             )
121         }
122 
123     val draggingItemKey: String?
124         get() = dragDropState.draggingItemKey
125 
126     val isDraggingToRemove: Boolean
127         get() = dragDropState.isDraggingToRemove
128 
129     val draggingItemOffset: Offset
130         get() = dragDropState.draggingItemOffset
131 
132     /**
133      * Called when dragging is initiated.
134      *
135      * @return {@code True} if dragging a grid item, {@code False} otherwise.
136      */
onDragStartnull137     fun onDragStart(
138         offset: Offset,
139         screenWidth: Int,
140         layoutDirection: LayoutDirection,
141         contentOffset: Offset,
142     ): Boolean = dragDropState.onDragStart(offset, screenWidth, layoutDirection, contentOffset)
143 
144     fun onDragInterrupted() = dragDropState.onDragInterrupted()
145 
146     fun onDrag(offset: Offset, layoutDirection: LayoutDirection) =
147         dragDropState.onDrag(offset, layoutDirection)
148 
149     suspend fun processScrollRequests(coroutineScope: CoroutineScope) =
150         dragDropState.processScrollRequests(coroutineScope)
151 }
152 
153 /**
154  * A private base class defining the API for handling drag-and-drop operations. There will be two
155  * implementations of this class: V1 for devices that do not have the glanceable_hub_v2 flag
156  * enabled, and V2 for devices that do have that flag enabled.
157  *
158  * TODO(b/400789179): Remove this class and the V1 implementation once glanceable_hub_v2 has
159  *   shipped.
160  */
161 private open class GridDragDropStateInternal(protected val state: LazyGridState) {
162     var draggingItemKey by mutableStateOf<String?>(null)
163         protected set
164 
165     var isDraggingToRemove by mutableStateOf(false)
166         protected set
167 
168     var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
169     var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
170 
171     val draggingItemOffset: Offset
172         get() =
173             draggingItemLayoutInfo?.let { item ->
174                 draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
175             } ?: Offset.Zero
176 
177     val draggingItemLayoutInfo: LazyGridItemInfo?
178         get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey }
179 
180     /**
181      * Called when dragging is initiated.
182      *
183      * @return {@code True} if dragging a grid item, {@code False} otherwise.
184      */
185     open fun onDragStart(
186         offset: Offset,
187         screenWidth: Int,
188         layoutDirection: LayoutDirection,
189         contentOffset: Offset,
190     ): Boolean = false
191 
192     open fun onDragInterrupted() = Unit
193 
194     open fun onDrag(offset: Offset, layoutDirection: LayoutDirection) = Unit
195 
196     open suspend fun processScrollRequests(coroutineScope: CoroutineScope) = Unit
197 }
198 
199 /**
200  * The V1 implementation of GridDragDropStateInternal to be used when the glanceable_hub_v2 flag is
201  * disabled.
202  */
203 private class GridDragDropStateV1(
204     val gridState: LazyGridState,
205     private val contentListState: ContentListState,
206     private val scope: CoroutineScope,
207     private val updateDragPositionForRemove: (draggingBoundingBox: IntRect) -> Boolean,
208 ) : GridDragDropStateInternal(gridState) {
209     private val scrollChannel = Channel<Float>()
210 
211     private val spacer = CommunalContentModel.Spacer(CommunalContentSize.Responsive(1))
212     private var spacerIndex: Int? = null
213 
214     private var previousTargetItemKey: Any? = null
215 
processScrollRequestsnull216     override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
217         while (true) {
218             val diff = scrollChannel.receive()
219             state.scrollBy(diff)
220         }
221     }
222 
onDragStartnull223     override fun onDragStart(
224         offset: Offset,
225         screenWidth: Int,
226         layoutDirection: LayoutDirection,
227         contentOffset: Offset,
228     ): Boolean {
229         val normalizedOffset =
230             Offset(
231                 if (layoutDirection == LayoutDirection.Ltr) offset.x else screenWidth - offset.x,
232                 offset.y,
233             )
234         state.layoutInfo.visibleItemsInfo
235             .filter { item -> contentListState.isItemEditable(item.index) }
236             // grid item offset is based off grid content container so we need to deduct
237             // before content padding from the initial pointer position
238             .firstItemAtOffset(normalizedOffset - contentOffset)
239             ?.apply {
240                 draggingItemKey = key as String
241                 draggingItemInitialOffset = this.offset.toOffset()
242                 // Add a spacer after the last widget if it is larger than the dragging widget.
243                 // This allows overscrolling, enabling the dragging widget to be placed beyond it.
244                 val lastWidget = contentListState.list.lastOrNull { it.isWidgetContent() }
245                 if (
246                     lastWidget != null &&
247                         draggingItemLayoutInfo != null &&
248                         lastWidget.size.span > draggingItemLayoutInfo!!.span
249                 ) {
250                     contentListState.list.add(spacer)
251                     spacerIndex = contentListState.list.size - 1
252                 }
253                 return true
254             }
255 
256         return false
257     }
258 
onDragInterruptednull259     override fun onDragInterrupted() {
260         draggingItemKey?.let {
261             if (isDraggingToRemove) {
262                 contentListState.onRemove(
263                     contentListState.list.indexOfFirst { it.key == draggingItemKey }
264                 )
265                 isDraggingToRemove = false
266                 updateDragPositionForRemove(IntRect.Zero)
267             }
268             // persist list editing changes on dragging ends
269             contentListState.onSaveList()
270             draggingItemKey = null
271         }
272         previousTargetItemKey = null
273         draggingItemDraggedDelta = Offset.Zero
274         draggingItemInitialOffset = Offset.Zero
275         // Remove spacer, if any, when a drag gesture finishes.
276         spacerIndex?.let {
277             contentListState.list.removeAt(it)
278             spacerIndex = null
279         }
280     }
281 
onDragnull282     override fun onDrag(offset: Offset, layoutDirection: LayoutDirection) {
283         // Adjust offset to match the layout direction
284         draggingItemDraggedDelta +=
285             Offset(offset.x.directional(LayoutDirection.Ltr, layoutDirection), offset.y)
286 
287         val draggingItem = draggingItemLayoutInfo ?: return
288         val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
289         val endOffset = startOffset + draggingItem.size.toSize()
290         val middleOffset = startOffset + (endOffset - startOffset) / 2f
291         val draggingBoundingBox =
292             IntRect(draggingItem.offset + draggingItemOffset.round(), draggingItem.size)
293 
294         val targetItem =
295             if (communalWidgetResizing()) {
296                 state.layoutInfo.visibleItemsInfo.findLast { item ->
297                     val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.last().index
298                     val itemBoundingBox = IntRect(item.offset, item.size)
299                     draggingItemKey != item.key &&
300                         contentListState.isItemEditable(item.index) &&
301                         (draggingBoundingBox.contains(itemBoundingBox.center) ||
302                             itemBoundingBox.contains(draggingBoundingBox.center)) &&
303                         // If we swap with the last visible item, and that item doesn't fit
304                         // in the gap created by moving the current item, then the current item
305                         // will get placed after the last visible item. In this case, it gets
306                         // placed outside of the viewport. We avoid this here, so the user
307                         // has to scroll first before the swap can happen.
308                         (item.index != lastVisibleItemIndex || item.span <= draggingItem.span)
309                 }
310             } else {
311                 state.layoutInfo.visibleItemsInfo
312                     .asSequence()
313                     .filter { item -> contentListState.isItemEditable(item.index) }
314                     .filter { item -> draggingItem.index != item.index }
315                     .firstItemAtOffset(middleOffset)
316             }
317 
318         if (
319             targetItem != null &&
320                 (!communalWidgetResizing() || targetItem.key != previousTargetItemKey)
321         ) {
322             val scrollToIndex =
323                 if (targetItem.index == state.firstVisibleItemIndex) {
324                     draggingItem.index
325                 } else if (draggingItem.index == state.firstVisibleItemIndex) {
326                     targetItem.index
327                 } else {
328                     null
329                 }
330             if (communalWidgetResizing()) {
331                 // Keep track of the previous target item, to avoid rapidly oscillating between
332                 // items if the target item doesn't visually move as a result of the index change.
333                 // In this case, even after the index changes, we'd still be colliding with the
334                 // element, so it would be selected as the target item the next time this function
335                 // runs again, which would trigger us to revert the index change we recently made.
336                 previousTargetItemKey = targetItem.key
337             }
338             if (scrollToIndex != null) {
339                 scope.launch {
340                     // this is needed to neutralize automatic keeping the first item first.
341                     state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
342                     contentListState.onMove(draggingItem.index, targetItem.index)
343                 }
344             } else {
345                 contentListState.onMove(draggingItem.index, targetItem.index)
346             }
347             isDraggingToRemove = false
348         } else if (targetItem == null) {
349             val overscroll = checkForOverscroll(startOffset, endOffset)
350             if (overscroll != 0f) {
351                 scrollChannel.trySend(overscroll)
352             }
353             isDraggingToRemove = checkForRemove(draggingBoundingBox)
354             previousTargetItemKey = null
355         }
356     }
357 
358     /** Calculate the amount dragged out of bound on both sides. Returns 0f if not overscrolled */
checkForOverscrollnull359     private fun checkForOverscroll(startOffset: Offset, endOffset: Offset): Float {
360         return when {
361             draggingItemDraggedDelta.x > 0 ->
362                 (endOffset.x - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
363             draggingItemDraggedDelta.x < 0 ->
364                 (startOffset.x - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
365             else -> 0f
366         }
367     }
368 
369     /** Calls the callback with the updated drag position and returns whether to remove the item. */
checkForRemovenull370     private fun checkForRemove(draggingItemBoundingBox: IntRect): Boolean {
371         return if (draggingItemDraggedDelta.y < 0) {
372             updateDragPositionForRemove(draggingItemBoundingBox)
373         } else {
374             false
375         }
376     }
377 }
378 
379 /**
380  * The V2 implementation of GridDragDropStateInternal to be used when the glanceable_hub_v2 flag is
381  * enabled.
382  */
383 private class GridDragDropStateV2(
384     val gridState: LazyGridState,
385     private val contentListState: ContentListState,
386     private val scope: CoroutineScope,
387     private val autoScrollThreshold: Float,
388     private val updateDragPositionForRemove: (draggingBoundingBox: IntRect) -> Boolean,
389 ) : GridDragDropStateInternal(gridState) {
390 
391     private val scrollChannel = Channel<Float>(Channel.UNLIMITED)
392 
393     // Used to keep track of the dragging item during scrolling (because it might be off screen
394     // and no longer in the list of visible items).
395     private var draggingItemWhileScrolling: LazyGridItemInfo? by mutableStateOf(null)
396 
397     private val spacer = CommunalContentModel.Spacer(CommunalContentSize.Responsive(1))
398     private var spacerIndex: Int? = null
399 
400     private var previousTargetItemKey: Any? = null
401 
402     // Basically, the location of the user's finger on the screen.
403     private var currentDragPositionOnScreen by mutableStateOf(Offset.Zero)
404     // The offset of the grid from the top of the screen.
405     private var contentOffset = Offset.Zero
406 
407     // The width of one column in the grid (needed in order to auto-scroll one column at a time).
408     private var columnWidth = 0
409 
processScrollRequestsnull410     override suspend fun processScrollRequests(coroutineScope: CoroutineScope) {
411         while (true) {
412             val amount = scrollChannel.receive()
413 
414             if (state.isScrollInProgress) {
415                 // Ignore overscrolling if a scroll is already in progress (but we still want to
416                 // consume the scroll event so that we don't end up processing a bunch of old
417                 // events after scrolling has finished).
418                 continue
419             }
420 
421             // We perform the rest of the drag action after scrolling has finished (or immediately
422             // if there will be no scrolling).
423             if (amount != 0f) {
424                 coroutineScope.launch {
425                     state.animateScrollBy(amount, tween(delayMillis = 250, durationMillis = 1000))
426                     performDragAction()
427                 }
428             } else {
429                 performDragAction()
430             }
431         }
432     }
433 
onDragStartnull434     override fun onDragStart(
435         offset: Offset,
436         screenWidth: Int,
437         layoutDirection: LayoutDirection,
438         contentOffset: Offset,
439     ): Boolean {
440         val normalizedOffset =
441             Offset(
442                 if (layoutDirection == LayoutDirection.Ltr) offset.x else screenWidth - offset.x,
443                 offset.y,
444             )
445 
446         currentDragPositionOnScreen = normalizedOffset
447         this.contentOffset = contentOffset
448 
449         state.layoutInfo.visibleItemsInfo
450             .filter { item -> contentListState.isItemEditable(item.index) }
451             // grid item offset is based off grid content container so we need to deduct
452             // before content padding from the initial pointer position
453             .firstItemAtOffset(normalizedOffset - contentOffset)
454             ?.apply {
455                 draggingItemKey = key as String
456                 draggingItemWhileScrolling = this
457                 draggingItemInitialOffset = this.offset.toOffset()
458                 columnWidth =
459                     this.size.width +
460                         state.layoutInfo.beforeContentPadding +
461                         state.layoutInfo.afterContentPadding
462                 // Add a spacer after the last widget if it is larger than the dragging widget.
463                 // This allows overscrolling, enabling the dragging widget to be placed beyond it.
464                 val lastWidget = contentListState.list.lastOrNull { it.isWidgetContent() }
465                 if (
466                     lastWidget != null &&
467                         draggingItemLayoutInfo != null &&
468                         lastWidget.size.span > draggingItemLayoutInfo!!.span
469                 ) {
470                     contentListState.list.add(spacer)
471                     spacerIndex = contentListState.list.size - 1
472                 }
473                 return true
474             }
475 
476         return false
477     }
478 
onDragInterruptednull479     override fun onDragInterrupted() {
480         draggingItemKey?.let {
481             if (isDraggingToRemove) {
482                 contentListState.onRemove(
483                     contentListState.list.indexOfFirst { it.key == draggingItemKey }
484                 )
485                 isDraggingToRemove = false
486                 updateDragPositionForRemove(IntRect.Zero)
487             }
488             // persist list editing changes on dragging ends
489             contentListState.onSaveList()
490             draggingItemKey = null
491         }
492         previousTargetItemKey = null
493         draggingItemDraggedDelta = Offset.Zero
494         draggingItemInitialOffset = Offset.Zero
495         currentDragPositionOnScreen = Offset.Zero
496         draggingItemWhileScrolling = null
497         // Remove spacer, if any, when a drag gesture finishes.
498         spacerIndex?.let {
499             contentListState.list.removeAt(it)
500             spacerIndex = null
501         }
502     }
503 
onDragnull504     override fun onDrag(offset: Offset, layoutDirection: LayoutDirection) {
505         // Adjust offset to match the layout direction
506         val delta = Offset(offset.x.directional(LayoutDirection.Ltr, layoutDirection), offset.y)
507         draggingItemDraggedDelta += delta
508         currentDragPositionOnScreen += delta
509 
510         scrollChannel.trySend(computeAutoscroll(currentDragPositionOnScreen))
511     }
512 
performDragActionnull513     fun performDragAction() {
514         val draggingItem = draggingItemLayoutInfo ?: draggingItemWhileScrolling
515         if (draggingItem == null) {
516             return
517         }
518 
519         val draggingBoundingBox =
520             IntRect(draggingItem.offset + draggingItemOffset.round(), draggingItem.size)
521         val curDragPositionInGrid = (currentDragPositionOnScreen - contentOffset)
522 
523         val targetItem =
524             if (communalWidgetResizing()) {
525                 val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.last().index
526                 state.layoutInfo.visibleItemsInfo.findLast(
527                     fun(item): Boolean {
528                         val itemBoundingBox = IntRect(item.offset, item.size)
529                         return draggingItemKey != item.key &&
530                             contentListState.isItemEditable(item.index) &&
531                             itemBoundingBox.contains(curDragPositionInGrid.round()) &&
532                             // If we swap with the last visible item, and that item doesn't fit
533                             // in the gap created by moving the current item, then the current item
534                             // will get placed after the last visible item. In this case, it gets
535                             // placed outside of the viewport. We avoid this here, so the user
536                             // has to scroll first before the swap can happen.
537                             (item.index != lastVisibleItemIndex || item.span <= draggingItem.span)
538                     }
539                 )
540             } else {
541                 state.layoutInfo.visibleItemsInfo
542                     .asSequence()
543                     .filter { item -> contentListState.isItemEditable(item.index) }
544                     .filter { item -> draggingItem.index != item.index }
545                     .firstItemAtOffset(curDragPositionInGrid)
546             }
547 
548         if (
549             targetItem != null &&
550                 (!communalWidgetResizing() || targetItem.key != previousTargetItemKey)
551         ) {
552             val scrollToIndex =
553                 if (targetItem.index == state.firstVisibleItemIndex) {
554                     draggingItem.index
555                 } else if (draggingItem.index == state.firstVisibleItemIndex) {
556                     targetItem.index
557                 } else {
558                     null
559                 }
560             if (communalWidgetResizing()) {
561                 // Keep track of the previous target item, to avoid rapidly oscillating between
562                 // items if the target item doesn't visually move as a result of the index change.
563                 // In this case, even after the index changes, we'd still be colliding with the
564                 // element, so it would be selected as the target item the next time this function
565                 // runs again, which would trigger us to revert the index change we recently made.
566                 previousTargetItemKey = targetItem.key
567             }
568             if (scrollToIndex != null) {
569                 scope.launch {
570                     // this is needed to neutralize automatic keeping the first item first.
571                     state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
572                     contentListState.swapItems(draggingItem.index, targetItem.index)
573                 }
574             } else {
575                 contentListState.swapItems(draggingItem.index, targetItem.index)
576             }
577             draggingItemWhileScrolling = targetItem
578             isDraggingToRemove = false
579         } else if (targetItem == null) {
580             isDraggingToRemove = checkForRemove(draggingBoundingBox)
581             previousTargetItemKey = null
582         }
583     }
584 
585     /** Calculate the amount dragged out of bound on both sides. Returns 0f if not overscrolled. */
computeAutoscrollnull586     private fun computeAutoscroll(dragOffset: Offset): Float {
587         val orientation = state.layoutInfo.orientation
588         val distanceFromStart =
589             if (orientation == Orientation.Horizontal) {
590                 dragOffset.x
591             } else {
592                 dragOffset.y
593             }
594         val distanceFromEnd =
595             if (orientation == Orientation.Horizontal) {
596                 state.layoutInfo.viewportEndOffset - dragOffset.x
597             } else {
598                 state.layoutInfo.viewportEndOffset - dragOffset.y
599             }
600 
601         return when {
602             distanceFromEnd < autoScrollThreshold -> {
603                 (columnWidth - state.layoutInfo.beforeContentPadding).toFloat()
604             }
605             distanceFromStart < autoScrollThreshold -> {
606                 -(columnWidth - state.layoutInfo.afterContentPadding).toFloat()
607             }
608             else -> 0f
609         }
610     }
611 
612     /** Calls the callback with the updated drag position and returns whether to remove the item. */
checkForRemovenull613     private fun checkForRemove(draggingItemBoundingBox: IntRect): Boolean {
614         return if (draggingItemDraggedDelta.y < 0) {
615             updateDragPositionForRemove(draggingItemBoundingBox)
616         } else {
617             false
618         }
619     }
620 }
621 
dragContainernull622 fun Modifier.dragContainer(
623     dragDropState: GridDragDropState,
624     layoutDirection: LayoutDirection,
625     screenWidth: Int,
626     contentOffset: Offset,
627     viewModel: BaseCommunalViewModel,
628 ): Modifier {
629     return this.then(
630         Modifier.pointerInput(dragDropState, contentOffset) {
631             detectDragGesturesAfterLongPress(
632                 onDrag = { change, offset ->
633                     change.consume()
634                     dragDropState.onDrag(offset, layoutDirection)
635                 },
636                 onDragStart = { offset ->
637                     if (
638                         dragDropState.onDragStart(
639                             offset,
640                             screenWidth,
641                             layoutDirection,
642                             contentOffset,
643                         )
644                     ) {
645                         // draggingItemKey is guaranteed to be non-null here because it is set in
646                         // onDragStart()
647                         viewModel.onReorderWidgetStart(dragDropState.draggingItemKey!!)
648                     }
649                 },
650                 onDragEnd = {
651                     dragDropState.onDragInterrupted()
652                     viewModel.onReorderWidgetEnd()
653                 },
654                 onDragCancel = {
655                     dragDropState.onDragInterrupted()
656                     viewModel.onReorderWidgetCancel()
657                 },
658             )
659         }
660     )
661 }
662 
663 /** Wrap LazyGrid item with additional modifier needed for drag and drop. */
664 @ExperimentalFoundationApi
665 @Composable
DraggableItemnull666 fun LazyGridItemScope.DraggableItem(
667     dragDropState: GridDragDropState,
668     key: Any,
669     enabled: Boolean,
670     selected: Boolean,
671     modifier: Modifier = Modifier,
672     content: @Composable (isDragging: Boolean) -> Unit,
673 ) {
674     if (!enabled) {
675         return content(false)
676     }
677 
678     val dragging = key == dragDropState.draggingItemKey
679     val itemAlpha: Float by
680         animateFloatAsState(
681             targetValue = if (dragDropState.isDraggingToRemove) 0.5f else 1f,
682             label = "DraggableItemAlpha",
683         )
684     val direction = LocalLayoutDirection.current
685     val draggingModifier =
686         if (dragging) {
687             Modifier.graphicsLayer {
688                 translationX =
689                     dragDropState.draggingItemOffset.x.directional(LayoutDirection.Ltr, direction)
690                 translationY = dragDropState.draggingItemOffset.y
691                 alpha = itemAlpha
692             }
693         } else {
694             Modifier.animateItem()
695         }
696 
697     // Animate the highlight alpha manually as alpha modifier (and AnimatedVisibility) clips the
698     // widget to bounds, which cuts off the highlight as we are drawing outside the widget bounds.
699     val highlightSelected = !communalWidgetResizing() && selected
700     val alpha by
701         animateFloatAsState(
702             targetValue =
703                 if ((dragging || highlightSelected) && !dragDropState.isDraggingToRemove) {
704                     1f
705                 } else {
706                     0f
707                 },
708             animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
709             label = "Widget outline alpha",
710         )
711 
712     Box(modifier) {
713         HighlightedItem(Modifier.matchParentSize(), alpha = alpha)
714         Box(draggingModifier) { content(dragging) }
715     }
716 }
717