• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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 package com.android.systemui.communal.ui.viewmodel
17 
18 import androidx.compose.foundation.gestures.AnchoredDraggableState
19 import androidx.compose.foundation.gestures.DraggableAnchors
20 import androidx.compose.foundation.gestures.snapTo
21 import androidx.compose.runtime.snapshotFlow
22 import com.android.app.tracing.coroutines.coroutineScopeTraced as coroutineScope
23 import com.android.systemui.lifecycle.ExclusiveActivatable
24 import kotlin.math.abs
25 import kotlin.math.ceil
26 import kotlin.math.floor
27 import kotlin.math.sign
28 import kotlinx.coroutines.awaitCancellation
29 import kotlinx.coroutines.flow.Flow
30 import kotlinx.coroutines.flow.MutableStateFlow
31 import kotlinx.coroutines.flow.distinctUntilChanged
32 import kotlinx.coroutines.flow.filter
33 import kotlinx.coroutines.flow.launchIn
34 import kotlinx.coroutines.flow.map
35 import kotlinx.coroutines.flow.merge
36 import kotlinx.coroutines.flow.onEach
37 
38 enum class DragHandle {
39     TOP,
40     BOTTOM,
41 }
42 
43 data class ResizeInfo(
44     /**
45      * The number of spans to resize by. A positive number indicates expansion, whereas a negative
46      * number indicates shrinking.
47      */
48     val spans: Int,
49     /** The drag handle which was used to resize the element. */
50     val fromHandle: DragHandle,
51 ) {
52     /** Whether we are expanding. If false, then we are shrinking. */
53     val isExpanding = spans > 0
54 }
55 
56 class ResizeableItemFrameViewModel : ExclusiveActivatable() {
57     data class GridLayoutInfo(
58         val currentRow: Int,
59         val currentSpan: Int,
60         val maxHeightPx: Int,
61         val minHeightPx: Int,
62         val resizeMultiple: Int,
63         val totalSpans: Int,
64         private val heightPerSpanPx: Float,
65         private val verticalItemSpacingPx: Float,
66     ) {
getPxOffsetForResizenull67         fun getPxOffsetForResize(spans: Int): Int =
68             (spans * (heightPerSpanPx + verticalItemSpacingPx)).toInt()
69 
70         private fun getSpansForPx(height: Int): Int =
71             ceil((height + verticalItemSpacingPx) / (heightPerSpanPx + verticalItemSpacingPx))
72                 .toInt()
73                 .coerceIn(resizeMultiple, totalSpans)
74 
75         private fun roundDownToMultiple(spans: Int): Int =
76             floor(spans.toDouble() / resizeMultiple).toInt() * resizeMultiple
77 
78         val maxSpans: Int
79             get() = roundDownToMultiple(getSpansForPx(maxHeightPx)).coerceAtLeast(currentSpan)
80 
81         val minSpans: Int
82             get() = roundDownToMultiple(getSpansForPx(minHeightPx)).coerceAtMost(currentSpan)
83     }
84 
85     /** Check if widget can expanded based on current drag states */
86     fun canExpand(): Boolean {
87         return getNextAnchor(bottomDragState, moveUp = false) != null ||
88             getNextAnchor(topDragState, moveUp = true) != null
89     }
90 
91     /** Check if widget can shrink based on current drag states */
canShrinknull92     fun canShrink(): Boolean {
93         return getNextAnchor(bottomDragState, moveUp = true) != null ||
94             getNextAnchor(topDragState, moveUp = false) != null
95     }
96 
97     /** Get the next anchor value in the specified direction */
getNextAnchornull98     private fun getNextAnchor(state: AnchoredDraggableState<Int>, moveUp: Boolean): Int? {
99         var nextAnchor: Int? = null
100         var nextAnchorDiff = Int.MAX_VALUE
101         val currentValue = state.currentValue
102 
103         for (i in 0 until state.anchors.size) {
104             val anchor = state.anchors.anchorAt(i) ?: continue
105             if (anchor == currentValue) continue
106 
107             val diff =
108                 if (moveUp) {
109                     currentValue - anchor
110                 } else {
111                     anchor - currentValue
112                 }
113 
114             if (diff in 1..<nextAnchorDiff) {
115                 nextAnchor = anchor
116                 nextAnchorDiff = diff
117             }
118         }
119 
120         return nextAnchor
121     }
122 
123     /** Handle expansion to the next anchor */
expandToNextAnchornull124     suspend fun expandToNextAnchor() {
125         if (!canExpand()) return
126         val bottomAnchor = getNextAnchor(state = bottomDragState, moveUp = false)
127         if (bottomAnchor != null) {
128             bottomDragState.snapTo(bottomAnchor)
129             return
130         }
131         val topAnchor =
132             getNextAnchor(
133                 state = topDragState,
134                 moveUp = true, // Moving up to expand
135             )
136         topAnchor?.let { topDragState.snapTo(it) }
137     }
138 
139     /** Handle shrinking to the next anchor */
shrinkToNextAnchornull140     suspend fun shrinkToNextAnchor() {
141         if (!canShrink()) return
142         val topAnchor = getNextAnchor(state = topDragState, moveUp = false)
143         if (topAnchor != null) {
144             topDragState.snapTo(topAnchor)
145             return
146         }
147         val bottomAnchor = getNextAnchor(state = bottomDragState, moveUp = true)
148         bottomAnchor?.let { bottomDragState.snapTo(it) }
149     }
150 
151     /**
152      * The layout information necessary in order to calculate the pixel offsets of the drag anchor
153      * points.
154      */
155     private val gridLayoutInfo = MutableStateFlow<GridLayoutInfo?>(null)
156 
<lambda>null157     val topDragState = AnchoredDraggableState(0, DraggableAnchors { 0 at 0f })
<lambda>null158     val bottomDragState = AnchoredDraggableState(0, DraggableAnchors { 0 at 0f })
159 
160     /** Emits a [ResizeInfo] when the element is resized using a drag gesture. */
161     val resizeInfo: Flow<ResizeInfo> =
162         merge(
<lambda>null163                 snapshotFlow { topDragState.settledValue }.map { ResizeInfo(-it, DragHandle.TOP) },
<lambda>null164                 snapshotFlow { bottomDragState.settledValue }
<lambda>null165                     .map { ResizeInfo(it, DragHandle.BOTTOM) },
166             )
<lambda>null167             .filter { it.spans != 0 }
168             .distinctUntilChanged()
169 
170     /**
171      * Sets the necessary grid layout information needed for calculating the pixel offsets of the
172      * drag anchors.
173      */
setGridLayoutInfonull174     fun setGridLayoutInfo(
175         verticalItemSpacingPx: Float,
176         currentRow: Int?,
177         maxHeightPx: Int,
178         minHeightPx: Int,
179         currentSpan: Int,
180         resizeMultiple: Int,
181         totalSpans: Int,
182         viewportHeightPx: Int,
183         verticalContentPaddingPx: Float,
184     ) {
185         if (currentRow == null) {
186             gridLayoutInfo.value = null
187             return
188         }
189         require(maxHeightPx >= minHeightPx) {
190             "Maximum item span of $maxHeightPx cannot be less than the minimum span of $minHeightPx"
191         }
192 
193         require(currentSpan <= totalSpans) {
194             "Current span ($currentSpan) cannot exceed the total number of spans ($totalSpans)"
195         }
196 
197         require(resizeMultiple > 0) {
198             "Resize multiple ($resizeMultiple) must be a positive integer"
199         }
200         val availableHeight = viewportHeightPx - verticalContentPaddingPx
201         val heightPerSpanPx =
202             (availableHeight - (totalSpans - 1) * verticalItemSpacingPx) / totalSpans
203 
204         gridLayoutInfo.value =
205             GridLayoutInfo(
206                 heightPerSpanPx = heightPerSpanPx,
207                 verticalItemSpacingPx = verticalItemSpacingPx,
208                 currentRow = currentRow,
209                 currentSpan = currentSpan,
210                 maxHeightPx = maxHeightPx.coerceAtMost(availableHeight.toInt()),
211                 minHeightPx = minHeightPx,
212                 resizeMultiple = resizeMultiple,
213                 totalSpans = totalSpans,
214             )
215     }
216 
calculateAnchorsForHandlenull217     private fun calculateAnchorsForHandle(
218         handle: DragHandle,
219         layoutInfo: GridLayoutInfo?,
220     ): DraggableAnchors<Int> {
221 
222         if (layoutInfo == null || (!isDragAllowed(handle, layoutInfo))) {
223             return DraggableAnchors { 0 at 0f }
224         }
225         val currentRow = layoutInfo.currentRow
226         val currentSpan = layoutInfo.currentSpan
227         val minItemSpan = layoutInfo.minSpans
228         val maxItemSpan = layoutInfo.maxSpans
229         val totalSpans = layoutInfo.totalSpans
230 
231         // The maximum row this handle can be dragged to.
232         val maxRow =
233             if (handle == DragHandle.TOP) {
234                 (currentRow + currentSpan - minItemSpan).coerceAtLeast(0)
235             } else {
236                 (currentRow + maxItemSpan).coerceAtMost(totalSpans)
237             }
238 
239         // The minimum row this handle can be dragged to.
240         val minRow =
241             if (handle == DragHandle.TOP) {
242                 (currentRow + currentSpan - maxItemSpan).coerceAtLeast(0)
243             } else {
244                 (currentRow + minItemSpan).coerceAtMost(totalSpans)
245             }
246 
247         // The current row position of this handle
248         val currentPosition = if (handle == DragHandle.TOP) currentRow else currentRow + currentSpan
249 
250         return DraggableAnchors {
251             for (targetRow in minRow..maxRow step layoutInfo.resizeMultiple) {
252                 val diff = targetRow - currentPosition
253                 val pixelOffset = (layoutInfo.getPxOffsetForResize(abs(diff)) * diff.sign).toFloat()
254                 diff at pixelOffset
255             }
256         }
257     }
258 
isDragAllowednull259     private fun isDragAllowed(handle: DragHandle, layoutInfo: GridLayoutInfo): Boolean {
260         val minItemSpan = layoutInfo.minSpans
261         val maxItemSpan = layoutInfo.maxSpans
262         val currentRow = layoutInfo.currentRow
263         val currentSpan = layoutInfo.currentSpan
264         val atMinSize = currentSpan == minItemSpan
265 
266         // If already at the minimum size and in the first row, item cannot be expanded from the top
267         if (handle == DragHandle.TOP && currentRow == 0 && atMinSize) {
268             return false
269         }
270 
271         // If already at the minimum size and occupying the last row, item cannot be expanded from
272         // the
273         // bottom
274         if (handle == DragHandle.BOTTOM && (currentRow + currentSpan) == maxItemSpan && atMinSize) {
275             return false
276         }
277 
278         // If at maximum size, item can only be shrunk from the bottom and not the top.
279         if (handle == DragHandle.TOP && currentSpan == maxItemSpan) {
280             return false
281         }
282 
283         return true
284     }
285 
onActivatednull286     override suspend fun onActivated(): Nothing {
287         coroutineScope("ResizeableItemFrameViewModel.onActivated") {
288             gridLayoutInfo
289                 .onEach { layoutInfo ->
290                     topDragState.updateAnchors(
291                         calculateAnchorsForHandle(DragHandle.TOP, layoutInfo)
292                     )
293                     bottomDragState.updateAnchors(
294                         calculateAnchorsForHandle(DragHandle.BOTTOM, layoutInfo)
295                     )
296                 }
297                 .launchIn(this)
298             awaitCancellation()
299         }
300     }
301 }
302