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