Loading packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt +27 −28 Original line number Diff line number Diff line Loading @@ -17,17 +17,19 @@ package com.android.systemui.volume.panel.component.volume.ui.composable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color Loading @@ -52,16 +54,15 @@ fun VolumeSlider( modifier: Modifier = Modifier, sliderColors: PlatformSliderColors, ) { val value by animateFloatAsState(targetValue = state.value, label = "VolumeSliderValueAnimation") val value by valueState(state) PlatformSlider( modifier = modifier.clearAndSetSemantics { if (!state.isEnabled) disabled() contentDescription = state.label // provide a not animated value to the a11y because it fails to announce the settled // value when it changes rapidly. // provide a not animated value to the a11y because it fails to announce the // settled value when it changes rapidly. progressBarRangeInfo = ProgressBarRangeInfo(state.value, state.valueRange) setProgress { targetValue -> val targetDirection = Loading Loading @@ -99,31 +100,29 @@ fun VolumeSlider( }, colors = sliderColors, label = { Column(modifier = Modifier) { Text( modifier = Modifier.basicMarquee(), text = state.label, style = MaterialTheme.typography.titleMedium, color = LocalContentColor.current, maxLines = 1, VolumeSliderContent( modifier = Modifier, label = state.label, isEnabled = state.isEnabled, disabledMessage = state.disabledMessage, ) if (!state.isEnabled) { state.disabledMessage?.let { message -> Text( modifier = Modifier.basicMarquee(), text = message, style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current, maxLines = 1, ) } } } } ) } @Composable private fun valueState(state: SliderState): State<Float> { var prevState by remember { mutableStateOf(state) } // Don't animate slider value when receive the first value and when changing isEnabled state val shouldSkipAnimation = prevState is SliderState.Empty || prevState.isEnabled != state.isEnabled val value = if (shouldSkipAnimation) mutableFloatStateOf(state.value) else animateFloatAsState(targetValue = state.value, label = "VolumeSliderValueAnimation") prevState = state return value } @Composable private fun SliderIcon( icon: Icon, Loading packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSliderContent.kt 0 → 100644 +152 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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.volume.panel.component.volume.ui.composable import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.basicMarquee import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.unit.Constraints import androidx.compose.ui.util.fastFirst import androidx.compose.ui.util.fastFirstOrNull import kotlinx.coroutines.launch private enum class VolumeSliderContentComponent { Label, DisabledMessage, } /** Shows label of the [VolumeSlider]. Also shows [disabledMessage] when not [isEnabled]. */ @Composable fun VolumeSliderContent( label: String, isEnabled: Boolean, disabledMessage: String?, modifier: Modifier = Modifier, ) { Layout( modifier = modifier.animateContentHeight(), content = { Text( modifier = Modifier.layoutId(VolumeSliderContentComponent.Label).basicMarquee(), text = label, style = MaterialTheme.typography.titleMedium, color = LocalContentColor.current, maxLines = 1, ) disabledMessage?.let { message -> AnimatedVisibility( modifier = Modifier.layoutId(VolumeSliderContentComponent.DisabledMessage), visible = !isEnabled, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), ) { Text( modifier = Modifier.basicMarquee(), text = message, style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current, maxLines = 1, ) } } }, measurePolicy = VolumeSliderContentMeasurePolicy(isEnabled) ) } /** * Uses [VolumeSliderContentComponent.Label] width when [isEnabled] and max available width * otherwise. This ensures that the slider always have the correct measurement to position the * content. */ private class VolumeSliderContentMeasurePolicy(private val isEnabled: Boolean) : MeasurePolicy { override fun MeasureScope.measure( measurables: List<Measurable>, constraints: Constraints ): MeasureResult { val labelPlaceable = measurables .fastFirst { it.layoutId == VolumeSliderContentComponent.Label } .measure(constraints) val layoutWidth: Int = constraints.maxWidth val fullLayoutWidth: Int = if (isEnabled) { // PlatformSlider uses half of the available space for the enabled state. // This is using it to allow disabled message to take whole space when animating to // prevent it from jumping left to right layoutWidth * 2 } else { layoutWidth } val disabledMessagePlaceable = measurables .fastFirstOrNull { it.layoutId == VolumeSliderContentComponent.DisabledMessage } ?.measure(constraints.copy(maxWidth = fullLayoutWidth)) val layoutHeight = labelPlaceable.height + (disabledMessagePlaceable?.height ?: 0) return layout(layoutWidth, layoutHeight) { labelPlaceable.placeRelative(0, 0, 0f) disabledMessagePlaceable?.placeRelative(0, labelPlaceable.height, 0f) } } } /** Animates composable height changes. */ @Composable private fun Modifier.animateContentHeight(): Modifier { var heightAnimation by remember { mutableStateOf<Animatable<Int, AnimationVector1D>?>(null) } val coroutineScope = rememberCoroutineScope() return layout { measurable, constraints -> val placeable = measurable.measure(constraints) val currentAnimation = heightAnimation val anim = if (currentAnimation == null) { Animatable(placeable.height, Int.VectorConverter).also { heightAnimation = it } } else { coroutineScope.launch { currentAnimation.animateTo(placeable.height) } currentAnimation } layout(placeable.width, anim.value) { placeable.place(0, 0) } } } Loading
packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt +27 −28 Original line number Diff line number Diff line Loading @@ -17,17 +17,19 @@ package com.android.systemui.volume.panel.component.volume.ui.composable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color Loading @@ -52,16 +54,15 @@ fun VolumeSlider( modifier: Modifier = Modifier, sliderColors: PlatformSliderColors, ) { val value by animateFloatAsState(targetValue = state.value, label = "VolumeSliderValueAnimation") val value by valueState(state) PlatformSlider( modifier = modifier.clearAndSetSemantics { if (!state.isEnabled) disabled() contentDescription = state.label // provide a not animated value to the a11y because it fails to announce the settled // value when it changes rapidly. // provide a not animated value to the a11y because it fails to announce the // settled value when it changes rapidly. progressBarRangeInfo = ProgressBarRangeInfo(state.value, state.valueRange) setProgress { targetValue -> val targetDirection = Loading Loading @@ -99,31 +100,29 @@ fun VolumeSlider( }, colors = sliderColors, label = { Column(modifier = Modifier) { Text( modifier = Modifier.basicMarquee(), text = state.label, style = MaterialTheme.typography.titleMedium, color = LocalContentColor.current, maxLines = 1, VolumeSliderContent( modifier = Modifier, label = state.label, isEnabled = state.isEnabled, disabledMessage = state.disabledMessage, ) if (!state.isEnabled) { state.disabledMessage?.let { message -> Text( modifier = Modifier.basicMarquee(), text = message, style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current, maxLines = 1, ) } } } } ) } @Composable private fun valueState(state: SliderState): State<Float> { var prevState by remember { mutableStateOf(state) } // Don't animate slider value when receive the first value and when changing isEnabled state val shouldSkipAnimation = prevState is SliderState.Empty || prevState.isEnabled != state.isEnabled val value = if (shouldSkipAnimation) mutableFloatStateOf(state.value) else animateFloatAsState(targetValue = state.value, label = "VolumeSliderValueAnimation") prevState = state return value } @Composable private fun SliderIcon( icon: Icon, Loading
packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSliderContent.kt 0 → 100644 +152 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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.volume.panel.component.volume.ui.composable import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.basicMarquee import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.unit.Constraints import androidx.compose.ui.util.fastFirst import androidx.compose.ui.util.fastFirstOrNull import kotlinx.coroutines.launch private enum class VolumeSliderContentComponent { Label, DisabledMessage, } /** Shows label of the [VolumeSlider]. Also shows [disabledMessage] when not [isEnabled]. */ @Composable fun VolumeSliderContent( label: String, isEnabled: Boolean, disabledMessage: String?, modifier: Modifier = Modifier, ) { Layout( modifier = modifier.animateContentHeight(), content = { Text( modifier = Modifier.layoutId(VolumeSliderContentComponent.Label).basicMarquee(), text = label, style = MaterialTheme.typography.titleMedium, color = LocalContentColor.current, maxLines = 1, ) disabledMessage?.let { message -> AnimatedVisibility( modifier = Modifier.layoutId(VolumeSliderContentComponent.DisabledMessage), visible = !isEnabled, enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), ) { Text( modifier = Modifier.basicMarquee(), text = message, style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current, maxLines = 1, ) } } }, measurePolicy = VolumeSliderContentMeasurePolicy(isEnabled) ) } /** * Uses [VolumeSliderContentComponent.Label] width when [isEnabled] and max available width * otherwise. This ensures that the slider always have the correct measurement to position the * content. */ private class VolumeSliderContentMeasurePolicy(private val isEnabled: Boolean) : MeasurePolicy { override fun MeasureScope.measure( measurables: List<Measurable>, constraints: Constraints ): MeasureResult { val labelPlaceable = measurables .fastFirst { it.layoutId == VolumeSliderContentComponent.Label } .measure(constraints) val layoutWidth: Int = constraints.maxWidth val fullLayoutWidth: Int = if (isEnabled) { // PlatformSlider uses half of the available space for the enabled state. // This is using it to allow disabled message to take whole space when animating to // prevent it from jumping left to right layoutWidth * 2 } else { layoutWidth } val disabledMessagePlaceable = measurables .fastFirstOrNull { it.layoutId == VolumeSliderContentComponent.DisabledMessage } ?.measure(constraints.copy(maxWidth = fullLayoutWidth)) val layoutHeight = labelPlaceable.height + (disabledMessagePlaceable?.height ?: 0) return layout(layoutWidth, layoutHeight) { labelPlaceable.placeRelative(0, 0, 0f) disabledMessagePlaceable?.placeRelative(0, labelPlaceable.height, 0f) } } } /** Animates composable height changes. */ @Composable private fun Modifier.animateContentHeight(): Modifier { var heightAnimation by remember { mutableStateOf<Animatable<Int, AnimationVector1D>?>(null) } val coroutineScope = rememberCoroutineScope() return layout { measurable, constraints -> val placeable = measurable.measure(constraints) val currentAnimation = heightAnimation val anim = if (currentAnimation == null) { Animatable(placeable.height, Int.VectorConverter).also { heightAnimation = it } } else { coroutineScope.launch { currentAnimation.animateTo(placeable.height) } currentAnimation } layout(placeable.width, anim.value) { placeable.place(0, 0) } } }