Update Volume Panel sliders to match the updated design
This change makes sure the VolumeDialogSliderTrack supports rtl and is
suitable for horizontal sliders
Flag: com.android.systemui.volume_redesign
Fixes: 399342224
Test: manual on foldable. Open Volume Panel and observe the sliders
Test: atest VolumePanelScreenshotTest
Test: atest VolumeDialogScreenshotTest
Change-Id: I43480d6c28cb9c63a2f3d508a53141f44251ad68
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt b/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
index df50eb8..da07fbd 100644
--- a/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
+++ b/packages/SystemUI/compose/core/src/com/android/compose/PlatformButtons.kt
@@ -101,10 +101,17 @@
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: IconButtonColors = iconButtonColors(),
+ shape: Shape = IconButtonDefaults.standardShape,
@DrawableRes iconResource: Int,
contentDescription: String?,
) {
- IconButton(modifier = modifier, onClick = onClick, enabled = enabled, colors = colors) {
+ IconButton(
+ modifier = modifier,
+ onClick = onClick,
+ enabled = enabled,
+ colors = colors,
+ shape = shape,
+ ) {
Icon(
painter = painterResource(id = iconResource),
contentDescription = contentDescription,
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/ColumnVolumeSliders.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/ColumnVolumeSliders.kt
index d9e8f02..52b1e3a 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/ColumnVolumeSliders.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/ColumnVolumeSliders.kt
@@ -30,9 +30,11 @@
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
@@ -42,7 +44,6 @@
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -154,9 +155,7 @@
totalCount = viewModels.size,
),
)
- .thenIf(!Flags.volumeRedesign()) {
- Modifier.padding(top = 16.dp)
- },
+ .padding(top = if (Flags.volumeRedesign()) 4.dp else 16.dp),
state = sliderState,
onValueChange = { newValue: Float ->
sliderViewModel.onValueChanged(sliderState, newValue)
@@ -223,7 +222,7 @@
}
@Composable
-private fun ExpandButton(
+private fun RowScope.ExpandButton(
isExpanded: Boolean,
isExpandable: Boolean,
onExpandedChanged: (Boolean) -> Unit,
@@ -243,16 +242,17 @@
) {
PlatformIconButton(
modifier =
- Modifier.size(width = 48.dp, height = 40.dp).semantics {
+ Modifier.size(40.dp).semantics {
role = Role.Switch
stateDescription = expandButtonStateDescription
},
onClick = { onExpandedChanged(!isExpanded) },
colors =
IconButtonDefaults.iconButtonColors(
- containerColor = Color.Transparent,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = MaterialTheme.colorScheme.onSurface,
),
+ shape = RoundedCornerShape(12.dp),
iconResource =
if (isExpanded) {
R.drawable.ic_arrow_down_24dp
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
index da54cb8..f9492da 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
@@ -30,14 +30,16 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon as MaterialIcon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -58,31 +60,33 @@
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.progressBarRangeInfo
-import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.android.compose.PlatformSlider
import com.android.compose.PlatformSliderColors
import com.android.systemui.Flags
-import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.common.shared.model.Icon as IconModel
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.haptics.slider.SliderHapticFeedbackFilter
import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.res.R
+import com.android.systemui.volume.dialog.sliders.ui.compose.SliderTrack
import com.android.systemui.volume.haptics.ui.VolumeHapticsConfigsProvider
import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderState
-import com.android.systemui.volume.ui.slider.AccessibilityParams
-import com.android.systemui.volume.ui.slider.Haptics
-import com.android.systemui.volume.ui.slider.Slider
+import com.android.systemui.volume.ui.compose.slider.AccessibilityParams
+import com.android.systemui.volume.ui.compose.slider.Haptics
+import com.android.systemui.volume.ui.compose.slider.Slider
+import com.android.systemui.volume.ui.compose.slider.SliderIcon
import kotlin.math.round
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun VolumeSlider(
state: SliderState,
@@ -92,7 +96,7 @@
modifier: Modifier = Modifier,
hapticsViewModelFactory: SliderHapticsViewModel.Factory?,
onValueChangeFinished: (() -> Unit)? = null,
- button: (@Composable () -> Unit)? = null,
+ button: (@Composable RowScope.() -> Unit)? = null,
) {
if (!Flags.volumeRedesign()) {
LegacyVolumeSlider(
@@ -107,54 +111,86 @@
return
}
- Column(
- modifier = modifier.animateContentSize().semantics(true) {},
- verticalArrangement = Arrangement.Top,
- ) {
+ Column(modifier = modifier.animateContentSize()) {
+ Text(
+ text = state.label,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth().clearAndSetSemantics {},
+ )
Row(
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- modifier = Modifier.fillMaxWidth().height(40.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- state.icon?.let {
- Icon(
- icon = it,
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(24.dp),
+ val materialSliderColors =
+ SliderDefaults.colors(
+ activeTickColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
- }
- Text(
- text = state.label,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.weight(1f).clearAndSetSemantics {},
- )
- button?.invoke()
- }
-
- Slider(
- value = state.value,
- valueRange = state.valueRange,
- onValueChanged = onValueChange,
- onValueChangeFinished = { onValueChangeFinished?.invoke() },
- isEnabled = state.isEnabled,
- stepDistance = state.step,
- accessibilityParams =
- AccessibilityParams(
- contentDescription = state.a11yContentDescription,
- stateDescription = state.a11yStateDescription,
- ),
- haptics =
- hapticsViewModelFactory?.let {
- Haptics.Enabled(
- hapticsViewModelFactory = it,
- hapticFilter = state.hapticFilter,
- orientation = Orientation.Horizontal,
+ Slider(
+ value = state.value,
+ valueRange = state.valueRange,
+ onValueChanged = onValueChange,
+ onValueChangeFinished = { onValueChangeFinished?.invoke() },
+ colors = materialSliderColors,
+ isEnabled = state.isEnabled,
+ stepDistance = state.step,
+ accessibilityParams =
+ AccessibilityParams(
+ contentDescription = state.a11yContentDescription,
+ stateDescription = state.a11yStateDescription,
+ ),
+ track = { sliderState ->
+ SliderTrack(
+ sliderState = sliderState,
+ colors = materialSliderColors,
+ isEnabled = state.isEnabled,
+ activeTrackStartIcon =
+ state.icon?.let { icon ->
+ { iconsState ->
+ SliderIcon(
+ icon = {
+ Icon(icon = icon, modifier = Modifier.size(24.dp))
+ },
+ isVisible = iconsState.isActiveTrackStartIconVisible,
+ )
+ }
+ },
+ inactiveTrackStartIcon =
+ state.icon?.let { icon ->
+ { iconsState ->
+ SliderIcon(
+ icon = {
+ Icon(icon = icon, modifier = Modifier.size(24.dp))
+ },
+ isVisible = !iconsState.isActiveTrackStartIconVisible,
+ )
+ }
+ },
)
- } ?: Haptics.Disabled,
- modifier =
- Modifier.height(40.dp).padding(top = 4.dp, bottom = 12.dp).sysuiResTag(state.label),
- )
+ },
+ thumb = { sliderState, interactionSource ->
+ SliderDefaults.Thumb(
+ sliderState = sliderState,
+ interactionSource = interactionSource,
+ enabled = state.isEnabled,
+ colors = materialSliderColors,
+ thumbSize = DpSize(4.dp, 52.dp),
+ )
+ },
+ haptics =
+ hapticsViewModelFactory?.let {
+ Haptics.Enabled(
+ hapticsViewModelFactory = it,
+ hapticFilter = state.hapticFilter,
+ orientation = Orientation.Horizontal,
+ )
+ } ?: Haptics.Disabled,
+ modifier = Modifier.weight(1f).sysuiResTag(state.label),
+ )
+ button?.invoke(this)
+ }
state.disabledMessage?.let { disabledMessage ->
AnimatedVisibility(visible = !state.isEnabled) {
Row(
@@ -253,7 +289,11 @@
enabled = state.isEnabled,
icon = {
state.icon?.let {
- SliderIcon(icon = it, onIconTapped = onIconTapped, isTappable = state.isMutable)
+ LegacySliderIcon(
+ icon = it,
+ onIconTapped = onIconTapped,
+ isTappable = state.isMutable,
+ )
}
},
colors = sliderColors,
@@ -289,8 +329,8 @@
}
@Composable
-private fun SliderIcon(
- icon: Icon,
+private fun LegacySliderIcon(
+ icon: IconModel,
onIconTapped: () -> Unit,
isTappable: Boolean,
modifier: Modifier = Modifier,
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt
index 32f784f..db4b8ef 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/VolumeDialogSliderViewBinder.kt
@@ -17,42 +17,37 @@
package com.android.systemui.volume.dialog.sliders.ui
import android.view.View
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.compose.theme.PlatformTheme
-import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.haptics.slider.SliderHapticFeedbackFilter
import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
import com.android.systemui.res.R
import com.android.systemui.volume.dialog.sliders.dagger.VolumeDialogSliderScope
-import com.android.systemui.volume.dialog.sliders.ui.compose.VolumeDialogSliderTrack
+import com.android.systemui.volume.dialog.sliders.ui.compose.SliderTrack
import com.android.systemui.volume.dialog.sliders.ui.viewmodel.VolumeDialogOverscrollViewModel
import com.android.systemui.volume.dialog.sliders.ui.viewmodel.VolumeDialogSliderViewModel
-import com.android.systemui.volume.ui.slider.AccessibilityParams
-import com.android.systemui.volume.ui.slider.Haptics
-import com.android.systemui.volume.ui.slider.Slider
+import com.android.systemui.volume.ui.compose.slider.AccessibilityParams
+import com.android.systemui.volume.ui.compose.slider.Haptics
+import com.android.systemui.volume.ui.compose.slider.Slider
+import com.android.systemui.volume.ui.compose.slider.SliderIcon
import javax.inject.Inject
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
@@ -85,7 +80,7 @@
}
}
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun VolumeDialogSlider(
viewModel: VolumeDialogSliderViewModel,
@@ -95,10 +90,7 @@
) {
val colors =
SliderDefaults.colors(
- thumbColor = MaterialTheme.colorScheme.primary,
activeTickColor = MaterialTheme.colorScheme.surfaceContainerHighest,
- inactiveTickColor = MaterialTheme.colorScheme.primary,
- activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
val collectedSliderStateModel by viewModel.state.collectAsStateWithLifecycle(null)
@@ -142,18 +134,38 @@
} ?: Haptics.Disabled,
stepDistance = 1f,
track = { sliderState ->
- VolumeDialogSliderTrack(
+ SliderTrack(
sliderState,
colors = colors,
isEnabled = !sliderStateModel.isDisabled,
- activeTrackEndIcon = { iconsState ->
- VolumeIcon(sliderStateModel.icon, iconsState.isActiveTrackEndIconVisible)
+ isVertical = true,
+ activeTrackStartIcon = { iconsState ->
+ SliderIcon(
+ icon = {
+ Icon(icon = sliderStateModel.icon, modifier = Modifier.size(20.dp))
+ },
+ isVisible = iconsState.isActiveTrackStartIconVisible,
+ )
},
- inactiveTrackEndIcon = { iconsState ->
- VolumeIcon(sliderStateModel.icon, !iconsState.isActiveTrackEndIconVisible)
+ inactiveTrackStartIcon = { iconsState ->
+ SliderIcon(
+ icon = {
+ Icon(icon = sliderStateModel.icon, modifier = Modifier.size(20.dp))
+ },
+ isVisible = !iconsState.isActiveTrackStartIconVisible,
+ )
},
)
},
+ thumb = { sliderState, interactions ->
+ SliderDefaults.Thumb(
+ sliderState = sliderState,
+ interactionSource = interactions,
+ enabled = !sliderStateModel.isDisabled,
+ colors = colors,
+ thumbSize = DpSize(52.dp, 4.dp),
+ )
+ },
accessibilityParams = AccessibilityParams(contentDescription = sliderStateModel.label),
modifier =
modifier.pointerInput(Unit) {
@@ -168,19 +180,3 @@
},
)
}
-
-@Composable
-private fun BoxScope.VolumeIcon(
- icon: Icon.Loaded,
- isVisible: Boolean,
- modifier: Modifier = Modifier,
-) {
- AnimatedVisibility(
- visible = isVisible,
- enter = fadeIn(animationSpec = tween(delayMillis = 33, durationMillis = 100)),
- exit = fadeOut(animationSpec = tween(durationMillis = 50)),
- modifier = modifier.align(Alignment.Center).size(40.dp).padding(10.dp),
- ) {
- Icon(icon)
- }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/compose/VolumeDialogSliderTrack.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/compose/VolumeDialogSliderTrack.kt
index 1dd9dda..fb8de45 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/compose/VolumeDialogSliderTrack.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/sliders/ui/compose/VolumeDialogSliderTrack.kt
@@ -19,6 +19,7 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -32,38 +33,47 @@
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
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.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastFirst
import kotlin.math.min
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
-fun VolumeDialogSliderTrack(
+fun SliderTrack(
sliderState: SliderState,
- colors: SliderColors,
isEnabled: Boolean,
modifier: Modifier = Modifier,
+ colors: SliderColors = SliderDefaults.colors(),
thumbTrackGapSize: Dp = 6.dp,
trackCornerSize: Dp = 12.dp,
trackInsideCornerSize: Dp = 2.dp,
trackSize: Dp = 40.dp,
+ isVertical: Boolean = false,
activeTrackStartIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
activeTrackEndIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
inactiveTrackStartIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
inactiveTrackEndIcon: (@Composable BoxScope.(iconsState: SliderIconsState) -> Unit)? = null,
) {
- val measurePolicy = remember(sliderState) { TrackMeasurePolicy(sliderState) }
+ val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+ val measurePolicy =
+ remember(sliderState) {
+ TrackMeasurePolicy(
+ sliderState = sliderState,
+ shouldMirrorIcons = !isVertical && isRtl || isVertical,
+ isVertical = isVertical,
+ gapSize = thumbTrackGapSize,
+ )
+ }
Layout(
measurePolicy = measurePolicy,
content = {
@@ -76,33 +86,41 @@
drawStopIndicator = null,
thumbTrackGapSize = thumbTrackGapSize,
drawTick = { _, _ -> },
- modifier = Modifier.width(trackSize).layoutId(Contents.Track),
+ modifier =
+ Modifier.then(
+ if (isVertical) {
+ Modifier.width(trackSize)
+ } else {
+ Modifier.height(trackSize)
+ }
+ )
+ .layoutId(Contents.Track),
)
TrackIcon(
icon = activeTrackStartIcon,
- contentsId = Contents.Active.TrackStartIcon,
+ contents = Contents.Active.TrackStartIcon,
isEnabled = isEnabled,
colors = colors,
state = measurePolicy,
)
TrackIcon(
icon = activeTrackEndIcon,
- contentsId = Contents.Active.TrackEndIcon,
+ contents = Contents.Active.TrackEndIcon,
isEnabled = isEnabled,
colors = colors,
state = measurePolicy,
)
TrackIcon(
icon = inactiveTrackStartIcon,
- contentsId = Contents.Inactive.TrackStartIcon,
+ contents = Contents.Inactive.TrackStartIcon,
isEnabled = isEnabled,
colors = colors,
state = measurePolicy,
)
TrackIcon(
icon = inactiveTrackEndIcon,
- contentsId = Contents.Inactive.TrackEndIcon,
+ contents = Contents.Inactive.TrackEndIcon,
isEnabled = isEnabled,
colors = colors,
state = measurePolicy,
@@ -116,24 +134,47 @@
private fun TrackIcon(
icon: (@Composable BoxScope.(sliderIconsState: SliderIconsState) -> Unit)?,
isEnabled: Boolean,
- contentsId: Contents,
+ contents: Contents,
state: SliderIconsState,
colors: SliderColors,
modifier: Modifier = Modifier,
) {
icon ?: return
- Box(modifier = modifier.layoutId(contentsId).fillMaxSize()) {
- CompositionLocalProvider(
- LocalContentColor provides contentsId.getColor(colors, isEnabled)
- ) {
- icon(state)
+ /*
+ ignore icons mirroring for the rtl layouts here because icons positioning is handled by the
+ TrackMeasurePolicy. It ensures that active icons are always above the active track and the
+ same for inactive
+ */
+ val iconColor =
+ when (contents) {
+ is Contents.Inactive ->
+ if (isEnabled) {
+ colors.inactiveTickColor
+ } else {
+ colors.disabledInactiveTickColor
+ }
+ is Contents.Active ->
+ if (isEnabled) {
+ colors.activeTickColor
+ } else {
+ colors.disabledActiveTickColor
+ }
+ is Contents.Track -> {
+ error("$contents is unsupported by the TrackIcon")
+ }
}
+ Box(modifier = modifier.layoutId(contents).fillMaxSize()) {
+ CompositionLocalProvider(LocalContentColor provides iconColor) { icon(state) }
}
}
@OptIn(ExperimentalMaterial3Api::class)
-private class TrackMeasurePolicy(private val sliderState: SliderState) :
- MeasurePolicy, SliderIconsState {
+private class TrackMeasurePolicy(
+ private val sliderState: SliderState,
+ private val shouldMirrorIcons: Boolean,
+ private val gapSize: Dp,
+ private val isVertical: Boolean,
+) : MeasurePolicy, SliderIconsState {
private val isVisible: Map<Contents, MutableState<Boolean>> =
mutableMapOf(
@@ -144,16 +185,16 @@
)
override val isActiveTrackStartIconVisible: Boolean
- get() = isVisible.getValue(Contents.Active.TrackStartIcon).value
+ get() = isVisible.getValue(Contents.Active.TrackStartIcon.resolve()).value
override val isActiveTrackEndIconVisible: Boolean
- get() = isVisible.getValue(Contents.Active.TrackEndIcon).value
+ get() = isVisible.getValue(Contents.Active.TrackEndIcon.resolve()).value
override val isInactiveTrackStartIconVisible: Boolean
- get() = isVisible.getValue(Contents.Inactive.TrackStartIcon).value
+ get() = isVisible.getValue(Contents.Inactive.TrackStartIcon.resolve()).value
override val isInactiveTrackEndIconVisible: Boolean
- get() = isVisible.getValue(Contents.Inactive.TrackEndIcon).value
+ get() = isVisible.getValue(Contents.Inactive.TrackEndIcon.resolve()).value
override fun MeasureScope.measure(
measurables: List<Measurable>,
@@ -164,178 +205,196 @@
val iconSize = min(track.width, track.height)
val iconConstraints = constraints.copy(maxWidth = iconSize, maxHeight = iconSize)
- val icons =
- measurables
- .fastFilter { it.layoutId != Contents.Track }
- .associateBy(
- keySelector = { it.layoutId as Contents },
- valueTransform = { it.measure(iconConstraints) },
- )
+ val components = buildMap {
+ put(Contents.Track, track)
+ for (measurable in measurables) {
+ // don't measure track a second time
+ if (measurable.layoutId != Contents.Track) {
+ put(
+ (measurable.layoutId as Contents).resolve(),
+ measurable.measure(iconConstraints),
+ )
+ }
+ }
+ }
return layout(track.width, track.height) {
- with(Contents.Track) {
- performPlacing(
- placeable = track,
- width = track.width,
- height = track.height,
- sliderState = sliderState,
- )
- }
-
- for (iconLayoutId in icons.keys) {
- with(iconLayoutId) {
- performPlacing(
- placeable = icons.getValue(iconLayoutId),
- width = track.width,
- height = track.height,
- sliderState = sliderState,
+ val gapSizePx = gapSize.roundToPx()
+ val coercedValueAsFraction =
+ if (shouldMirrorIcons) {
+ 1 - sliderState.coercedValueAsFraction
+ } else {
+ sliderState.coercedValueAsFraction
+ }
+ for (iconLayoutId in components.keys) {
+ val iconPlaceable = components.getValue(iconLayoutId)
+ if (isVertical) {
+ iconPlaceable.place(
+ 0,
+ iconLayoutId.calculatePosition(
+ placeableDimension = iconPlaceable.height,
+ containerDimension = track.height,
+ gapSize = gapSizePx,
+ coercedValueAsFraction = coercedValueAsFraction,
+ ),
)
+ } else {
+ iconPlaceable.place(
+ iconLayoutId.calculatePosition(
+ placeableDimension = iconPlaceable.width,
+ containerDimension = track.width,
+ gapSize = gapSizePx,
+ coercedValueAsFraction = coercedValueAsFraction,
+ ),
+ 0,
+ )
+ }
- isVisible.getValue(iconLayoutId).value =
- isVisible(
- placeable = icons.getValue(iconLayoutId),
- width = track.width,
- height = track.height,
- sliderState = sliderState,
+ // isVisible is only relevant for the icons
+ if (iconLayoutId != Contents.Track) {
+ val isVisibleState = isVisible.getValue(iconLayoutId)
+ val newIsVisible =
+ iconLayoutId.isVisible(
+ placeableDimension =
+ if (isVertical) iconPlaceable.height else iconPlaceable.width,
+ containerDimension = if (isVertical) track.height else track.width,
+ gapSize = gapSizePx,
+ coercedValueAsFraction = coercedValueAsFraction,
)
+ if (isVisibleState.value != newIsVisible) {
+ isVisibleState.value = newIsVisible
+ }
}
}
}
}
+
+ private fun Contents.resolve(): Contents {
+ return if (shouldMirrorIcons) {
+ mirrored
+ } else {
+ this
+ }
+ }
}
-@OptIn(ExperimentalMaterial3Api::class)
private sealed interface Contents {
data object Track : Contents {
- override fun Placeable.PlacementScope.performPlacing(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ) = placeable.place(x = 0, y = 0)
+
+ override val mirrored: Contents
+ get() = error("unsupported for Track")
+
+ override fun calculatePosition(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Int = 0
override fun isVisible(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ) = true
-
- override fun getColor(sliderColors: SliderColors, isEnabled: Boolean): Color =
- error("Unsupported")
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Boolean = true
}
interface Active : Contents {
- override fun getColor(sliderColors: SliderColors, isEnabled: Boolean): Color {
- return if (isEnabled) {
- sliderColors.activeTickColor
- } else {
- sliderColors.disabledActiveTickColor
- }
- }
+
+ override fun isVisible(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Boolean =
+ (containerDimension * coercedValueAsFraction - gapSize).toInt() > placeableDimension
data object TrackStartIcon : Active {
- override fun Placeable.PlacementScope.performPlacing(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ) =
- placeable.place(
- x = 0,
- y = (height * (1 - sliderState.coercedValueAsFraction)).toInt(),
- )
- override fun isVisible(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ): Boolean = (height * (sliderState.coercedValueAsFraction)).toInt() > placeable.height
+ override val mirrored: Contents
+ get() = Inactive.TrackEndIcon
+
+ override fun calculatePosition(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Int = 0
}
data object TrackEndIcon : Active {
- override fun Placeable.PlacementScope.performPlacing(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ) = placeable.place(x = 0, y = (height - placeable.height))
- override fun isVisible(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ): Boolean = (height * (sliderState.coercedValueAsFraction)).toInt() > placeable.height
+ override val mirrored: Contents
+ get() = Inactive.TrackStartIcon
+
+ override fun calculatePosition(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Int =
+ (containerDimension * coercedValueAsFraction - placeableDimension - gapSize).toInt()
}
}
interface Inactive : Contents {
- override fun getColor(sliderColors: SliderColors, isEnabled: Boolean): Color {
- return if (isEnabled) {
- sliderColors.inactiveTickColor
- } else {
- sliderColors.disabledInactiveTickColor
- }
- }
+ override fun isVisible(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Boolean =
+ containerDimension - (containerDimension * coercedValueAsFraction + gapSize) >
+ placeableDimension
data object TrackStartIcon : Inactive {
- override fun Placeable.PlacementScope.performPlacing(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ) {
- placeable.place(x = 0, y = 0)
- }
- override fun isVisible(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ): Boolean =
- (height * (1 - sliderState.coercedValueAsFraction)).toInt() > placeable.height
+ override val mirrored: Contents
+ get() = Active.TrackEndIcon
+
+ override fun calculatePosition(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Int = (containerDimension * coercedValueAsFraction + gapSize).toInt()
}
data object TrackEndIcon : Inactive {
- override fun Placeable.PlacementScope.performPlacing(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ) {
- placeable.place(
- x = 0,
- y =
- (height * (1 - sliderState.coercedValueAsFraction)).toInt() -
- placeable.height,
- )
- }
- override fun isVisible(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- ): Boolean =
- (height * (1 - sliderState.coercedValueAsFraction)).toInt() > placeable.height
+ override val mirrored: Contents
+ get() = Active.TrackStartIcon
+
+ override fun calculatePosition(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Int = containerDimension - placeableDimension
}
}
- fun Placeable.PlacementScope.performPlacing(
- placeable: Placeable,
- width: Int,
- height: Int,
- sliderState: SliderState,
- )
+ fun calculatePosition(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Int
- fun isVisible(placeable: Placeable, width: Int, height: Int, sliderState: SliderState): Boolean
+ fun isVisible(
+ placeableDimension: Int,
+ containerDimension: Int,
+ gapSize: Int,
+ coercedValueAsFraction: Float,
+ ): Boolean
- fun getColor(sliderColors: SliderColors, isEnabled: Boolean): Color
+ /**
+ * [Contents] that is visually on the opposite side of the current one on the slider. This is
+ * handy when dealing with the rtl layouts
+ */
+ val mirrored: Contents
}
/** Provides visibility state for each of the Slider's icons. */
diff --git a/packages/SystemUI/src/com/android/systemui/volume/ui/slider/Slider.kt b/packages/SystemUI/src/com/android/systemui/volume/ui/compose/slider/Slider.kt
similarity index 94%
rename from packages/SystemUI/src/com/android/systemui/volume/ui/slider/Slider.kt
rename to packages/SystemUI/src/com/android/systemui/volume/ui/compose/slider/Slider.kt
index 502b311..54d2f79 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/ui/slider/Slider.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/ui/compose/slider/Slider.kt
@@ -16,7 +16,7 @@
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
-package com.android.systemui.volume.ui.slider
+package com.android.systemui.volume.ui.compose.slider
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
@@ -61,8 +61,6 @@
private val defaultSpring =
SpringSpec<Float>(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessHigh)
-private val defaultTrack: @Composable (SliderState) -> Unit =
- @Composable { SliderDefaults.Track(it) }
@Composable
fun Slider(
@@ -79,7 +77,14 @@
haptics: Haptics = Haptics.Disabled,
isVertical: Boolean = false,
isReverseDirection: Boolean = false,
- track: (@Composable (SliderState) -> Unit)? = null,
+ track: (@Composable (SliderState) -> Unit) = { SliderDefaults.Track(it) },
+ thumb: (@Composable (SliderState, MutableInteractionSource) -> Unit) = { _, _ ->
+ SliderDefaults.Thumb(
+ interactionSource = interactionSource,
+ colors = colors,
+ enabled = isEnabled,
+ )
+ },
) {
require(stepDistance >= 0) { "stepDistance must not be negative" }
val coroutineScope = rememberCoroutineScope()
@@ -139,7 +144,8 @@
reverseDirection = isReverseDirection,
interactionSource = interactionSource,
colors = colors,
- track = track ?: defaultTrack,
+ track = track,
+ thumb = { thumb(it, interactionSource) },
modifier = modifier.clearAndSetSemantics(semantics),
)
} else {
@@ -148,7 +154,8 @@
enabled = isEnabled,
interactionSource = interactionSource,
colors = colors,
- track = track ?: defaultTrack,
+ track = track,
+ thumb = { thumb(it, interactionSource) },
modifier = modifier.clearAndSetSemantics(semantics),
)
}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/ui/compose/slider/SliderIcon.kt b/packages/SystemUI/src/com/android/systemui/volume/ui/compose/slider/SliderIcon.kt
new file mode 100644
index 0000000..fd8f477
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/ui/compose/slider/SliderIcon.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.volume.ui.compose.slider
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+fun SliderIcon(
+ icon: @Composable BoxScope.() -> Unit,
+ isVisible: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = fadeIn(animationSpec = tween(delayMillis = 33, durationMillis = 100)),
+ exit = fadeOut(animationSpec = tween(durationMillis = 50)),
+ ) {
+ icon()
+ }
+ }
+}