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

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

Use ButtonGroup within QS

This replaces the QS grid by a column of ButtonGroups, which provides the boucne animation on clicks and long clicks

Flag: com.android.systemui.qs_material_expressive_tiles
Bug: 419309247
Test: manually - pressing/long-pressing toggleable tiles
Test: TileBounceMotionTest.kt
Test: TileTest.kt
Change-Id: Ib18969bfa51f18b563c878ae52c50b6b5c7a5946
parent c9de048c
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -978,6 +978,16 @@ flag {
    }
}

flag {
    name: "qs_material_expressive_tiles"
    namespace: "systemui"
    description: "Uses the Material Expressive button groups for QS tiles."
    bug: "419309247"
    metadata {
      purpose: PURPOSE_BUGFIX
    }
}

flag {
    name: "qs_edit_mode_tabs"
    namespace: "systemui"
+48 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.qs.flags

import com.android.systemui.Flags
import com.android.systemui.flags.RefactorFlagUtils

/** Object to help check if QS is using Material Expressive tiles. */
@Suppress("NOTHING_TO_INLINE")
object QSMaterialExpressiveTiles {
    /** The aconfig flag name */
    const val FLAG_NAME = Flags.FLAG_QS_MATERIAL_EXPRESSIVE_TILES

    /** Is the tooltip enabled */
    @JvmStatic
    inline val isEnabled
        get() = Flags.qsMaterialExpressiveTiles()

    /**
     * Called to ensure code is only run when the flag is enabled. This protects users from the
     * unintended behaviors caused by accidentally running new logic, while also crashing on an eng
     * build to ensure that the refactor author catches issues in testing.
     */
    @JvmStatic
    inline fun isUnexpectedlyInLegacyMode() =
        RefactorFlagUtils.isUnexpectedlyInLegacyMode(isEnabled, FLAG_NAME)

    /**
     * Called to ensure code is only run when the flag is disabled. This will throw an exception if
     * the flag is enabled to ensure that the refactor author catches issues in testing.
     */
    @JvmStatic
    inline fun assertInLegacyMode() = RefactorFlagUtils.assertInLegacyMode(isEnabled, FLAG_NAME)
}
+54 −27
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import com.android.compose.animation.scene.ContentScope
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.grid.ui.compose.VerticalSpannedGrid
import com.android.systemui.qs.composefragment.ui.GridAnchor
import com.android.systemui.qs.flags.QSMaterialExpressiveTiles
import com.android.systemui.qs.panels.ui.compose.infinitegrid.Tile
import com.android.systemui.qs.panels.ui.viewmodel.BounceableTileViewModel
import com.android.systemui.qs.panels.ui.viewmodel.QuickQuickSettingsViewModel
@@ -42,18 +43,42 @@ fun ContentScope.QuickQuickSettings(
    modifier: Modifier = Modifier,
    listening: () -> Boolean,
) {

    val columns = viewModel.columns
    val sizedTiles = viewModel.tileViewModels
    val tiles = sizedTiles.fastMap { it.tile }
    val bounceables = remember(sizedTiles) { List(sizedTiles.size) { BounceableTileViewModel() } }
    val squishiness by viewModel.squishinessViewModel.squishiness.collectAsStateWithLifecycle()
    val scope = rememberCoroutineScope()

    val spans by remember(sizedTiles) { derivedStateOf { sizedTiles.fastMap { it.width } } }

    val columns = viewModel.columns
    Box(modifier = modifier) {
        GridAnchor()

        if (QSMaterialExpressiveTiles.isEnabled) {
            ButtonGroupGrid(
                sizedTiles = sizedTiles,
                columns = columns,
                keys = { it.spec },
                elementKey = { it.spec.toElementKey() },
                horizontalPadding = dimensionResource(R.dimen.qs_tile_margin_horizontal),
            ) { sizedTile, interactionSource ->
                Tile(
                    tile = sizedTile.tile,
                    iconOnly = sizedTile.isIcon,
                    squishiness = { squishiness },
                    coroutineScope = scope,
                    tileHapticsViewModelFactoryProvider =
                        viewModel.tileHapticsViewModelFactoryProvider,
                    // There should be no QuickQuickSettings when the details
                    // view is enabled.
                    detailsViewModel = null,
                    isVisible = listening,
                    bounceableInfo = null,
                    interactionSource = interactionSource,
                )
            }
        } else {
            val bounceables =
                remember(sizedTiles) { List(sizedTiles.size) { BounceableTileViewModel() } }
            val spans by remember(sizedTiles) { derivedStateOf { sizedTiles.fastMap { it.width } } }
            VerticalSpannedGrid(
                columns = columns,
                columnSpacing = dimensionResource(R.dimen.qs_tile_margin_horizontal),
@@ -63,7 +88,7 @@ fun ContentScope.QuickQuickSettings(
                keys = { sizedTiles[it].tile.spec },
            ) { spanIndex, column, isFirstInColumn, isLastInColumn ->
                val it = sizedTiles[spanIndex]
            Element(it.tile.spec.toElementKey(spanIndex), Modifier) {
                Element(it.tile.spec.toElementKey(), Modifier) {
                    Tile(
                        tile = it.tile,
                        iconOnly = it.isIcon,
@@ -83,10 +108,12 @@ fun ContentScope.QuickQuickSettings(
                        // There should be no QuickQuickSettings when the details view is enabled.
                        detailsViewModel = null,
                        isVisible = listening,
                        interactionSource = null,
                    )
                }
            }
        }
    }

    TileListener(tiles, listening)
}
+170 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.qs.panels.ui.compose

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastSumBy
import com.android.compose.animation.scene.ContentScope
import com.android.compose.animation.scene.ElementKey
import com.android.systemui.qs.panels.shared.model.SizedTile
import com.android.systemui.qs.panels.shared.model.splitInRowsSequence
import com.android.systemui.res.R

/**
 * A grid of [SizedTile] implemented using a column of [ButtonGroup].
 *
 * @param sizedTiles The list of [SizedTile] to display.
 * @param columns The number of columns in the grid.
 * @param horizontalPadding The horizontal padding of the grid.
 * @param keys A factory of stable and unique keys representing the item.
 * @param elementKey A factory for [ElementKey] for each tile.
 * @param modifier The [Modifier] to be applied to the grid.
 * @param tileContent The content of each tile. It receives the [SizedTile] to display and the
 *   [MutableInteractionSource] to use for animating the tile's width. Do not use this interaction
 *   source if the tile should not bounce.
 */
@Composable
fun <T> ContentScope.ButtonGroupGrid(
    sizedTiles: List<SizedTile<T>>,
    columns: Int,
    horizontalPadding: Dp,
    keys: (T) -> Any,
    elementKey: (T) -> ElementKey,
    modifier: Modifier = Modifier,
    tileContent:
        @Composable
        ContentScope.(tile: SizedTile<T>, interactionSource: MutableInteractionSource) -> Unit,
) {
    val rows = remember(sizedTiles, columns) { splitInRowsSequence(sizedTiles, columns).toList() }
    Column(
        verticalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_vertical)),
        modifier = modifier,
    ) {
        for (row in rows) {
            key(row.fastMap { keys(it.tile) }) {
                ButtonGroupRow(
                    row,
                    columns,
                    horizontalPadding,
                    keys,
                    elementKey,
                    tileContent = tileContent,
                )
            }
        }
    }
}

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun <T> ContentScope.ButtonGroupRow(
    row: List<SizedTile<T>>,
    columns: Int,
    horizontalPadding: Dp,
    keys: (T) -> Any,
    elementKey: (T) -> ElementKey,
    modifier: Modifier = Modifier,
    tileContent:
        @Composable
        ContentScope.(tile: SizedTile<T>, interactionSource: MutableInteractionSource) -> Unit,
) {
    val halfPadding = horizontalPadding / 2

    // Avoid setting the horizontal padding with the ButtonGroup to ensure that weight distribution
    // works properly for all rows.
    ButtonGroup(
        overflowIndicator = {},
        horizontalArrangement = spacedBy(0.dp),
        modifier = modifier.fillMaxWidth(),
    ) {
        for ((indexInRow, sizedTile) in row.withIndex()) {
            val column = row.subList(0, indexInRow).fastSumBy { it.width }
            val onLastColumn = column == columns - sizedTile.width
            val isFirst = indexInRow == 0
            customItem(
                buttonGroupContent = {
                    key(keys(sizedTile.tile)) {
                        // This interaction source may not be used by tiles that shouldn't bounce.
                        val interactionSource = remember { MutableInteractionSource() }
                        Element(
                            elementKey(sizedTile.tile),
                            Modifier.animateWidth(interactionSource)
                                .weight(sizedTile.width.toFloat())
                                .rowPadding(isFirst, onLastColumn, halfPadding),
                        ) {
                            tileContent(sizedTile, interactionSource)
                        }
                    }
                },
                menuContent = {},
            )
        }

        // If the row isn't filled, add a spacer
        val columnsLeft = columns - row.fastSumBy { it.width }
        if (columnsLeft > 0) {
            customItem(
                buttonGroupContent = {
                    Spacer(
                        Modifier.weight(columnsLeft.toFloat())
                            .rowPadding(isFirst = false, onLastColumn = true, halfPadding)
                    )
                },
                menuContent = {},
            )
        }
    }
}

/**
 * Adds padding to a Composable within a row, adjusting based on its position within that row.
 *
 * This modifier is useful for creating consistent spacing between items in a row, especially when
 * you want to avoid extra padding at the beginning or end of the row.
 *
 * @param isFirst True if this is the first item in the row. If true, no start padding is applied.
 * @param onLastColumn True if this item is in the last column of the row. The last element of a row
 *   may not be on the last column. If true, no end padding is applied.
 * @param horizontalPadding The amount of padding to apply to the start (if not first) and end (if
 *   not last) of the item.
 * @return A [Modifier] that applies the calculated padding.
 */
private fun Modifier.rowPadding(
    isFirst: Boolean,
    onLastColumn: Boolean,
    horizontalPadding: Dp,
): Modifier {
    return padding(
        start = if (isFirst) 0.dp else horizontalPadding,
        end = if (onLastColumn) 0.dp else horizontalPadding,
    )
}
+64 −25
Original line number Diff line number Diff line
@@ -40,8 +40,10 @@ import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.grid.ui.compose.VerticalSpannedGrid
import com.android.systemui.haptics.msdl.qs.TileHapticsViewModelFactoryProvider
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.qs.flags.QSMaterialExpressiveTiles
import com.android.systemui.qs.flags.QsEditModeTabs
import com.android.systemui.qs.panels.shared.model.SizedTileImpl
import com.android.systemui.qs.panels.ui.compose.ButtonGroupGrid
import com.android.systemui.qs.panels.ui.compose.EditTileListState
import com.android.systemui.qs.panels.ui.compose.PaginatableGridLayout
import com.android.systemui.qs.panels.ui.compose.TileListener
@@ -100,16 +102,51 @@ constructor(
                    SizedTileImpl(it, if (largeTiles.contains(it.spec)) largeTilesSpan else 1)
                }
            }
        val bounceables =
            remember(sizedTiles) { List(sizedTiles.size) { BounceableTileViewModel() } }
        val squishiness by viewModel.squishinessViewModel.squishiness.collectAsStateWithLifecycle()
        val scope = rememberCoroutineScope()
        val spans by remember(sizedTiles) { derivedStateOf { sizedTiles.fastMap { it.width } } }

        val motionBuilderContext = rememberMotionBuilderContext()
        val marginBottom =
            with(LocalDensity.current) { QuickSettingsShade.Dimensions.Padding.toPx() }

        if (QSMaterialExpressiveTiles.isEnabled) {
            ButtonGroupGrid(
                sizedTiles = sizedTiles,
                columns = columns,
                keys = { it.spec },
                elementKey = { it.spec.toElementKey() },
                horizontalPadding = dimensionResource(R.dimen.qs_tile_margin_horizontal),
                modifier = modifier,
            ) { sizedTile, interactionSource ->
                Tile(
                    tile = sizedTile.tile,
                    iconOnly = iconTilesViewModel.isIconTile(sizedTile.tile.spec),
                    squishiness = { squishiness },
                    tileHapticsViewModelFactoryProvider = tileHapticsViewModelFactoryProvider,
                    coroutineScope = scope,
                    detailsViewModel = detailsViewModel,
                    isVisible = listening,
                    requestToggleTextFeedback = textFeedbackViewModel::requestShowFeedback,
                    modifier =
                        if (revealEffectContainer != null) {
                            Modifier.verticalTactileSurfaceReveal(
                                contentScope = this@TileGrid,
                                motionBuilderContext = motionBuilderContext,
                                container = revealEffectContainer,
                                deltaY = -marginBottom,
                            )
                        } else {
                            Modifier
                        },
                    revealEffectContainer = revealEffectContainer,
                    bounceableInfo = null,
                    interactionSource = interactionSource,
                )
            }
        } else {
            val bounceables =
                remember(sizedTiles) { List(sizedTiles.size) { BounceableTileViewModel() } }
            val spans by remember(sizedTiles) { derivedStateOf { sizedTiles.fastMap { it.width } } }
            VerticalSpannedGrid(
                columns = columns,
                columnSpacing = dimensionResource(R.dimen.qs_tile_margin_horizontal),
@@ -120,7 +157,7 @@ constructor(
            ) { spanIndex, column, isFirstInColumn, isLastInColumn ->
                val it = sizedTiles[spanIndex]

            Element(it.tile.spec.toElementKey(spanIndex), Modifier) {
                Element(it.tile.spec.toElementKey(), Modifier) {
                    Tile(
                        tile = it.tile,
                        iconOnly = iconTilesViewModel.isIconTile(it.tile.spec),
@@ -151,9 +188,11 @@ constructor(
                                Modifier
                            },
                        revealEffectContainer = revealEffectContainer,
                        interactionSource = null,
                    )
                }
            }
        }

        TileListener(tiles, listening)
    }
Loading