• 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 
17 @file:OptIn(ExperimentalFoundationApi::class)
18 
19 package com.android.systemui.qs.panels.ui.compose.infinitegrid
20 
21 import androidx.compose.animation.AnimatedContent
22 import androidx.compose.animation.animateColorAsState
23 import androidx.compose.animation.animateContentSize
24 import androidx.compose.animation.core.LinearEasing
25 import androidx.compose.animation.core.Spring
26 import androidx.compose.animation.core.VisibilityThreshold
27 import androidx.compose.animation.core.animateDpAsState
28 import androidx.compose.animation.core.animateFloatAsState
29 import androidx.compose.animation.core.spring
30 import androidx.compose.animation.core.tween
31 import androidx.compose.animation.fadeIn
32 import androidx.compose.animation.fadeOut
33 import androidx.compose.foundation.ExperimentalFoundationApi
34 import androidx.compose.foundation.LocalOverscrollFactory
35 import androidx.compose.foundation.ScrollState
36 import androidx.compose.foundation.background
37 import androidx.compose.foundation.border
38 import androidx.compose.foundation.clickable
39 import androidx.compose.foundation.clipScrollableContainer
40 import androidx.compose.foundation.gestures.Orientation
41 import androidx.compose.foundation.gestures.detectTapGestures
42 import androidx.compose.foundation.layout.Arrangement.spacedBy
43 import androidx.compose.foundation.layout.Box
44 import androidx.compose.foundation.layout.BoxScope
45 import androidx.compose.foundation.layout.Column
46 import androidx.compose.foundation.layout.IntrinsicSize
47 import androidx.compose.foundation.layout.PaddingValues
48 import androidx.compose.foundation.layout.Row
49 import androidx.compose.foundation.layout.Spacer
50 import androidx.compose.foundation.layout.fillMaxHeight
51 import androidx.compose.foundation.layout.fillMaxSize
52 import androidx.compose.foundation.layout.fillMaxWidth
53 import androidx.compose.foundation.layout.height
54 import androidx.compose.foundation.layout.heightIn
55 import androidx.compose.foundation.layout.padding
56 import androidx.compose.foundation.layout.requiredHeightIn
57 import androidx.compose.foundation.layout.size
58 import androidx.compose.foundation.layout.wrapContentHeight
59 import androidx.compose.foundation.layout.wrapContentSize
60 import androidx.compose.foundation.lazy.grid.GridCells
61 import androidx.compose.foundation.lazy.grid.LazyGridScope
62 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
63 import androidx.compose.foundation.rememberScrollState
64 import androidx.compose.foundation.shape.CircleShape
65 import androidx.compose.foundation.shape.RoundedCornerShape
66 import androidx.compose.foundation.verticalScroll
67 import androidx.compose.material.icons.Icons
68 import androidx.compose.material.icons.automirrored.filled.ArrowBack
69 import androidx.compose.material.icons.filled.Add
70 import androidx.compose.material.icons.filled.Clear
71 import androidx.compose.material3.ButtonDefaults
72 import androidx.compose.material3.ExperimentalMaterial3Api
73 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
74 import androidx.compose.material3.Icon
75 import androidx.compose.material3.IconButton
76 import androidx.compose.material3.LocalContentColor
77 import androidx.compose.material3.MaterialTheme
78 import androidx.compose.material3.Scaffold
79 import androidx.compose.material3.Text
80 import androidx.compose.material3.TextButton
81 import androidx.compose.material3.TopAppBar
82 import androidx.compose.material3.TopAppBarDefaults
83 import androidx.compose.runtime.Composable
84 import androidx.compose.runtime.CompositionLocalProvider
85 import androidx.compose.runtime.LaunchedEffect
86 import androidx.compose.runtime.State
87 import androidx.compose.runtime.derivedStateOf
88 import androidx.compose.runtime.getValue
89 import androidx.compose.runtime.key
90 import androidx.compose.runtime.mutableStateListOf
91 import androidx.compose.runtime.mutableStateOf
92 import androidx.compose.runtime.remember
93 import androidx.compose.runtime.rememberCoroutineScope
94 import androidx.compose.runtime.rememberUpdatedState
95 import androidx.compose.runtime.setValue
96 import androidx.compose.ui.Alignment
97 import androidx.compose.ui.Modifier
98 import androidx.compose.ui.draw.clip
99 import androidx.compose.ui.draw.drawBehind
100 import androidx.compose.ui.geometry.CornerRadius
101 import androidx.compose.ui.geometry.Offset
102 import androidx.compose.ui.geometry.isSpecified
103 import androidx.compose.ui.graphics.Color
104 import androidx.compose.ui.graphics.graphicsLayer
105 import androidx.compose.ui.input.pointer.pointerInput
106 import androidx.compose.ui.layout.MeasureScope
107 import androidx.compose.ui.layout.layout
108 import androidx.compose.ui.layout.onGloballyPositioned
109 import androidx.compose.ui.layout.onSizeChanged
110 import androidx.compose.ui.layout.positionInRoot
111 import androidx.compose.ui.platform.LocalDensity
112 import androidx.compose.ui.platform.testTag
113 import androidx.compose.ui.res.dimensionResource
114 import androidx.compose.ui.res.painterResource
115 import androidx.compose.ui.res.stringResource
116 import androidx.compose.ui.semantics.CustomAccessibilityAction
117 import androidx.compose.ui.semantics.contentDescription
118 import androidx.compose.ui.semantics.customActions
119 import androidx.compose.ui.semantics.semantics
120 import androidx.compose.ui.semantics.stateDescription
121 import androidx.compose.ui.text.style.Hyphens
122 import androidx.compose.ui.text.style.TextAlign
123 import androidx.compose.ui.text.style.TextOverflow
124 import androidx.compose.ui.unit.Dp
125 import androidx.compose.ui.unit.IntOffset
126 import androidx.compose.ui.unit.dp
127 import androidx.compose.ui.util.fastMap
128 import com.android.compose.gesture.effect.rememberOffsetOverscrollEffectFactory
129 import com.android.compose.modifiers.height
130 import com.android.compose.theme.LocalAndroidColorScheme
131 import com.android.systemui.common.ui.compose.load
132 import com.android.systemui.qs.panels.shared.model.SizedTile
133 import com.android.systemui.qs.panels.shared.model.SizedTileImpl
134 import com.android.systemui.qs.panels.ui.compose.DragAndDropState
135 import com.android.systemui.qs.panels.ui.compose.DragType
136 import com.android.systemui.qs.panels.ui.compose.EditTileListState
137 import com.android.systemui.qs.panels.ui.compose.EditTileListState.Companion.INVALID_INDEX
138 import com.android.systemui.qs.panels.ui.compose.dragAndDropRemoveZone
139 import com.android.systemui.qs.panels.ui.compose.dragAndDropTileList
140 import com.android.systemui.qs.panels.ui.compose.dragAndDropTileSource
141 import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.InactiveCornerRadius
142 import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.TileArrangementPadding
143 import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.TileHeight
144 import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.ToggleTargetSize
145 import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AUTO_SCROLL_DISTANCE
146 import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AUTO_SCROLL_SPEED
147 import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AvailableTilesGridMinHeight
148 import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.CurrentTilesGridPadding
149 import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.GridBackgroundCornerRadius
150 import com.android.systemui.qs.panels.ui.compose.selection.InteractiveTileContainer
151 import com.android.systemui.qs.panels.ui.compose.selection.MutableSelectionState
152 import com.android.systemui.qs.panels.ui.compose.selection.ResizingState
153 import com.android.systemui.qs.panels.ui.compose.selection.ResizingState.ResizeOperation
154 import com.android.systemui.qs.panels.ui.compose.selection.ResizingState.ResizeOperation.FinalResizeOperation
155 import com.android.systemui.qs.panels.ui.compose.selection.ResizingState.ResizeOperation.TemporaryResizeOperation
156 import com.android.systemui.qs.panels.ui.compose.selection.StaticTileBadge
157 import com.android.systemui.qs.panels.ui.compose.selection.TileState
158 import com.android.systemui.qs.panels.ui.compose.selection.rememberResizingState
159 import com.android.systemui.qs.panels.ui.compose.selection.rememberSelectionState
160 import com.android.systemui.qs.panels.ui.compose.selection.selectableTile
161 import com.android.systemui.qs.panels.ui.model.AvailableTileGridCell
162 import com.android.systemui.qs.panels.ui.model.GridCell
163 import com.android.systemui.qs.panels.ui.model.SpacerGridCell
164 import com.android.systemui.qs.panels.ui.model.TileGridCell
165 import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
166 import com.android.systemui.qs.pipeline.shared.TileSpec
167 import com.android.systemui.qs.shared.model.TileCategory
168 import com.android.systemui.qs.shared.model.groupAndSort
169 import com.android.systemui.res.R
170 import kotlin.math.abs
171 import kotlin.math.roundToInt
172 import kotlinx.coroutines.CoroutineScope
173 import kotlinx.coroutines.launch
174 
175 object TileType
176 
177 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
178 @Composable
179 private fun EditModeTopBar(onStopEditing: () -> Unit, onReset: (() -> Unit)?) {
180     val primaryContainerColor = MaterialTheme.colorScheme.primaryContainer
181     TopAppBar(
182         colors =
183             TopAppBarDefaults.topAppBarColors(
184                 containerColor = Color.Transparent,
185                 titleContentColor = MaterialTheme.colorScheme.onSurface,
186             ),
187         title = {
188             Text(
189                 text = stringResource(id = R.string.qs_edit_tiles),
190                 style = MaterialTheme.typography.titleLargeEmphasized,
191                 modifier = Modifier.padding(start = 24.dp),
192             )
193         },
194         navigationIcon = {
195             IconButton(
196                 onClick = onStopEditing,
197                 modifier = Modifier.drawBehind { drawCircle(primaryContainerColor) },
198             ) {
199                 Icon(
200                     Icons.AutoMirrored.Filled.ArrowBack,
201                     tint = MaterialTheme.colorScheme.onSurface,
202                     contentDescription =
203                         stringResource(id = com.android.internal.R.string.action_bar_up_description),
204                 )
205             }
206         },
207         actions = {
208             if (onReset != null) {
209                 TextButton(
210                     onClick = onReset,
211                     colors =
212                         ButtonDefaults.textButtonColors(
213                             containerColor = MaterialTheme.colorScheme.primary,
214                             contentColor = MaterialTheme.colorScheme.onPrimary,
215                         ),
216                 ) {
217                     Text(
218                         text = stringResource(id = com.android.internal.R.string.reset),
219                         style = MaterialTheme.typography.labelLarge,
220                     )
221                 }
222             }
223         },
224         modifier = Modifier.padding(vertical = 8.dp),
225     )
226 }
227 
228 @Composable
DefaultEditTileGridnull229 fun DefaultEditTileGrid(
230     listState: EditTileListState,
231     otherTiles: List<SizedTile<EditTileViewModel>>,
232     columns: Int,
233     largeTilesSpan: Int,
234     modifier: Modifier,
235     onAddTile: (TileSpec, Int) -> Unit,
236     onRemoveTile: (TileSpec) -> Unit,
237     onSetTiles: (List<TileSpec>) -> Unit,
238     onResize: (TileSpec, toIcon: Boolean) -> Unit,
239     onStopEditing: () -> Unit,
240     onReset: (() -> Unit)?,
241 ) {
242     val selectionState = rememberSelectionState()
243     val reset: (() -> Unit)? =
244         if (onReset != null) {
245             {
246                 selectionState.unSelect()
247                 onReset()
248             }
249         } else {
250             null
251         }
252 
253     LaunchedEffect(selectionState.placementEvent) {
254         selectionState.placementEvent?.let { event ->
255             listState
256                 .targetIndexForPlacement(event)
257                 .takeIf { it != INVALID_INDEX }
258                 ?.let { onAddTile(event.movingSpec, it) }
259         }
260     }
261 
262     Scaffold(
263         containerColor = Color.Transparent,
264         topBar = { EditModeTopBar(onStopEditing = onStopEditing, onReset = reset) },
265     ) { innerPadding ->
266         CompositionLocalProvider(
267             LocalOverscrollFactory provides rememberOffsetOverscrollEffectFactory()
268         ) {
269             val scrollState = rememberScrollState()
270 
271             AutoScrollGrid(listState, scrollState, innerPadding)
272 
273             LaunchedEffect(listState.dragType) {
274                 // Only scroll to the top when adding a new tile, not when reordering existing ones
275                 if (listState.dragInProgress && listState.dragType == DragType.Add) {
276                     scrollState.animateScrollTo(0)
277                 }
278             }
279 
280             Column(
281                 verticalArrangement =
282                     spacedBy(dimensionResource(id = R.dimen.qs_label_container_margin)),
283                 modifier =
284                     modifier
285                         .fillMaxSize()
286                         // Apply top padding before the scroll so the scrollable doesn't show under
287                         // the top bar
288                         .padding(top = innerPadding.calculateTopPadding())
289                         .clipScrollableContainer(Orientation.Vertical)
290                         .verticalScroll(scrollState)
291                         .dragAndDropRemoveZone(listState) { spec, removalEnabled ->
292                             if (removalEnabled) {
293                                 // If removal is enabled, remove the tile
294                                 onRemoveTile(spec)
295                             } else {
296                                 // Otherwise submit the new tile ordering
297                                 onSetTiles(listState.tileSpecs())
298                                 selectionState.select(spec)
299                             }
300                         },
301             ) {
302                 CurrentTilesGridHeader(
303                     listState,
304                     selectionState,
305                     onRemoveTile,
306                     modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
307                 )
308 
309                 CurrentTilesGrid(
310                     listState,
311                     selectionState,
312                     columns,
313                     largeTilesSpan,
314                     onResize,
315                     onRemoveTile,
316                     onSetTiles,
317                 )
318 
319                 // Sets a minimum height to be used when available tiles are hidden
320                 Box(
321                     Modifier.fillMaxWidth()
322                         .requiredHeightIn(AvailableTilesGridMinHeight)
323                         .animateContentSize()
324                 ) {
325                     // Using the fully qualified name here as a workaround for AnimatedVisibility
326                     // not being available from a Box
327                     androidx.compose.animation.AnimatedVisibility(
328                         visible = !listState.dragInProgress && !selectionState.placementEnabled,
329                         enter = fadeIn(),
330                         exit = fadeOut(),
331                     ) {
332                         // Hide available tiles when dragging
333                         Column(
334                             verticalArrangement =
335                                 spacedBy(dimensionResource(id = R.dimen.qs_label_container_margin)),
336                             modifier = modifier.fillMaxSize(),
337                         ) {
338                             val availableTiles = remember {
339                                 mutableStateListOf<AvailableTileGridCell>().apply {
340                                     addAll(toAvailableTiles(listState.tiles, otherTiles))
341                                 }
342                             }
343                             LaunchedEffect(listState.tiles, otherTiles) {
344                                 availableTiles.apply {
345                                     clear()
346                                     addAll(toAvailableTiles(listState.tiles, otherTiles))
347                                 }
348                             }
349                             AvailableTileGrid(
350                                 availableTiles,
351                                 selectionState,
352                                 columns,
353                                 { onAddTile(it, listState.tileSpecs().size) }, // Add to the end
354                                 listState,
355                             )
356                         }
357                     }
358                 }
359             }
360         }
361     }
362 }
363 
364 @Composable
AutoScrollGridnull365 private fun AutoScrollGrid(
366     listState: EditTileListState,
367     scrollState: ScrollState,
368     padding: PaddingValues,
369 ) {
370     val density = LocalDensity.current
371     val (top, bottom) =
372         remember(density) {
373             with(density) {
374                 padding.calculateTopPadding().roundToPx() to
375                     padding.calculateBottomPadding().roundToPx()
376             }
377         }
378     val scrollTarget by
379         remember(listState, scrollState, top, bottom) {
380             derivedStateOf {
381                 val position = listState.draggedPosition
382                 if (position.isSpecified) {
383                     // Return the scroll target needed based on the position of the drag movement,
384                     // or null if we don't need to scroll
385                     val y = position.y.roundToInt()
386                     when {
387                         y < AUTO_SCROLL_DISTANCE + top -> 0
388                         y > scrollState.viewportSize - bottom - AUTO_SCROLL_DISTANCE ->
389                             scrollState.maxValue
390                         else -> null
391                     }
392                 } else {
393                     null
394                 }
395             }
396         }
397     LaunchedEffect(scrollTarget) {
398         scrollTarget?.let {
399             // Change the duration of the animation based on the distance to maintain the
400             // same scrolling speed
401             val distance = abs(it - scrollState.value)
402             scrollState.animateScrollTo(
403                 it,
404                 animationSpec =
405                     tween(durationMillis = distance * AUTO_SCROLL_SPEED, easing = LinearEasing),
406             )
407         }
408     }
409 }
410 
411 private enum class EditModeHeaderState {
412     Remove,
413     Place,
414     Idle,
415 }
416 
417 @Composable
rememberEditModeStatenull418 private fun rememberEditModeState(
419     listState: EditTileListState,
420     selectionState: MutableSelectionState,
421 ): State<EditModeHeaderState> {
422     val editGridHeaderState = remember { mutableStateOf(EditModeHeaderState.Idle) }
423     LaunchedEffect(
424         listState.dragInProgress,
425         selectionState.selected,
426         selectionState.placementEnabled,
427     ) {
428         val canRemove =
429             listState.isDraggedCellRemovable ||
430                 selectionState.selection?.let { listState.isRemovable(it) } ?: false
431 
432         editGridHeaderState.value =
433             when {
434                 selectionState.placementEnabled -> EditModeHeaderState.Place
435                 canRemove -> EditModeHeaderState.Remove
436                 else -> EditModeHeaderState.Idle
437             }
438     }
439 
440     return editGridHeaderState
441 }
442 
443 @Composable
CurrentTilesGridHeadernull444 private fun CurrentTilesGridHeader(
445     listState: EditTileListState,
446     selectionState: MutableSelectionState,
447     onRemoveTile: (TileSpec) -> Unit,
448     modifier: Modifier = Modifier,
449 ) {
450     val editGridHeaderState by rememberEditModeState(listState, selectionState)
451 
452     AnimatedContent(
453         targetState = editGridHeaderState,
454         label = "QSEditHeader",
455         contentAlignment = Alignment.Center,
456         modifier = modifier,
457     ) { state ->
458         EditGridHeader {
459             when (state) {
460                 EditModeHeaderState.Remove -> {
461                     RemoveTileTarget {
462                         selectionState.selection?.let {
463                             selectionState.unSelect()
464                             onRemoveTile(it)
465                         }
466                     }
467                 }
468                 EditModeHeaderState.Place -> {
469                     EditGridCenteredText(text = stringResource(id = R.string.tap_to_position_tile))
470                 }
471                 EditModeHeaderState.Idle -> {
472                     EditGridCenteredText(
473                         text = stringResource(id = R.string.drag_to_rearrange_tiles)
474                     )
475                 }
476             }
477         }
478     }
479 }
480 
481 @Composable
EditGridHeadernull482 private fun EditGridHeader(
483     modifier: Modifier = Modifier,
484     content: @Composable BoxScope.() -> Unit,
485 ) {
486     CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
487         Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxWidth()) { content() }
488     }
489 }
490 
491 @Composable
EditGridCenteredTextnull492 private fun EditGridCenteredText(text: String, modifier: Modifier = Modifier) {
493     Text(text = text, style = MaterialTheme.typography.titleSmall, modifier = modifier)
494 }
495 
496 @Composable
RemoveTileTargetnull497 private fun RemoveTileTarget(onClick: () -> Unit) {
498     Row(
499         verticalAlignment = Alignment.CenterVertically,
500         horizontalArrangement = tileHorizontalArrangement(),
501         modifier =
502             Modifier.wrapContentSize()
503                 .clickable(onClick = onClick)
504                 .border(1.dp, LocalContentColor.current, shape = CircleShape)
505                 .padding(10.dp),
506     ) {
507         Icon(imageVector = Icons.Default.Clear, contentDescription = null)
508         Text(text = stringResource(id = R.string.qs_customize_remove))
509     }
510 }
511 
512 @Composable
CurrentTilesGridnull513 private fun CurrentTilesGrid(
514     listState: EditTileListState,
515     selectionState: MutableSelectionState,
516     columns: Int,
517     largeTilesSpan: Int,
518     onResize: (TileSpec, toIcon: Boolean) -> Unit,
519     onRemoveTile: (TileSpec) -> Unit,
520     onSetTiles: (List<TileSpec>) -> Unit,
521 ) {
522     val currentListState by rememberUpdatedState(listState)
523     val totalRows = listState.tiles.lastOrNull()?.row ?: 0
524     val totalHeight by
525         animateDpAsState(
526             gridHeight(totalRows + 1, TileHeight, TileArrangementPadding, CurrentTilesGridPadding),
527             label = "QSEditCurrentTilesGridHeight",
528         )
529     val gridState = rememberLazyGridState()
530     var gridContentOffset by remember { mutableStateOf(Offset(0f, 0f)) }
531     val coroutineScope = rememberCoroutineScope()
532 
533     val cells = listState.tiles
534     val primaryColor = MaterialTheme.colorScheme.primary
535     TileLazyGrid(
536         state = gridState,
537         columns = GridCells.Fixed(columns),
538         contentPadding = PaddingValues(CurrentTilesGridPadding),
539         modifier =
540             Modifier.fillMaxWidth()
541                 .height { totalHeight.roundToPx() }
542                 .border(
543                     width = 2.dp,
544                     color = primaryColor,
545                     shape = RoundedCornerShape(GridBackgroundCornerRadius),
546                 )
547                 .dragAndDropTileList(gridState, { gridContentOffset }, listState) { spec ->
548                     onSetTiles(currentListState.tileSpecs())
549                     selectionState.select(spec)
550                 }
551                 .onGloballyPositioned { coordinates ->
552                     gridContentOffset = coordinates.positionInRoot()
553                 }
554                 .drawBehind {
555                     drawRoundRect(
556                         primaryColor,
557                         cornerRadius = CornerRadius(GridBackgroundCornerRadius.toPx()),
558                         alpha = .15f,
559                     )
560                 }
561                 .testTag(CURRENT_TILES_GRID_TEST_TAG),
562     ) {
563         EditTiles(
564             cells = cells,
565             dragAndDropState = listState,
566             selectionState = selectionState,
567             coroutineScope = coroutineScope,
568             largeTilesSpan = largeTilesSpan,
569             onRemoveTile = onRemoveTile,
570         ) { resizingOperation ->
571             when (resizingOperation) {
572                 is TemporaryResizeOperation -> {
573                     currentListState.resizeTile(resizingOperation.spec, resizingOperation.toIcon)
574                 }
575                 is FinalResizeOperation -> {
576                     // Commit the new size of the tile
577                     onResize(resizingOperation.spec, resizingOperation.toIcon)
578                 }
579             }
580         }
581     }
582 }
583 
584 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
585 @Composable
AvailableTileGridnull586 private fun AvailableTileGrid(
587     tiles: List<AvailableTileGridCell>,
588     selectionState: MutableSelectionState,
589     columns: Int,
590     onAddTile: (TileSpec) -> Unit,
591     dragAndDropState: DragAndDropState,
592 ) {
593     // Available tiles aren't visible during drag and drop, so the row/col isn't needed
594     val groupedTiles =
595         remember(tiles.fastMap { it.tile.category }, tiles.fastMap { it.tile.label }) {
596             groupAndSort(tiles)
597         }
598 
599     // Available tiles
600     Column(
601         verticalArrangement = spacedBy(TileArrangementPadding),
602         horizontalAlignment = Alignment.Start,
603         modifier =
604             Modifier.fillMaxWidth().wrapContentHeight().testTag(AVAILABLE_TILES_GRID_TEST_TAG),
605     ) {
606         groupedTiles.forEach { (category, tiles) ->
607             key(category) {
608                 val surfaceColor = MaterialTheme.colorScheme.surface
609                 Column(
610                     verticalArrangement = spacedBy(16.dp),
611                     modifier =
612                         Modifier.drawBehind {
613                                 drawRoundRect(
614                                     surfaceColor,
615                                     cornerRadius = CornerRadius(GridBackgroundCornerRadius.toPx()),
616                                     alpha = .32f,
617                                 )
618                             }
619                             .padding(16.dp),
620                 ) {
621                     CategoryHeader(
622                         category,
623                         modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
624                     )
625                     tiles.chunked(columns).forEach { row ->
626                         Row(
627                             horizontalArrangement = spacedBy(TileArrangementPadding),
628                             modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Max),
629                         ) {
630                             row.forEach { tileGridCell ->
631                                 key(tileGridCell.key) {
632                                     AvailableTileGridCell(
633                                         cell = tileGridCell,
634                                         dragAndDropState = dragAndDropState,
635                                         selectionState = selectionState,
636                                         onAddTile = onAddTile,
637                                         modifier = Modifier.weight(1f).fillMaxHeight(),
638                                     )
639                                 }
640                             }
641 
642                             // Spacers for incomplete rows
643                             repeat(columns - row.size) { Spacer(modifier = Modifier.weight(1f)) }
644                         }
645                     }
646                 }
647             }
648         }
649     }
650 }
651 
gridHeightnull652 fun gridHeight(rows: Int, tileHeight: Dp, tilePadding: Dp, gridPadding: Dp): Dp {
653     return ((tileHeight + tilePadding) * rows) + gridPadding * 2
654 }
655 
keynull656 private fun GridCell.key(index: Int): Any {
657     return if (this is TileGridCell) key else index
658 }
659 
660 /**
661  * Adds a list of [GridCell] to the lazy grid
662  *
663  * @param cells the list of [GridCell]
664  * @param dragAndDropState the [DragAndDropState] for this grid
665  * @param selectionState the [MutableSelectionState] for this grid
666  * @param coroutineScope the [CoroutineScope] to be used for the tiles
667  * @param largeTilesSpan the width used for large tiles
668  * @param onRemoveTile the callback when a tile is removed from this grid
669  * @param onResize the callback when a tile has a new [ResizeOperation]
670  */
EditTilesnull671 fun LazyGridScope.EditTiles(
672     cells: List<GridCell>,
673     dragAndDropState: DragAndDropState,
674     selectionState: MutableSelectionState,
675     coroutineScope: CoroutineScope,
676     largeTilesSpan: Int,
677     onRemoveTile: (TileSpec) -> Unit,
678     onResize: (operation: ResizeOperation) -> Unit,
679 ) {
680     items(
681         count = cells.size,
682         key = { cells[it].key(it) },
683         span = { cells[it].span },
684         contentType = { TileType },
685     ) { index ->
686         when (val cell = cells[index]) {
687             is TileGridCell ->
688                 if (dragAndDropState.isMoving(cell.tile.tileSpec)) {
689                     // If the tile is being moved, replace it with a visible spacer
690                     SpacerGridCell(
691                         Modifier.background(
692                             color =
693                                 MaterialTheme.colorScheme.secondary.copy(
694                                     alpha = EditModeTileDefaults.PLACEHOLDER_ALPHA
695                                 ),
696                             shape = RoundedCornerShape(InactiveCornerRadius),
697                         )
698                     )
699                 } else {
700                     TileGridCell(
701                         cell = cell,
702                         index = index,
703                         dragAndDropState = dragAndDropState,
704                         selectionState = selectionState,
705                         onResize = onResize,
706                         onRemoveTile = onRemoveTile,
707                         coroutineScope = coroutineScope,
708                         largeTilesSpan = largeTilesSpan,
709                         modifier =
710                             Modifier.animateItem(
711                                 placementSpec =
712                                     spring(
713                                         stiffness = Spring.StiffnessMediumLow,
714                                         dampingRatio = Spring.DampingRatioLowBouncy,
715                                         visibilityThreshold = IntOffset.VisibilityThreshold,
716                                     )
717                             ),
718                     )
719                 }
720             is SpacerGridCell ->
721                 SpacerGridCell(
722                     Modifier.pointerInput(Unit) {
723                         detectTapGestures(onTap = { selectionState.onTap(index) })
724                     }
725                 )
726         }
727     }
728 }
729 
730 @Composable
rememberTileStatenull731 private fun rememberTileState(
732     tile: EditTileViewModel,
733     selectionState: MutableSelectionState,
734 ): State<TileState> {
735     val tileState = remember { mutableStateOf(TileState.None) }
736     val canShowRemovalBadge = tile.isRemovable
737 
738     LaunchedEffect(selectionState.selection, selectionState.placementEnabled, canShowRemovalBadge) {
739         tileState.value =
740             selectionState.tileStateFor(tile.tileSpec, tileState.value, canShowRemovalBadge)
741     }
742 
743     return tileState
744 }
745 
746 @Composable
TileGridCellnull747 private fun TileGridCell(
748     cell: TileGridCell,
749     index: Int,
750     dragAndDropState: DragAndDropState,
751     selectionState: MutableSelectionState,
752     onResize: (operation: ResizeOperation) -> Unit,
753     onRemoveTile: (TileSpec) -> Unit,
754     coroutineScope: CoroutineScope,
755     largeTilesSpan: Int,
756     modifier: Modifier = Modifier,
757 ) {
758     val stateDescription = stringResource(id = R.string.accessibility_qs_edit_position, index + 1)
759     val tileState by rememberTileState(cell.tile, selectionState)
760     val resizingState = rememberResizingState(cell.tile.tileSpec, cell.isIcon)
761     val progress: () -> Float = {
762         if (tileState == TileState.Selected) {
763             resizingState.progress()
764         } else {
765             if (cell.isIcon) 0f else 1f
766         }
767     }
768 
769     if (tileState != TileState.Selected) {
770         // Update the draggable anchor state when the tile's size is not manually toggled
771         LaunchedEffect(cell.isIcon) { resizingState.updateCurrentValue(cell.isIcon) }
772     } else {
773         // If the tile is selected, listen to new target values from the draggable anchor to toggle
774         // the tile's size
775         LaunchedEffect(resizingState.temporaryResizeOperation) {
776             onResize(resizingState.temporaryResizeOperation)
777         }
778         LaunchedEffect(resizingState.finalResizeOperation) {
779             onResize(resizingState.finalResizeOperation)
780         }
781     }
782 
783     val totalPadding =
784         with(LocalDensity.current) { (largeTilesSpan - 1) * TileArrangementPadding.roundToPx() }
785     val colors = EditModeTileDefaults.editTileColors()
786     val toggleSizeLabel = stringResource(R.string.accessibility_qs_edit_toggle_tile_size_action)
787     val togglePlacementModeLabel =
788         stringResource(R.string.accessibility_qs_edit_toggle_placement_mode)
789     val decorationClickLabel =
790         when (tileState) {
791             TileState.Removable ->
792                 stringResource(id = R.string.accessibility_qs_edit_remove_tile_action)
793             TileState.Selected -> toggleSizeLabel
794             TileState.None,
795             TileState.Placeable,
796             TileState.GreyedOut -> null
797         }
798     InteractiveTileContainer(
799         tileState = tileState,
800         resizingState = resizingState,
801         modifier =
802             modifier.height(TileHeight).fillMaxWidth().onSizeChanged {
803                 // Calculate the min/max width from the idle size
804                 val min = if (cell.isIcon) it.width else (it.width - totalPadding) / largeTilesSpan
805                 val max = if (cell.isIcon) (it.width * largeTilesSpan) + totalPadding else it.width
806                 resizingState.updateAnchors(min.toFloat(), max.toFloat())
807             },
808         onClick = {
809             if (tileState == TileState.Removable) {
810                 onRemoveTile(cell.tile.tileSpec)
811             } else if (tileState == TileState.Selected) {
812                 coroutineScope.launch { resizingState.toggleCurrentValue() }
813             }
814         },
815         onClickLabel = decorationClickLabel,
816     ) {
817         val placeableColor = MaterialTheme.colorScheme.primary.copy(alpha = .4f)
818         val backgroundColor by
819             animateColorAsState(
820                 if (tileState == TileState.Placeable) placeableColor else colors.background
821             )
822         Box(
823             modifier
824                 .fillMaxSize()
825                 .semantics(mergeDescendants = true) {
826                     this.stateDescription = stateDescription
827                     contentDescription = cell.tile.label.text
828                     customActions =
829                         listOf(
830                             // TODO(b/367748260): Add final accessibility actions
831                             CustomAccessibilityAction(toggleSizeLabel) {
832                                 onResize(FinalResizeOperation(cell.tile.tileSpec, !cell.isIcon))
833                                 true
834                             },
835                             CustomAccessibilityAction(togglePlacementModeLabel) {
836                                 selectionState.togglePlacementMode(cell.tile.tileSpec)
837                                 true
838                             },
839                         )
840                 }
841                 .selectableTile(cell.tile.tileSpec, selectionState)
842                 .dragAndDropTileSource(
843                     SizedTileImpl(cell.tile, cell.width),
844                     dragAndDropState,
845                     DragType.Move,
846                     selectionState::unSelect,
847                 )
848                 .tileBackground { backgroundColor }
849         ) {
850             EditTile(
851                 tile = cell.tile,
852                 tileState = tileState,
853                 state = resizingState,
854                 progress = progress,
855             )
856         }
857     }
858 }
859 
860 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
861 @Composable
CategoryHeadernull862 private fun CategoryHeader(category: TileCategory, modifier: Modifier = Modifier) {
863     Row(
864         verticalAlignment = Alignment.CenterVertically,
865         horizontalArrangement = spacedBy(8.dp),
866         modifier = modifier,
867     ) {
868         Icon(
869             painter = painterResource(category.iconId),
870             contentDescription = null,
871             tint = MaterialTheme.colorScheme.onSurface,
872         )
873         Text(
874             text = category.label.load() ?: "",
875             style = MaterialTheme.typography.titleMediumEmphasized,
876             color = MaterialTheme.colorScheme.onSurface,
877         )
878     }
879 }
880 
881 @Composable
AvailableTileGridCellnull882 private fun AvailableTileGridCell(
883     cell: AvailableTileGridCell,
884     dragAndDropState: DragAndDropState,
885     selectionState: MutableSelectionState,
886     onAddTile: (TileSpec) -> Unit,
887     modifier: Modifier = Modifier,
888 ) {
889     val stateDescription: String? =
890         if (cell.isAvailable) null
891         else stringResource(R.string.accessibility_qs_edit_tile_already_added)
892 
893     val alpha by animateFloatAsState(if (cell.isAvailable) 1f else .38f)
894     val colors = EditModeTileDefaults.editTileColors()
895 
896     // Displays the tile as an icon tile with the label underneath
897     Column(
898         horizontalAlignment = Alignment.CenterHorizontally,
899         verticalArrangement = spacedBy(CommonTileDefaults.TileStartPadding, Alignment.Top),
900         modifier =
901             modifier
902                 .graphicsLayer { this.alpha = alpha }
903                 .semantics(mergeDescendants = true) {
904                     stateDescription?.let { this.stateDescription = it }
905                 },
906     ) {
907         Box(Modifier.fillMaxWidth().height(TileHeight)) {
908             val draggableModifier =
909                 if (cell.isAvailable) {
910                     Modifier.dragAndDropTileSource(
911                         SizedTileImpl(cell.tile, cell.width),
912                         dragAndDropState,
913                         DragType.Add,
914                     ) {
915                         selectionState.unSelect()
916                     }
917                 } else {
918                     Modifier
919                 }
920             Box(draggableModifier.fillMaxSize().tileBackground { colors.background }) {
921                 // Icon
922                 SmallTileContent(
923                     iconProvider = { cell.tile.icon },
924                     color = colors.icon,
925                     animateToEnd = true,
926                     modifier = Modifier.align(Alignment.Center),
927                 )
928             }
929 
930             StaticTileBadge(
931                 icon = Icons.Default.Add,
932                 contentDescription =
933                     stringResource(id = R.string.accessibility_qs_edit_tile_add_action),
934                 enabled = cell.isAvailable,
935             ) {
936                 onAddTile(cell.tile.tileSpec)
937                 selectionState.select(cell.tile.tileSpec)
938             }
939         }
940         Box(Modifier.fillMaxSize()) {
941             Text(
942                 cell.tile.label.text,
943                 maxLines = 2,
944                 color = colors.label,
945                 overflow = TextOverflow.Ellipsis,
946                 textAlign = TextAlign.Center,
947                 style = MaterialTheme.typography.labelMedium.copy(hyphens = Hyphens.Auto),
948                 modifier = Modifier.align(Alignment.TopCenter),
949             )
950         }
951     }
952 }
953 
954 @Composable
SpacerGridCellnull955 private fun SpacerGridCell(modifier: Modifier = Modifier) {
956     // By default, spacers are invisible and exist purely to catch drag movements
957     Box(modifier.height(TileHeight).fillMaxWidth())
958 }
959 
960 @Composable
EditTilenull961 fun EditTile(
962     tile: EditTileViewModel,
963     tileState: TileState,
964     state: ResizingState,
965     progress: () -> Float,
966     colors: TileColors = EditModeTileDefaults.editTileColors(),
967 ) {
968     val iconSizeDiff = CommonTileDefaults.IconSize - CommonTileDefaults.LargeTileIconSize
969     val alpha by animateFloatAsState(if (tileState == TileState.GreyedOut) .4f else 1f)
970     Row(
971         horizontalArrangement = spacedBy(6.dp),
972         verticalAlignment = Alignment.CenterVertically,
973         modifier =
974             Modifier.layout { measurable, constraints ->
975                     val (min, max) = state.bounds
976                     val currentProgress = progress()
977                     // Always display the tile using the large size and trust the parent composable
978                     // to clip the content as needed. This stop the labels from being truncated.
979                     val width =
980                         max?.roundToInt()?.takeIf { it > constraints.maxWidth }
981                             ?: constraints.maxWidth
982                     val placeable =
983                         measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
984 
985                     val startPadding =
986                         if (currentProgress == 0f) {
987                             // Find the center of the max width when the tile is icon only
988                             iconHorizontalCenter(constraints.maxWidth)
989                         } else {
990                             // Find the center of the minimum width to hold the same position as the
991                             // tile is resized.
992                             val basePadding =
993                                 min?.let { iconHorizontalCenter(it.roundToInt()) } ?: 0f
994                             // Large tiles, represented with a progress of 1f, have a 0.dp padding
995                             basePadding * (1f - currentProgress)
996                         }
997 
998                     layout(constraints.maxWidth, constraints.maxHeight) {
999                         placeable.place(startPadding.roundToInt(), 0)
1000                     }
1001                 }
1002                 .largeTilePadding()
1003                 .graphicsLayer { this.alpha = alpha },
1004     ) {
1005         // Icon
1006         Box(Modifier.size(ToggleTargetSize)) {
1007             SmallTileContent(
1008                 iconProvider = { tile.icon },
1009                 color = colors.icon,
1010                 animateToEnd = true,
1011                 size = { CommonTileDefaults.IconSize - iconSizeDiff * progress() },
1012                 modifier = Modifier.align(Alignment.Center),
1013             )
1014         }
1015 
1016         // Labels, positioned after the icon
1017         LargeTileLabels(
1018             label = tile.label.text,
1019             secondaryLabel = tile.appName?.text,
1020             colors = colors,
1021             modifier = Modifier.weight(1f).graphicsLayer { this.alpha = progress() },
1022         )
1023     }
1024 }
1025 
toAvailableTilesnull1026 private fun toAvailableTiles(
1027     currentTiles: List<GridCell>,
1028     otherTiles: List<SizedTile<EditTileViewModel>>,
1029 ): List<AvailableTileGridCell> {
1030     return currentTiles.filterIsInstance<TileGridCell>().fastMap {
1031         AvailableTileGridCell(it.tile, isAvailable = false)
1032     } + otherTiles.fastMap { AvailableTileGridCell(it.tile) }
1033 }
1034 
MeasureScopenull1035 private fun MeasureScope.iconHorizontalCenter(containerSize: Int): Float {
1036     return (containerSize - ToggleTargetSize.roundToPx()) / 2f -
1037         CommonTileDefaults.TileStartPadding.toPx()
1038 }
1039 
Modifiernull1040 private fun Modifier.tileBackground(color: () -> Color): Modifier {
1041     // Clip tile contents from overflowing past the tile
1042     return clip(RoundedCornerShape(InactiveCornerRadius)).drawBehind { drawRect(color()) }
1043 }
1044 
1045 private object EditModeTileDefaults {
1046     const val PLACEHOLDER_ALPHA = .3f
1047     const val AUTO_SCROLL_DISTANCE = 100
1048     const val AUTO_SCROLL_SPEED = 2 // 2ms per pixel
1049     val CurrentTilesGridPadding = 10.dp
1050     val AvailableTilesGridMinHeight = 200.dp
1051     val GridBackgroundCornerRadius = 42.dp
1052 
1053     @Composable
editTileColorsnull1054     fun editTileColors(): TileColors =
1055         TileColors(
1056             background = LocalAndroidColorScheme.current.surfaceEffect2,
1057             iconBackground = Color.Transparent,
1058             label = MaterialTheme.colorScheme.onSurface,
1059             secondaryLabel = MaterialTheme.colorScheme.onSurface,
1060             icon = MaterialTheme.colorScheme.onSurface,
1061         )
1062 }
1063 
1064 private const val CURRENT_TILES_GRID_TEST_TAG = "CurrentTilesGrid"
1065 private const val AVAILABLE_TILES_GRID_TEST_TAG = "AvailableTilesGrid"
1066