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