1 /*
<lambda>null2 * Copyright (C) 2024 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 package com.android.systemui.volume.panel.component.volume.ui.composable
18
19 import androidx.compose.animation.AnimatedVisibility
20 import androidx.compose.animation.animateContentSize
21 import androidx.compose.animation.core.animateFloatAsState
22 import androidx.compose.animation.core.tween
23 import androidx.compose.animation.fadeIn
24 import androidx.compose.animation.fadeOut
25 import androidx.compose.foundation.basicMarquee
26 import androidx.compose.foundation.clickable
27 import androidx.compose.foundation.gestures.Orientation
28 import androidx.compose.foundation.interaction.MutableInteractionSource
29 import androidx.compose.foundation.layout.Arrangement
30 import androidx.compose.foundation.layout.Box
31 import androidx.compose.foundation.layout.Column
32 import androidx.compose.foundation.layout.Row
33 import androidx.compose.foundation.layout.RowScope
34 import androidx.compose.foundation.layout.fillMaxSize
35 import androidx.compose.foundation.layout.fillMaxWidth
36 import androidx.compose.foundation.layout.padding
37 import androidx.compose.foundation.layout.size
38 import androidx.compose.material3.ExperimentalMaterial3Api
39 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
40 import androidx.compose.material3.Icon as MaterialIcon
41 import androidx.compose.material3.MaterialTheme
42 import androidx.compose.material3.SliderDefaults
43 import androidx.compose.material3.Text
44 import androidx.compose.runtime.Composable
45 import androidx.compose.runtime.LaunchedEffect
46 import androidx.compose.runtime.State
47 import androidx.compose.runtime.getValue
48 import androidx.compose.runtime.mutableFloatStateOf
49 import androidx.compose.runtime.mutableStateOf
50 import androidx.compose.runtime.remember
51 import androidx.compose.runtime.setValue
52 import androidx.compose.runtime.snapshotFlow
53 import androidx.compose.ui.Alignment
54 import androidx.compose.ui.Modifier
55 import androidx.compose.ui.res.painterResource
56 import androidx.compose.ui.semantics.CustomAccessibilityAction
57 import androidx.compose.ui.semantics.ProgressBarRangeInfo
58 import androidx.compose.ui.semantics.clearAndSetSemantics
59 import androidx.compose.ui.semantics.contentDescription
60 import androidx.compose.ui.semantics.customActions
61 import androidx.compose.ui.semantics.disabled
62 import androidx.compose.ui.semantics.progressBarRangeInfo
63 import androidx.compose.ui.semantics.setProgress
64 import androidx.compose.ui.semantics.stateDescription
65 import androidx.compose.ui.unit.DpSize
66 import androidx.compose.ui.unit.dp
67 import com.android.compose.PlatformSlider
68 import com.android.compose.PlatformSliderColors
69 import com.android.systemui.Flags
70 import com.android.systemui.common.shared.model.Icon as IconModel
71 import com.android.systemui.common.ui.compose.Icon
72 import com.android.systemui.compose.modifiers.sysuiResTag
73 import com.android.systemui.haptics.slider.SliderHapticFeedbackFilter
74 import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
75 import com.android.systemui.lifecycle.rememberViewModel
76 import com.android.systemui.res.R
77 import com.android.systemui.volume.dialog.sliders.ui.compose.SliderTrack
78 import com.android.systemui.volume.haptics.ui.VolumeHapticsConfigsProvider
79 import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderState
80 import com.android.systemui.volume.ui.compose.slider.AccessibilityParams
81 import com.android.systemui.volume.ui.compose.slider.Haptics
82 import com.android.systemui.volume.ui.compose.slider.Slider
83 import com.android.systemui.volume.ui.compose.slider.SliderIcon
84 import kotlin.math.round
85 import kotlinx.coroutines.flow.distinctUntilChanged
86 import kotlinx.coroutines.flow.filter
87 import kotlinx.coroutines.flow.map
88
89 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
90 @Composable
91 fun VolumeSlider(
92 state: SliderState,
93 onValueChange: (newValue: Float) -> Unit,
94 onIconTapped: () -> Unit,
95 sliderColors: PlatformSliderColors,
96 modifier: Modifier = Modifier,
97 hapticsViewModelFactory: SliderHapticsViewModel.Factory?,
98 onValueChangeFinished: (() -> Unit)? = null,
99 button: (@Composable RowScope.() -> Unit)? = null,
100 ) {
101 if (!Flags.volumeRedesign()) {
102 LegacyVolumeSlider(
103 state = state,
104 onValueChange = onValueChange,
105 onIconTapped = onIconTapped,
106 sliderColors = sliderColors,
107 onValueChangeFinished = onValueChangeFinished,
108 modifier = modifier,
109 hapticsViewModelFactory = hapticsViewModelFactory,
110 )
111 return
112 }
113
114 Column(modifier = modifier.animateContentSize()) {
115 Text(
116 text = state.label,
117 style = MaterialTheme.typography.titleMedium,
118 color = MaterialTheme.colorScheme.onSurface,
119 modifier = Modifier.fillMaxWidth().clearAndSetSemantics {},
120 )
121 Row(
122 horizontalArrangement = Arrangement.spacedBy(8.dp),
123 modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
124 verticalAlignment = Alignment.CenterVertically,
125 ) {
126 val materialSliderColors =
127 SliderDefaults.colors(
128 activeTickColor = MaterialTheme.colorScheme.surfaceContainerHigh,
129 inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
130 disabledActiveTickColor = MaterialTheme.colorScheme.surfaceContainerHigh,
131 disabledInactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
132 )
133 Slider(
134 value = state.value,
135 valueRange = state.valueRange,
136 onValueChanged = onValueChange,
137 onValueChangeFinished = { onValueChangeFinished?.invoke() },
138 colors = materialSliderColors,
139 isEnabled = state.isEnabled,
140 stepDistance = state.step,
141 accessibilityParams =
142 AccessibilityParams(
143 contentDescription = state.a11yContentDescription,
144 stateDescription = state.a11yStateDescription,
145 ),
146 track = { sliderState ->
147 SliderTrack(
148 sliderState = sliderState,
149 colors = materialSliderColors,
150 isEnabled = state.isEnabled,
151 activeTrackStartIcon =
152 state.icon?.let { icon ->
153 { iconsState ->
154 SliderIcon(
155 icon = {
156 Icon(icon = icon, modifier = Modifier.size(24.dp))
157 },
158 isVisible = iconsState.isActiveTrackStartIconVisible,
159 )
160 }
161 },
162 inactiveTrackStartIcon =
163 state.icon?.let { icon ->
164 { iconsState ->
165 SliderIcon(
166 icon = {
167 Icon(icon = icon, modifier = Modifier.size(24.dp))
168 },
169 isVisible = !iconsState.isActiveTrackStartIconVisible,
170 )
171 }
172 },
173 )
174 },
175 thumb = { sliderState, interactionSource ->
176 SliderDefaults.Thumb(
177 sliderState = sliderState,
178 interactionSource = interactionSource,
179 enabled = state.isEnabled,
180 colors = materialSliderColors,
181 thumbSize = DpSize(4.dp, 52.dp),
182 )
183 },
184 haptics =
185 hapticsViewModelFactory?.let {
186 Haptics.Enabled(
187 hapticsViewModelFactory = it,
188 hapticFilter = state.hapticFilter,
189 orientation = Orientation.Horizontal,
190 )
191 } ?: Haptics.Disabled,
192 modifier = Modifier.weight(1f).sysuiResTag(state.label),
193 )
194 button?.invoke(this)
195 }
196 state.disabledMessage?.let { disabledMessage ->
197 AnimatedVisibility(visible = !state.isEnabled) {
198 Row(
199 modifier = Modifier.padding(bottom = 12.dp),
200 horizontalArrangement = Arrangement.spacedBy(8.dp),
201 verticalAlignment = Alignment.CenterVertically,
202 ) {
203 MaterialIcon(
204 painter = painterResource(R.drawable.ic_error_outline),
205 contentDescription = null,
206 tint = MaterialTheme.colorScheme.onSurfaceVariant,
207 modifier = Modifier.size(16.dp),
208 )
209 Text(
210 text = disabledMessage,
211 color = MaterialTheme.colorScheme.onSurfaceVariant,
212 style = MaterialTheme.typography.labelSmall,
213 modifier = Modifier.basicMarquee().clearAndSetSemantics {},
214 )
215 }
216 }
217 }
218 }
219 }
220
221 @Composable
LegacyVolumeSlidernull222 private fun LegacyVolumeSlider(
223 state: SliderState,
224 onValueChange: (newValue: Float) -> Unit,
225 onIconTapped: () -> Unit,
226 sliderColors: PlatformSliderColors,
227 hapticsViewModelFactory: SliderHapticsViewModel.Factory?,
228 modifier: Modifier = Modifier,
229 onValueChangeFinished: (() -> Unit)? = null,
230 ) {
231 val value by valueState(state)
232 val interactionSource = remember { MutableInteractionSource() }
233 val hapticsViewModel: SliderHapticsViewModel? =
234 setUpHapticsViewModel(
235 value,
236 state.valueRange,
237 state.hapticFilter,
238 interactionSource,
239 hapticsViewModelFactory,
240 )
241
242 PlatformSlider(
243 modifier =
244 modifier.sysuiResTag(state.label).clearAndSetSemantics {
245 if (state.isEnabled) {
246 contentDescription = state.label
247 state.a11yClickDescription?.let {
248 customActions =
249 listOf(
250 CustomAccessibilityAction(it) {
251 onIconTapped()
252 true
253 }
254 )
255 }
256
257 state.a11yStateDescription?.let { stateDescription = it }
258 progressBarRangeInfo = ProgressBarRangeInfo(state.value, state.valueRange)
259 } else {
260 disabled()
261 contentDescription =
262 state.disabledMessage?.let { "${state.label}, $it" } ?: state.label
263 }
264 setProgress { targetValue ->
265 val targetDirection =
266 when {
267 targetValue > value -> 1
268 targetValue < value -> -1
269 else -> 0
270 }
271
272 val newValue =
273 (value + targetDirection * state.step).coerceIn(
274 state.valueRange.start,
275 state.valueRange.endInclusive,
276 )
277 onValueChange(newValue)
278 true
279 }
280 },
281 value = value,
282 valueRange = state.valueRange,
283 onValueChange = { newValue ->
284 hapticsViewModel?.addVelocityDataPoint(newValue)
285 onValueChange(newValue)
286 },
287 onValueChangeFinished = {
288 hapticsViewModel?.onValueChangeEnded()
289 onValueChangeFinished?.invoke()
290 },
291 enabled = state.isEnabled,
292 icon = {
293 state.icon?.let {
294 LegacySliderIcon(
295 icon = it,
296 onIconTapped = onIconTapped,
297 isTappable = state.isMutable,
298 )
299 }
300 },
301 colors = sliderColors,
302 label = { isDragging ->
303 AnimatedVisibility(
304 visible = !isDragging,
305 enter = fadeIn(tween(150)),
306 exit = fadeOut(tween(150)),
307 ) {
308 VolumeSliderContent(
309 modifier = Modifier,
310 label = state.label,
311 isEnabled = state.isEnabled,
312 disabledMessage = state.disabledMessage,
313 )
314 }
315 },
316 interactionSource = interactionSource,
317 )
318 }
319
320 @Composable
valueStatenull321 private fun valueState(state: SliderState): State<Float> {
322 var prevState by remember { mutableStateOf(state) }
323 // Don't animate slider value when receive the first value and when changing isEnabled state
324 val shouldSkipAnimation =
325 prevState is SliderState.Empty || prevState.isEnabled != state.isEnabled
326 val value =
327 if (shouldSkipAnimation) remember { mutableFloatStateOf(state.value) }
328 else animateFloatAsState(targetValue = state.value, label = "VolumeSliderValueAnimation")
329 prevState = state
330 return value
331 }
332
333 @Composable
LegacySliderIconnull334 private fun LegacySliderIcon(
335 icon: IconModel,
336 onIconTapped: () -> Unit,
337 isTappable: Boolean,
338 modifier: Modifier = Modifier,
339 ) {
340 val boxModifier =
341 if (isTappable) {
342 modifier.clickable(
343 onClick = onIconTapped,
344 interactionSource = null,
345 indication = null,
346 )
347 } else {
348 modifier
349 }
350 .fillMaxSize()
351 Box(
352 modifier = boxModifier,
353 contentAlignment = Alignment.Center,
354 content = { Icon(modifier = Modifier.size(24.dp), icon = icon) },
355 )
356 }
357
358 @Composable
setUpHapticsViewModelnull359 private fun setUpHapticsViewModel(
360 value: Float,
361 valueRange: ClosedFloatingPointRange<Float>,
362 hapticFilter: SliderHapticFeedbackFilter,
363 interactionSource: MutableInteractionSource,
364 hapticsViewModelFactory: SliderHapticsViewModel.Factory?,
365 ): SliderHapticsViewModel? {
366 return hapticsViewModelFactory?.let {
367 rememberViewModel(traceName = "SliderHapticsViewModel") {
368 it.create(
369 interactionSource,
370 valueRange,
371 Orientation.Horizontal,
372 VolumeHapticsConfigsProvider.sliderHapticFeedbackConfig(
373 valueRange,
374 hapticFilter,
375 ),
376 VolumeHapticsConfigsProvider.seekableSliderTrackerConfig,
377 )
378 }
379 .also { hapticsViewModel ->
380 var lastDiscreteStep by remember { mutableFloatStateOf(round(value)) }
381 LaunchedEffect(value) {
382 snapshotFlow { value }
383 .map { round(it) }
384 .filter { it != lastDiscreteStep }
385 .distinctUntilChanged()
386 .collect { discreteStep ->
387 lastDiscreteStep = discreteStep
388 hapticsViewModel.onValueChange(discreteStep)
389 }
390 }
391 }
392 }
393 }
394