Small UI improvements for tiles
- Animate color change between states
- Add different colors for dual target tiles to show the inactive circle
- Better transition between icon and large tiles when resizing
- Different icon sizes for icon and large tiles
Test: manually
Flag: com.android.systemui.qs_ui_refactor_compose_fragment
Bug: 331601141
Change-Id: I32b09f561a4f8ef170bc5e1fa1082e91502d1065
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/Icon.kt b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/Icon.kt
index 6e83124..82d1436 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/Icon.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/Icon.kt
@@ -32,11 +32,7 @@
* Note: You can use [Color.Unspecified] to disable the tint and keep the original icon colors.
*/
@Composable
-fun Icon(
- icon: Icon,
- modifier: Modifier = Modifier,
- tint: Color = LocalContentColor.current,
-) {
+fun Icon(icon: Icon, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current) {
val contentDescription = icon.contentDescription?.load()
when (icon) {
is Icon.Loaded -> {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt
index 978a353..d107222 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt
@@ -18,12 +18,12 @@
import android.graphics.drawable.Animatable
import android.text.TextUtils
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -32,8 +32,9 @@
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -44,6 +45,7 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Shape
@@ -57,7 +59,9 @@
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import com.android.compose.modifiers.size
import com.android.compose.modifiers.thenIf
import com.android.systemui.Flags
import com.android.systemui.common.shared.model.Icon
@@ -88,12 +92,14 @@
) {
// Icon
val longPressLabel = longPressLabel().takeIf { onLongClick != null }
+ val animatedBackgroundColor by
+ animateColorAsState(colors.iconBackground, label = "QSTileDualTargetBackgroundColor")
Box(
modifier =
Modifier.size(CommonTileDefaults.ToggleTargetSize).thenIf(toggleClick != null) {
Modifier.clip(iconShape)
.verticalSquish(squishiness)
- .background(colors.iconBackground)
+ .drawBehind { drawRect(animatedBackgroundColor) }
.combinedClickable(
onClick = toggleClick!!,
onLongClick = onLongClick,
@@ -117,6 +123,7 @@
SmallTileContent(
icon = icon,
color = colors.icon,
+ size = { CommonTileDefaults.LargeTileIconSize },
modifier = Modifier.align(Alignment.Center),
)
}
@@ -139,18 +146,22 @@
modifier: Modifier = Modifier,
accessibilityUiState: AccessibilityUiState? = null,
) {
+ val animatedLabelColor by animateColorAsState(colors.label, label = "QSTileLabelColor")
+ val animatedSecondaryLabelColor by
+ animateColorAsState(colors.secondaryLabel, label = "QSTileSecondaryLabelColor")
Column(verticalArrangement = Arrangement.Center, modifier = modifier.fillMaxHeight()) {
- Text(
+ BasicText(
label,
style = MaterialTheme.typography.labelLarge,
- color = colors.label,
+ color = { animatedLabelColor },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!TextUtils.isEmpty(secondaryLabel)) {
- Text(
+ BasicText(
secondaryLabel ?: "",
- color = colors.secondaryLabel,
+ color = { animatedSecondaryLabelColor },
+ maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
modifier =
Modifier.thenIf(
@@ -170,9 +181,11 @@
modifier: Modifier = Modifier,
icon: Icon,
color: Color,
+ size: () -> Dp = { CommonTileDefaults.IconSize },
animateToEnd: Boolean = false,
) {
- val iconModifier = modifier.size(CommonTileDefaults.IconSize)
+ val animatedColor by animateColorAsState(color, label = "QSTileIconColor")
+ val iconModifier = modifier.size({ size().roundToPx() }, { size().roundToPx() })
val context = LocalContext.current
val loadedDrawable =
remember(icon, context) {
@@ -182,7 +195,7 @@
}
}
if (loadedDrawable !is Animatable) {
- Icon(icon = icon, tint = color, modifier = iconModifier)
+ Icon(icon = icon, tint = animatedColor, modifier = iconModifier)
} else if (icon is Icon.Resource) {
val image = AnimatedImageVector.animatedVectorResource(id = icon.res)
val painter =
@@ -198,14 +211,15 @@
Image(
painter = painter,
contentDescription = icon.contentDescription?.load(),
- colorFilter = ColorFilter.tint(color = color),
+ colorFilter = ColorFilter.tint(color = animatedColor),
modifier = iconModifier,
)
}
}
object CommonTileDefaults {
- val IconSize = 24.dp
+ val IconSize = 32.dp
+ val LargeTileIconSize = 28.dp
val ToggleTargetSize = 56.dp
val TileHeight = 72.dp
val TilePadding = 8.dp
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt
index 418ed0b..36a7b22 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt
@@ -20,6 +20,7 @@
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
@@ -54,7 +55,6 @@
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -69,21 +69,22 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
-import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
@@ -103,6 +104,7 @@
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastMap
+import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.compose.animation.bounceable
import com.android.compose.modifiers.height
import com.android.systemui.common.ui.compose.load
@@ -134,9 +136,10 @@
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.qs.shared.model.groupAndSort
import com.android.systemui.res.R
+import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
-import com.android.app.tracing.coroutines.launchTraced as launch
+import kotlinx.coroutines.flow.collectLatest
object TileType
@@ -409,7 +412,7 @@
/**
* Adds a list of [GridCell] to the lazy grid
*
- * @param cells the pairs of [GridCell] to [BounceableTileViewModel]
+ * @param cells the pairs of [GridCell] to [AnimatableTileViewModel]
* @param dragAndDropState the [DragAndDropState] for this grid
* @param selectionState the [MutableSelectionState] for this grid
* @param onToggleSize the callback when a tile's size is toggled
@@ -545,9 +548,27 @@
selectionState::unSelect,
)
.tileBackground(colors.background)
- .tilePadding()
) {
- EditTile(tile = cell.tile, iconOnly = cell.isIcon)
+ val targetValue = if (cell.isIcon) 0f else 1f
+ val animatedProgress = remember { Animatable(targetValue) }
+
+ if (selected) {
+ val resizingState = selectionState.resizingState
+ LaunchedEffect(targetValue, resizingState) {
+ if (resizingState == null) {
+ animatedProgress.animateTo(targetValue)
+ } else {
+ snapshotFlow { resizingState.progression }
+ .collectLatest { animatedProgress.snapTo(it) }
+ }
+ }
+ }
+
+ EditTile(
+ tile = cell.tile,
+ tileWidths = { tileWidths },
+ progress = { animatedProgress.value },
+ )
}
}
}
@@ -612,45 +633,72 @@
}
@Composable
-fun BoxScope.EditTile(
+fun EditTile(
tile: EditTileViewModel,
- iconOnly: Boolean,
+ tileWidths: () -> TileWidths?,
+ progress: () -> Float,
colors: TileColors = EditModeTileDefaults.editTileColors(),
) {
- // Animated horizontal alignment from center (0f) to start (-1f)
- val alignmentValue by
- animateFloatAsState(
- targetValue = if (iconOnly) 0f else -1f,
- label = "QSEditTileContentAlignment",
- )
- val alignment by remember {
- derivedStateOf { BiasAlignment(horizontalBias = alignmentValue, verticalBias = 0f) }
- }
- // Icon
- Box(Modifier.size(ToggleTargetSize).align(alignment)) {
- SmallTileContent(
- icon = tile.icon,
- color = colors.icon,
- animateToEnd = true,
- modifier = Modifier.align(Alignment.Center),
- )
- }
+ val iconSizeDiff = CommonTileDefaults.IconSize - CommonTileDefaults.LargeTileIconSize
+ Row(
+ horizontalArrangement = spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier.layout { measurable, constraints ->
+ // Always display the tile using the large size and trust the parent composable
+ // to clip the content as needed. This stop the labels from being truncated.
+ val width = tileWidths()?.max ?: constraints.maxWidth
+ val placeable =
+ measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
+ val currentProgress = progress()
+ val startPadding =
+ if (currentProgress == 0f) {
+ // Find the center of the max width when the tile is icon only
+ iconHorizontalCenter(constraints.maxWidth)
+ } else {
+ // Find the center of the minimum width to hold the same position as the
+ // tile is resized.
+ val basePadding =
+ tileWidths()?.min?.let { iconHorizontalCenter(it) } ?: 0f
+ // Large tiles, represented with a progress of 1f, have a 0.dp padding
+ basePadding * (1f - currentProgress)
+ }
- // Labels, positioned after the icon
- AnimatedVisibility(visible = !iconOnly, enter = fadeIn(), exit = fadeOut()) {
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ placeable.place(startPadding.roundToInt(), 0)
+ }
+ }
+ .tilePadding(),
+ ) {
+ // Icon
+ Box(Modifier.size(ToggleTargetSize)) {
+ SmallTileContent(
+ icon = tile.icon,
+ color = colors.icon,
+ animateToEnd = true,
+ size = { CommonTileDefaults.IconSize - iconSizeDiff * progress() },
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ // Labels, positioned after the icon
LargeTileLabels(
label = tile.label.text,
secondaryLabel = tile.appName?.text,
colors = colors,
- modifier = Modifier.padding(start = ToggleTargetSize + TileArrangementPadding),
+ modifier = Modifier.weight(1f).graphicsLayer { alpha = progress() },
)
}
}
+private fun MeasureScope.iconHorizontalCenter(containerSize: Int): Float {
+ return (containerSize - ToggleTargetSize.roundToPx()) / 2f -
+ CommonTileDefaults.TilePadding.toPx()
+}
+
private fun Modifier.tileBackground(color: Color): Modifier {
- return drawBehind {
- drawRoundRect(SolidColor(color), cornerRadius = CornerRadius(InactiveCornerRadius.toPx()))
- }
+ // Clip tile contents from overflowing past the tile
+ return clip(RoundedCornerShape(InactiveCornerRadius)).drawBehind { drawRect(color) }
}
private object EditModeTileDefaults {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt
index e1583e3..5bebdbc 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt
@@ -21,6 +21,7 @@
import android.content.res.Resources
import android.service.quicksettings.Tile.STATE_ACTIVE
import android.service.quicksettings.Tile.STATE_INACTIVE
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
@@ -61,6 +62,7 @@
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.compose.animation.Expandable
import com.android.compose.animation.bounceable
import com.android.compose.modifiers.thenIf
@@ -74,6 +76,7 @@
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.qs.panels.ui.compose.BounceableInfo
import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.InactiveCornerRadius
+import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.TileHeight
import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.longPressLabel
import com.android.systemui.qs.panels.ui.viewmodel.TileUiState
import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
@@ -82,7 +85,6 @@
import com.android.systemui.res.R
import java.util.function.Supplier
import kotlinx.coroutines.CoroutineScope
-import com.android.app.tracing.coroutines.launchTraced as launch
private const val TEST_TAG_SMALL = "qs_tile_small"
private const val TEST_TAG_LARGE = "qs_tile_large"
@@ -128,14 +130,18 @@
// TODO(b/361789146): Draw the shapes instead of clipping
val tileShape = TileDefaults.animateTileShape(uiState.state)
-
- TileExpandable(
- color =
+ val animatedColor by
+ animateColorAsState(
if (iconOnly || !uiState.handlesSecondaryClick) {
colors.iconBackground
} else {
colors.background
},
+ label = "QSTileBackgroundColor",
+ )
+
+ TileExpandable(
+ color = { animatedColor },
shape = tileShape,
squishiness = squishiness,
hapticsViewModel = hapticsViewModel,
@@ -212,7 +218,7 @@
@Composable
private fun TileExpandable(
- color: Color,
+ color: () -> Color,
shape: Shape,
squishiness: () -> Float,
hapticsViewModel: TileHapticsViewModel?,
@@ -220,7 +226,7 @@
content: @Composable (Expandable) -> Unit,
) {
Expandable(
- color = color,
+ color = color(),
shape = shape,
modifier = modifier.clip(shape).verticalSquish(squishiness),
) {
@@ -238,7 +244,7 @@
) {
Box(
modifier =
- Modifier.height(CommonTileDefaults.TileHeight)
+ Modifier.height(TileHeight)
.fillMaxWidth()
.tileCombinedClickable(
onClick = onClick,
@@ -336,6 +342,16 @@
)
@Composable
+ fun inactiveDualTargetTileColors(): TileColors =
+ TileColors(
+ background = MaterialTheme.colorScheme.surfaceVariant,
+ iconBackground = MaterialTheme.colorScheme.surfaceContainerHighest,
+ label = MaterialTheme.colorScheme.onSurfaceVariant,
+ secondaryLabel = MaterialTheme.colorScheme.onSurfaceVariant,
+ icon = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ @Composable
fun inactiveTileColors(): TileColors =
TileColors(
background = MaterialTheme.colorScheme.surfaceVariant,
@@ -365,7 +381,13 @@
activeTileColors()
}
}
- STATE_INACTIVE -> inactiveTileColors()
+ STATE_INACTIVE -> {
+ if (uiState.handlesSecondaryClick) {
+ inactiveDualTargetTileColors()
+ } else {
+ inactiveTileColors()
+ }
+ }
else -> unavailableTileColors()
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/ResizingState.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/ResizingState.kt
index a084bc2..9552aa9 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/ResizingState.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/ResizingState.kt
@@ -17,25 +17,30 @@
package com.android.systemui.qs.panels.ui.compose.selection
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import com.android.systemui.qs.panels.ui.compose.selection.ResizingDefaults.RESIZING_THRESHOLD
class ResizingState(private val widths: TileWidths, private val onResize: () -> Unit) {
- // Total drag offset of this resize operation
- private var totalOffset = 0f
+ /** Total drag offset of this resize operation. */
+ private var totalOffset by mutableFloatStateOf(0f)
/** Width in pixels of the resizing tile. */
var width by mutableIntStateOf(widths.base)
+ /** Progression between icon (0) and large (1) sizes. */
+ val progression
+ get() = calculateProgression()
+
// Whether the tile is currently over the threshold and should be a large tile
- private var passedThreshold: Boolean = passedThreshold(calculateProgression(width))
+ private var passedThreshold: Boolean = passedThreshold(progression)
fun onDrag(offset: Float) {
totalOffset += offset
width = (widths.base + totalOffset).toInt().coerceIn(widths.min, widths.max)
- passedThreshold(calculateProgression(width)).let {
+ passedThreshold(progression).let {
// Resize if we went over the threshold
if (passedThreshold != it) {
passedThreshold = it
@@ -49,7 +54,7 @@
}
/** The progression of the resizing tile between an icon tile (0f) and a large tile (1f) */
- private fun calculateProgression(width: Int): Float {
+ private fun calculateProgression(): Float {
return ((width - widths.min) / (widths.max - widths.min).toFloat()).coerceIn(0f, 1f)
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt
index 9f13a37..8a345ce 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt
@@ -16,7 +16,11 @@
package com.android.systemui.qs.panels.ui.compose.selection
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Box
@@ -78,7 +82,6 @@
ResizingHandle(
enabled = selected,
selectionState = selectionState,
- transition = selectionAlpha,
tileWidths = tileWidths,
modifier =
// Higher zIndex to make sure the handle is drawn above the content
@@ -91,7 +94,6 @@
private fun ResizingHandle(
enabled: Boolean,
selectionState: MutableSelectionState,
- transition: () -> Float,
tileWidths: () -> TileWidths?,
modifier: Modifier = Modifier,
) {
@@ -126,19 +128,24 @@
}
}
) {
- ResizingDot(transition = transition, modifier = Modifier.align(Alignment.Center))
+ ResizingDot(enabled = enabled, modifier = Modifier.align(Alignment.Center))
}
}
@Composable
private fun ResizingDot(
- transition: () -> Float,
+ enabled: Boolean,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
) {
+ val alpha by animateFloatAsState(if (enabled) 1f else 0f)
+ val radius by
+ animateDpAsState(
+ if (enabled) ResizingDotSize / 2 else 0.dp,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
+ )
Canvas(modifier = modifier.size(ResizingDotSize)) {
- val v = transition()
- drawCircle(color = color, radius = (ResizingDotSize / 2).toPx() * v, alpha = v)
+ drawCircle(color = color, radius = radius.toPx(), alpha = alpha)
}
}