Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 65198945 authored by Olivier St-Onge's avatar Olivier St-Onge
Browse files

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
parent ee829765
Loading
Loading
Loading
Loading
+1 −5
Original line number Diff line number Diff line
@@ -32,11 +32,7 @@ import com.android.systemui.common.shared.model.Icon
 * 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 -> {
+25 −11
Original line number Diff line number Diff line
@@ -18,12 +18,12 @@ package com.android.systemui.qs.panels.ui.compose.infinitegrid

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.Row
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.runtime.setValue
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.semantics
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 @@ fun LargeTileContent(
    ) {
        // 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 @@ fun LargeTileContent(
            SmallTileContent(
                icon = icon,
                color = colors.icon,
                size = { CommonTileDefaults.LargeTileIconSize },
                modifier = Modifier.align(Alignment.Center),
            )
        }
@@ -139,18 +146,22 @@ fun LargeTileLabels(
    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 @@ fun SmallTileContent(
    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 @@ fun SmallTileContent(
            }
        }
    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 @@ fun SmallTileContent(
        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
+83 −35
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ package com.android.systemui.qs.panels.ui.compose.infinitegrid

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.shape.RoundedCornerShape
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.material3.TopAppBarDefaults
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.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.panels.ui.viewmodel.EditTileViewModel
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 @@ private fun GridCell.key(index: Int, dragAndDropState: DragAndDropState): Any {
/**
 * 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 @@ private fun TileGridCell(
                    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 @@ private fun SpacerGridCell(modifier: Modifier = Modifier) {
}

@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) }
    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)
                        }

                    layout(constraints.maxWidth, constraints.maxHeight) {
                        placeable.place(startPadding.roundToInt(), 0)
                    }
                }
                .tilePadding(),
    ) {
        // Icon
    Box(Modifier.size(ToggleTargetSize).align(alignment)) {
        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
    AnimatedVisibility(visible = !iconOnly, enter = fadeIn(), exit = fadeOut()) {
        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 Modifier.tileBackground(color: Color): Modifier {
    return drawBehind {
        drawRoundRect(SolidColor(color), cornerRadius = CornerRadius(InactiveCornerRadius.toPx()))
private fun MeasureScope.iconHorizontalCenter(containerSize: Int): Float {
    return (containerSize - ToggleTargetSize.roundToPx()) / 2f -
        CommonTileDefaults.TilePadding.toPx()
}

private fun Modifier.tileBackground(color: Color): Modifier {
    // Clip tile contents from overflowing past the tile
    return clip(RoundedCornerShape(InactiveCornerRadius)).drawBehind { drawRect(color) }
}

private object EditModeTileDefaults {
+30 −8
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ package com.android.systemui.qs.panels.ui.compose.infinitegrid
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.semantics.toggleableState
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.lifecycle.rememberViewModel
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.qs.tileimpl.QSTileImpl
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 @@ fun Tile(

    // 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 @@ fun Tile(

@Composable
private fun TileExpandable(
    color: Color,
    color: () -> Color,
    shape: Shape,
    squishiness: () -> Float,
    hapticsViewModel: TileHapticsViewModel?,
@@ -220,7 +226,7 @@ private fun TileExpandable(
    content: @Composable (Expandable) -> Unit,
) {
    Expandable(
        color = color,
        color = color(),
        shape = shape,
        modifier = modifier.clip(shape).verticalSquish(squishiness),
    ) {
@@ -238,7 +244,7 @@ fun TileContainer(
) {
    Box(
        modifier =
            Modifier.height(CommonTileDefaults.TileHeight)
            Modifier.height(TileHeight)
                .fillMaxWidth()
                .tileCombinedClickable(
                    onClick = onClick,
@@ -335,6 +341,16 @@ private object TileDefaults {
            icon = MaterialTheme.colorScheme.onPrimary,
        )

    @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(
@@ -365,7 +381,13 @@ private object TileDefaults {
                    activeTileColors()
                }
            }
            STATE_INACTIVE -> inactiveTileColors()
            STATE_INACTIVE -> {
                if (uiState.handlesSecondaryClick) {
                    inactiveDualTargetTileColors()
                } else {
                    inactiveTileColors()
                }
            }
            else -> unavailableTileColors()
        }
    }
+10 −5
Original line number Diff line number Diff line
@@ -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 @@ class ResizingState(private val widths: TileWidths, private val onResize: () ->
    }

    /** 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)
    }
}
Loading