1 /*
<lambda>null2 * Copyright (C) 2025 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
18
19 package com.android.systemui.volume.ui.compose.slider
20
21 import androidx.compose.animation.core.Spring
22 import androidx.compose.animation.core.animateFloatAsState
23 import androidx.compose.animation.core.spring
24 import androidx.compose.foundation.gestures.Orientation
25 import androidx.compose.foundation.interaction.MutableInteractionSource
26 import androidx.compose.material3.ExperimentalMaterial3Api
27 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
28 import androidx.compose.material3.Slider
29 import androidx.compose.material3.SliderColors
30 import androidx.compose.material3.SliderDefaults
31 import androidx.compose.material3.SliderState
32 import androidx.compose.material3.VerticalSlider
33 import androidx.compose.runtime.Composable
34 import androidx.compose.runtime.LaunchedEffect
35 import androidx.compose.runtime.State
36 import androidx.compose.runtime.getValue
37 import androidx.compose.runtime.mutableFloatStateOf
38 import androidx.compose.runtime.mutableStateOf
39 import androidx.compose.runtime.remember
40 import androidx.compose.runtime.setValue
41 import androidx.compose.runtime.snapshotFlow
42 import androidx.compose.ui.Modifier
43 import androidx.compose.ui.semantics.ProgressBarRangeInfo
44 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
45 import androidx.compose.ui.semantics.clearAndSetSemantics
46 import androidx.compose.ui.semantics.contentDescription
47 import androidx.compose.ui.semantics.disabled
48 import androidx.compose.ui.semantics.progressBarRangeInfo
49 import androidx.compose.ui.semantics.setProgress
50 import androidx.compose.ui.semantics.stateDescription
51 import com.android.systemui.haptics.slider.SliderHapticFeedbackFilter
52 import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
53 import com.android.systemui.lifecycle.rememberViewModel
54 import com.android.systemui.volume.haptics.ui.VolumeHapticsConfigsProvider
55 import kotlin.math.round
56 import kotlinx.coroutines.flow.distinctUntilChanged
57 import kotlinx.coroutines.flow.filter
58 import kotlinx.coroutines.flow.map
59
60 @Composable
61 fun Slider(
62 value: Float,
63 valueRange: ClosedFloatingPointRange<Float>,
64 onValueChanged: (Float) -> Unit,
65 onValueChangeFinished: ((Float) -> Unit)?,
66 isEnabled: Boolean,
67 accessibilityParams: AccessibilityParams,
68 modifier: Modifier = Modifier,
69 stepDistance: Float = 0f,
70 colors: SliderColors = SliderDefaults.colors(),
71 interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
72 haptics: Haptics = Haptics.Disabled,
73 isVertical: Boolean = false,
74 isReverseDirection: Boolean = false,
<lambda>null75 track: (@Composable (SliderState) -> Unit) = { SliderDefaults.Track(it) },
_null76 thumb: (@Composable (SliderState, MutableInteractionSource) -> Unit) = { _, _ ->
77 SliderDefaults.Thumb(
78 interactionSource = interactionSource,
79 colors = colors,
80 enabled = isEnabled,
81 )
82 },
83 ) {
<lambda>null84 require(stepDistance >= 0) { "stepDistance must not be negative" }
85 val snappedValue by valueState(value, isEnabled)
86 val hapticsViewModel = haptics.createViewModel(snappedValue, valueRange, interactionSource)
87
88 val sliderState =
<lambda>null89 remember(valueRange) { SliderState(value = snappedValue, valueRange = valueRange) }
newValuenull90 val valueChange: (Float) -> Unit = { newValue ->
91 hapticsViewModel?.onValueChange(newValue)
92 onValueChanged(newValue)
93 }
94 val semantics =
95 createSemantics(
96 accessibilityParams,
97 snappedValue,
98 valueRange,
99 valueChange,
100 isEnabled,
101 stepDistance,
102 )
103
<lambda>null104 sliderState.onValueChangeFinished = {
105 hapticsViewModel?.onValueChangeEnded()
106 onValueChangeFinished?.invoke(snappedValue)
107 }
108 sliderState.onValueChange = valueChange
109 sliderState.value = snappedValue
110
111 if (isVertical) {
112 VerticalSlider(
113 state = sliderState,
114 enabled = isEnabled,
115 reverseDirection = isReverseDirection,
116 interactionSource = interactionSource,
117 colors = colors,
118 track = track,
<lambda>null119 thumb = { thumb(it, interactionSource) },
120 modifier = modifier.clearAndSetSemantics(semantics),
121 )
122 } else {
123 Slider(
124 state = sliderState,
125 enabled = isEnabled,
126 interactionSource = interactionSource,
127 colors = colors,
128 track = track,
<lambda>null129 thumb = { thumb(it, interactionSource) },
130 modifier = modifier.clearAndSetSemantics(semantics),
131 )
132 }
133 }
134
135 @Composable
valueStatenull136 private fun valueState(targetValue: Float, isEnabled: Boolean): State<Float> {
137 var prevValue by remember { mutableFloatStateOf(targetValue) }
138 var prevEnabled by remember { mutableStateOf(isEnabled) }
139 // Don't animate slider value when receive the first value and when changing isEnabled state
140 val value =
141 if (prevEnabled != isEnabled) mutableFloatStateOf(targetValue)
142 else
143 animateFloatAsState(
144 targetValue = targetValue,
145 animationSpec =
146 spring(
147 dampingRatio = Spring.DampingRatioNoBouncy,
148 stiffness = Spring.StiffnessMedium,
149 ),
150 label = "VolumeSliderValueAnimation",
151 )
152 prevValue = targetValue
153 prevEnabled = isEnabled
154 return value
155 }
156
createSemanticsnull157 private fun createSemantics(
158 params: AccessibilityParams,
159 value: Float,
160 valueRange: ClosedFloatingPointRange<Float>,
161 onValueChanged: (Float) -> Unit,
162 isEnabled: Boolean,
163 stepDistance: Float,
164 ): SemanticsPropertyReceiver.() -> Unit {
165 return {
166 contentDescription = params.contentDescription
167 if (isEnabled) {
168 params.stateDescription?.let { stateDescription = it }
169 progressBarRangeInfo = ProgressBarRangeInfo(value, valueRange)
170 } else {
171 disabled()
172 }
173 setProgress { targetValue ->
174 val targetDirection =
175 when {
176 targetValue > value -> 1f
177 targetValue < value -> -1f
178 else -> 0f
179 }
180 val offset =
181 if (stepDistance > 0) {
182 // advance to the next step when stepDistance is > 0
183 targetDirection * stepDistance
184 } else {
185 // advance to the desired value otherwise
186 targetValue - value
187 }
188
189 val newValue = (value + offset).coerceIn(valueRange.start, valueRange.endInclusive)
190 onValueChanged(newValue)
191 true
192 }
193 }
194 }
195
196 @Composable
createViewModelnull197 private fun Haptics.createViewModel(
198 value: Float,
199 valueRange: ClosedFloatingPointRange<Float>,
200 interactionSource: MutableInteractionSource,
201 ): SliderHapticsViewModel? {
202 return when (this) {
203 is Haptics.Disabled -> null
204 is Haptics.Enabled -> {
205 hapticsViewModelFactory.let {
206 rememberViewModel(traceName = "SliderHapticsViewModel") {
207 it.create(
208 interactionSource,
209 valueRange,
210 orientation,
211 VolumeHapticsConfigsProvider.sliderHapticFeedbackConfig(
212 valueRange,
213 hapticFilter,
214 ),
215 VolumeHapticsConfigsProvider.seekableSliderTrackerConfig,
216 )
217 }
218 .also { hapticsViewModel ->
219 var lastDiscreteStep by remember { mutableFloatStateOf(value) }
220 LaunchedEffect(value) {
221 snapshotFlow { value }
222 .map { round(it) }
223 .filter { it != lastDiscreteStep }
224 .distinctUntilChanged()
225 .collect { discreteStep ->
226 lastDiscreteStep = discreteStep
227 hapticsViewModel.onValueChange(discreteStep)
228 }
229 }
230 }
231 }
232 }
233 }
234 }
235
236 data class AccessibilityParams(
237 val contentDescription: String,
238 val stateDescription: String? = null,
239 )
240
241 sealed interface Haptics {
242 data object Disabled : Haptics
243
244 data class Enabled(
245 val hapticsViewModelFactory: SliderHapticsViewModel.Factory,
246 val hapticFilter: SliderHapticFeedbackFilter,
247 val orientation: Orientation,
248 ) : Haptics
249 }
250