• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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