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