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

Commit 97cdc0c8 authored by Anton Potapov's avatar Anton Potapov
Browse files

Rework VolumePanelRadioButtons using Layout

This makes sure that the state it calculated on measuring and snaps
initial offset without an animation

Flag: aconfig new_volume_panel TEAMFOOD
Test: manual on the phone with a supported headset
Fixes: 329184473
Change-Id: I1c323ebf8d857ef7b94c136d73558a5c8c266921
parent fd9010cd
Loading
Loading
Loading
Loading
+165 −86
Original line number Diff line number Diff line
@@ -16,38 +16,38 @@

package com.android.systemui.volume.panel.component.selector.ui.composable

import androidx.compose.animation.core.animateOffsetAsState
import androidx.compose.foundation.Canvas
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFirst
import kotlinx.coroutines.launch

/**
 * Radio button group for the Volume Panel. It allows selecting a single item
@@ -65,8 +65,8 @@ fun VolumePanelRadioButtonBar(
    spacing: Dp = VolumePanelRadioButtonBarDefaults.DefaultSpacing,
    labelIndicatorBackgroundSpacing: Dp =
        VolumePanelRadioButtonBarDefaults.DefaultLabelIndicatorBackgroundSpacing,
    indicatorCornerRadius: CornerRadius =
        VolumePanelRadioButtonBarDefaults.defaultIndicatorCornerRadius(),
    indicatorCornerSize: CornerSize =
        CornerSize(VolumePanelRadioButtonBarDefaults.DefaultIndicatorCornerRadius),
    indicatorBackgroundCornerSize: CornerSize =
        CornerSize(VolumePanelRadioButtonBarDefaults.DefaultIndicatorBackgroundCornerRadius),
    colors: VolumePanelRadioButtonBarColors = VolumePanelRadioButtonBarDefaults.defaultColors(),
@@ -76,60 +76,41 @@ fun VolumePanelRadioButtonBar(
        VolumePanelRadioButtonBarScopeImpl().apply(content).apply {
            require(hasSelectedItem) { "At least one item should be selected" }
        }

    val items = scope.items

    var selectedIndex by remember { mutableIntStateOf(items.indexOfFirst { it.isSelected }) }

    var size by remember { mutableStateOf(IntSize(0, 0)) }
    val spacingPx = with(LocalDensity.current) { spacing.toPx() }
    val indicatorWidth = size.width / items.size - (spacingPx * (items.size - 1) / items.size)
    val offset by
        animateOffsetAsState(
            targetValue =
                Offset(
                    selectedIndex * indicatorWidth + (spacingPx * selectedIndex),
                    0f,
                ),
            label = "VolumePanelRadioButtonOffsetAnimation",
            finishedListener = {
                for (itemIndex in items.indices) {
                    val item = items[itemIndex]
                    if (itemIndex == selectedIndex) {
                        item.onItemSelected()
                        break
                    }
                }
            }
        )

    Column(modifier = modifier) {
        Box(modifier = Modifier.height(IntrinsicSize.Max)) {
            Canvas(
    val coroutineScope = rememberCoroutineScope()
    val offsetAnimatable = remember { Animatable(UNSET_OFFSET, Int.VectorConverter) }
    Layout(
        modifier = modifier,
        content = {
            Spacer(
                modifier =
                    Modifier.fillMaxSize()
                    Modifier.layoutId(RadioButtonBarComponent.ButtonsBackground)
                        .background(
                            colors.indicatorBackgroundColor,
                            RoundedCornerShape(indicatorBackgroundCornerSize),
                        )
            )
            Spacer(
                modifier =
                    Modifier.layoutId(RadioButtonBarComponent.Indicator)
                        .offset { IntOffset(offsetAnimatable.value, 0) }
                        .padding(indicatorBackgroundPadding)
                        .onGloballyPositioned { size = it.size }
            ) {
                drawRoundRect(
                    color = colors.indicatorColor,
                    topLeft = offset,
                    size = Size(indicatorWidth, size.height.toFloat()),
                    cornerRadius = indicatorCornerRadius,
                        .background(
                            colors.indicatorColor,
                            RoundedCornerShape(indicatorCornerSize),
                        )
            )
            }
            Row(
                modifier = Modifier.padding(indicatorBackgroundPadding),
                modifier =
                    Modifier.layoutId(RadioButtonBarComponent.Buttons)
                        .padding(indicatorBackgroundPadding),
                horizontalArrangement = Arrangement.spacedBy(spacing)
            ) {
                for (itemIndex in items.indices) {
                    TextButton(
                        modifier = Modifier.weight(1f),
                        onClick = { selectedIndex = itemIndex },
                        onClick = { items[itemIndex].onItemSelected() },
                    ) {
                        val item = items[itemIndex]
                        if (item.icon !== Empty) {
@@ -138,11 +119,10 @@ fun VolumePanelRadioButtonBar(
                    }
                }
            }
        }

            Row(
                modifier =
                Modifier.padding(
                    Modifier.layoutId(RadioButtonBarComponent.Labels)
                        .padding(
                            start = indicatorBackgroundPadding,
                            top = labelIndicatorBackgroundSpacing,
                            end = indicatorBackgroundPadding
@@ -152,7 +132,7 @@ fun VolumePanelRadioButtonBar(
                for (itemIndex in items.indices) {
                    TextButton(
                        modifier = Modifier.weight(1f),
                    onClick = { selectedIndex = itemIndex },
                        onClick = { items[itemIndex].onItemSelected() },
                    ) {
                        val item = items[itemIndex]
                        if (item.icon !== Empty) {
@@ -161,6 +141,95 @@ fun VolumePanelRadioButtonBar(
                    }
                }
            }
        },
        measurePolicy =
            with(LocalDensity.current) {
                val spacingPx =
                    (spacing - indicatorBackgroundPadding * 2).roundToPx().coerceAtLeast(0)

                BarMeasurePolicy(
                    buttonsCount = items.size,
                    selectedIndex = scope.selectedIndex,
                    spacingPx = spacingPx,
                ) {
                    coroutineScope.launch {
                        if (offsetAnimatable.value == UNSET_OFFSET) {
                            offsetAnimatable.snapTo(it)
                        } else {
                            offsetAnimatable.animateTo(it)
                        }
                    }
                }
            },
    )
}

private class BarMeasurePolicy(
    private val buttonsCount: Int,
    private val selectedIndex: Int,
    private val spacingPx: Int,
    private val onTargetIndicatorOffsetMeasured: (Int) -> Unit,
) : MeasurePolicy {

    override fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult {
        val fillWidthConstraints = constraints.copy(minWidth = constraints.maxWidth)
        val buttonsPlaceable: Placeable =
            measurables
                .fastFirst { it.layoutId == RadioButtonBarComponent.Buttons }
                .measure(fillWidthConstraints)
        val labelsPlaceable: Placeable =
            measurables
                .fastFirst { it.layoutId == RadioButtonBarComponent.Labels }
                .measure(fillWidthConstraints)

        val buttonsBackgroundPlaceable: Placeable =
            measurables
                .fastFirst { it.layoutId == RadioButtonBarComponent.ButtonsBackground }
                .measure(
                    Constraints(
                        minWidth = buttonsPlaceable.width,
                        maxWidth = buttonsPlaceable.width,
                        minHeight = buttonsPlaceable.height,
                        maxHeight = buttonsPlaceable.height,
                    )
                )

        val totalSpacing = spacingPx * (buttonsCount - 1)
        val indicatorWidth = (buttonsBackgroundPlaceable.width - totalSpacing) / buttonsCount
        val indicatorPlaceable: Placeable =
            measurables
                .fastFirst { it.layoutId == RadioButtonBarComponent.Indicator }
                .measure(
                    Constraints(
                        minWidth = indicatorWidth,
                        maxWidth = indicatorWidth,
                        minHeight = buttonsBackgroundPlaceable.height,
                        maxHeight = buttonsBackgroundPlaceable.height,
                    )
                )

        onTargetIndicatorOffsetMeasured(
            selectedIndex * indicatorWidth + (spacingPx * selectedIndex)
        )

        return layout(constraints.maxWidth, buttonsPlaceable.height + labelsPlaceable.height) {
            buttonsBackgroundPlaceable.placeRelative(
                0,
                0,
                RadioButtonBarComponent.ButtonsBackground.zIndex,
            )
            indicatorPlaceable.placeRelative(0, 0, RadioButtonBarComponent.Indicator.zIndex)

            buttonsPlaceable.placeRelative(0, 0, RadioButtonBarComponent.Buttons.zIndex)
            labelsPlaceable.placeRelative(
                0,
                buttonsBackgroundPlaceable.height,
                RadioButtonBarComponent.Labels.zIndex,
            )
        }
    }
}

@@ -179,12 +248,6 @@ object VolumePanelRadioButtonBarDefaults {
    val DefaultIndicatorCornerRadius = 20.dp
    val DefaultIndicatorBackgroundCornerRadius = 20.dp

    @Composable
    fun defaultIndicatorCornerRadius(
        x: Dp = DefaultIndicatorCornerRadius,
        y: Dp = DefaultIndicatorCornerRadius,
    ): CornerRadius = with(LocalDensity.current) { CornerRadius(x.toPx(), y.toPx()) }

    /**
     * Returns the default VolumePanelRadioButtonBar colors.
     *
@@ -225,9 +288,12 @@ private val Empty: @Composable RowScope.() -> Unit = {}

private class VolumePanelRadioButtonBarScopeImpl : VolumePanelRadioButtonBarScope {

    var hasSelectedItem: Boolean = false
    var selectedIndex: Int = UNSET_INDEX
        private set

    val hasSelectedItem: Boolean
        get() = selectedIndex != UNSET_INDEX

    private val mutableItems: MutableList<Item> = mutableListOf()
    val items: List<Item> = mutableItems

@@ -238,21 +304,34 @@ private class VolumePanelRadioButtonBarScopeImpl : VolumePanelRadioButtonBarScop
        label: @Composable RowScope.() -> Unit,
    ) {
        require(!isSelected || !hasSelectedItem) { "Only one item should be selected at a time" }
        hasSelectedItem = hasSelectedItem || isSelected
        if (isSelected) {
            selectedIndex = mutableItems.size
        }
        mutableItems.add(
            Item(
                isSelected = isSelected,
                onItemSelected = onItemSelected,
                icon = icon,
                label = label,
            )
        )
    }

    private companion object {
        const val UNSET_INDEX = -1
    }
}

private class Item(
    val isSelected: Boolean,
    val onItemSelected: () -> Unit,
    val icon: @Composable RowScope.() -> Unit,
    val label: @Composable RowScope.() -> Unit,
)

private const val UNSET_OFFSET = -1

private enum class RadioButtonBarComponent(val zIndex: Float) {
    ButtonsBackground(0f),
    Indicator(1f),
    Buttons(2f),
    Labels(2f),
}
+1 −1
Original line number Diff line number Diff line
@@ -65,7 +65,7 @@ constructor(
            return
        }

        val enabledModelStates by viewModel.spatialAudioButtonByEnabled.collectAsState()
        val enabledModelStates by viewModel.spatialAudioButtons.collectAsState()
        if (enabledModelStates.isEmpty()) {
            return
        }
+1 −1
Original line number Diff line number Diff line
@@ -56,7 +56,7 @@ constructor(
    val isAvailable: StateFlow<Boolean> =
        availabilityCriteria.isAvailable().stateIn(scope, SharingStarted.Eagerly, true)

    val spatialAudioButtonByEnabled: StateFlow<List<SpatialAudioButtonViewModel>> =
    val spatialAudioButtons: StateFlow<List<SpatialAudioButtonViewModel>> =
        combine(interactor.isEnabled, interactor.isAvailable) { currentIsEnabled, isAvailable ->
                SpatialAudioEnabledModel.values
                    .filter {