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 @file:OptIn(ExperimentalMaterial3Api::class)
18 
19 package com.android.compose
20 
21 import androidx.compose.animation.animateColorAsState
22 import androidx.compose.animation.core.animateDpAsState
23 import androidx.compose.animation.core.animateFloatAsState
24 import androidx.compose.foundation.Canvas
25 import androidx.compose.foundation.background
26 import androidx.compose.foundation.interaction.DragInteraction
27 import androidx.compose.foundation.interaction.MutableInteractionSource
28 import androidx.compose.foundation.layout.Box
29 import androidx.compose.foundation.layout.Spacer
30 import androidx.compose.foundation.layout.fillMaxSize
31 import androidx.compose.foundation.layout.height
32 import androidx.compose.foundation.layout.offset
33 import androidx.compose.foundation.layout.padding
34 import androidx.compose.foundation.layout.size
35 import androidx.compose.foundation.shape.CircleShape
36 import androidx.compose.material3.ExperimentalMaterial3Api
37 import androidx.compose.material3.LocalContentColor
38 import androidx.compose.material3.MaterialTheme
39 import androidx.compose.material3.Slider
40 import androidx.compose.material3.SliderState
41 import androidx.compose.runtime.Composable
42 import androidx.compose.runtime.CompositionLocalProvider
43 import androidx.compose.runtime.LaunchedEffect
44 import androidx.compose.runtime.getValue
45 import androidx.compose.runtime.mutableStateOf
46 import androidx.compose.runtime.remember
47 import androidx.compose.runtime.setValue
48 import androidx.compose.ui.Alignment
49 import androidx.compose.ui.Modifier
50 import androidx.compose.ui.draw.clip
51 import androidx.compose.ui.geometry.CornerRadius
52 import androidx.compose.ui.geometry.RoundRect
53 import androidx.compose.ui.graphics.Color
54 import androidx.compose.ui.graphics.Path
55 import androidx.compose.ui.graphics.drawscope.clipPath
56 import androidx.compose.ui.layout.Layout
57 import androidx.compose.ui.layout.Measurable
58 import androidx.compose.ui.layout.MeasurePolicy
59 import androidx.compose.ui.layout.MeasureResult
60 import androidx.compose.ui.layout.MeasureScope
61 import androidx.compose.ui.layout.Placeable
62 import androidx.compose.ui.layout.layoutId
63 import androidx.compose.ui.platform.LocalDensity
64 import androidx.compose.ui.platform.LocalLayoutDirection
65 import androidx.compose.ui.unit.Constraints
66 import androidx.compose.ui.unit.Dp
67 import androidx.compose.ui.unit.IntOffset
68 import androidx.compose.ui.unit.LayoutDirection
69 import androidx.compose.ui.unit.dp
70 import androidx.compose.ui.util.fastFirst
71 import androidx.compose.ui.util.fastFirstOrNull
72 
73 /**
74  * Platform slider implementation that displays a slider with an [icon] and a [label] at the start.
75  *
76  * @param onValueChangeFinished is called when the slider settles on a [value]. This callback
77  *   shouldn't be used to react to value changes. Use [onValueChange] instead
78  * @param interactionSource the [MutableInteractionSource] representing the stream of Interactions
79  *   for this slider. You can create and pass in your own remembered instance to observe
80  *   Interactions and customize the appearance / behavior of this slider in different states.
81  * @param colors determine slider color scheme.
82  * @param draggingCornersRadius is the radius of the slider indicator when the user drags it
83  * @param icon at the start of the slider. Icon is limited to a square space at the start of the
84  *   slider
85  * @param label is shown next to the icon.
86  */
87 @OptIn(ExperimentalMaterial3Api::class)
88 @Composable
89 fun PlatformSlider(
90     value: Float,
91     onValueChange: (Float) -> Unit,
92     modifier: Modifier = Modifier,
93     onValueChangeFinished: (() -> Unit)? = null,
94     valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
95     enabled: Boolean = true,
96     interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
97     colors: PlatformSliderColors = PlatformSliderDefaults.defaultPlatformSliderColors(),
98     draggingCornersRadius: Dp = PlatformSliderDefaults.DefaultPlatformSliderDraggingCornerRadius,
99     icon: (@Composable (isDragging: Boolean) -> Unit)? = null,
100     label: (@Composable (isDragging: Boolean) -> Unit)? = null,
101 ) {
102     val sliderHeight: Dp = 64.dp
103     val thumbSize: Dp = sliderHeight
<lambda>null104     var isDragging by remember { mutableStateOf(false) }
<lambda>null105     LaunchedEffect(interactionSource) {
106         interactionSource.interactions.collect { interaction ->
107             when (interaction) {
108                 is DragInteraction.Start -> {
109                     isDragging = true
110                 }
111                 is DragInteraction.Cancel,
112                 is DragInteraction.Stop -> {
113                     isDragging = false
114                 }
115             }
116         }
117     }
118 
<lambda>null119     Box(modifier = modifier.height(sliderHeight)) {
120         Slider(
121             modifier = Modifier.fillMaxSize(),
122             value = value,
123             onValueChange = onValueChange,
124             valueRange = valueRange,
125             onValueChangeFinished = onValueChangeFinished,
126             interactionSource = interactionSource,
127             enabled = enabled,
128             track = {
129                 Track(
130                     sliderState = it,
131                     enabled = enabled,
132                     colors = colors,
133                     draggingCornersRadius = draggingCornersRadius,
134                     sliderHeight = sliderHeight,
135                     thumbSize = thumbSize,
136                     isDragging = isDragging,
137                     label = label,
138                     icon = icon,
139                     modifier = Modifier.fillMaxSize(),
140                 )
141             },
142             thumb = { Spacer(Modifier.size(thumbSize)) },
143         )
144 
145         if (enabled) {
146             Spacer(
147                 Modifier.padding(8.dp)
148                     .size(4.dp)
149                     .align(Alignment.CenterEnd)
150                     .background(color = colors.indicatorColor, shape = CircleShape)
151             )
152         }
153     }
154 }
155 
156 private enum class TrackComponent(val zIndex: Float) {
157     Background(0f),
158     Icon(1f),
159     Label(1f),
160 }
161 
162 @Composable
Tracknull163 private fun Track(
164     sliderState: SliderState,
165     enabled: Boolean,
166     colors: PlatformSliderColors,
167     draggingCornersRadius: Dp,
168     sliderHeight: Dp,
169     thumbSize: Dp,
170     isDragging: Boolean,
171     icon: (@Composable (isDragging: Boolean) -> Unit)?,
172     label: (@Composable (isDragging: Boolean) -> Unit)?,
173     modifier: Modifier = Modifier,
174 ) {
175     val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
176     var drawingState: DrawingState by remember { mutableStateOf(DrawingState()) }
177     Layout(
178         modifier = modifier,
179         content = {
180             TrackBackground(
181                 modifier = Modifier.layoutId(TrackComponent.Background),
182                 drawingState = drawingState,
183                 enabled = enabled,
184                 colors = colors,
185                 draggingCornersRadiusActive = draggingCornersRadius,
186                 draggingCornersRadiusIdle = sliderHeight / 2,
187                 isDragging = isDragging,
188             )
189             if (icon != null) {
190                 Box(
191                     modifier = Modifier.layoutId(TrackComponent.Icon).clip(CircleShape),
192                     contentAlignment = Alignment.Center,
193                 ) {
194                     CompositionLocalProvider(
195                         LocalContentColor provides
196                             if (enabled) colors.iconColor else colors.disabledIconColor
197                     ) {
198                         icon(isDragging)
199                     }
200                 }
201             }
202             if (label != null) {
203                 val offsetX by
204                     animateFloatAsState(
205                         targetValue =
206                             if (enabled) {
207                                 if (drawingState.isLabelOnTopOfIndicator) {
208                                     drawingState.iconWidth.coerceAtLeast(
209                                         LocalDensity.current.run { 16.dp.toPx() }
210                                     )
211                                 } else {
212                                     val indicatorWidth =
213                                         drawingState.indicatorRight - drawingState.indicatorLeft
214                                     indicatorWidth + LocalDensity.current.run { 16.dp.toPx() }
215                                 }
216                             } else {
217                                 drawingState.iconWidth
218                             },
219                         label = "LabelIconSpacingAnimation"
220                     )
221                 Box(
222                     modifier =
223                         Modifier.layoutId(TrackComponent.Label)
224                             .offset { IntOffset(offsetX.toInt(), 0) }
225                             .padding(end = 16.dp),
226                     contentAlignment = Alignment.CenterStart,
227                 ) {
228                     CompositionLocalProvider(
229                         LocalContentColor provides
230                             colors.getLabelColor(
231                                 isEnabled = enabled,
232                                 isLabelOnTopOfTheIndicator = drawingState.isLabelOnTopOfIndicator,
233                             )
234                     ) {
235                         label(isDragging)
236                     }
237                 }
238             }
239         },
240         measurePolicy =
241             TrackMeasurePolicy(
242                 sliderState = sliderState,
243                 enabled = enabled,
244                 thumbSize = LocalDensity.current.run { thumbSize.roundToPx() },
245                 isRtl = isRtl,
246                 onDrawingStateMeasured = { drawingState = it }
247             )
248     )
249 }
250 
251 @Composable
TrackBackgroundnull252 private fun TrackBackground(
253     drawingState: DrawingState,
254     enabled: Boolean,
255     colors: PlatformSliderColors,
256     draggingCornersRadiusActive: Dp,
257     draggingCornersRadiusIdle: Dp,
258     isDragging: Boolean,
259     modifier: Modifier = Modifier,
260 ) {
261     val indicatorRadiusDp: Dp by
262         animateDpAsState(
263             targetValue =
264                 if (isDragging) draggingCornersRadiusActive else draggingCornersRadiusIdle,
265             label = "PlatformSliderCornersAnimation",
266         )
267 
268     val trackColor by
269         animateColorAsState(
270             colors.getTrackColor(enabled),
271             label = "PlatformSliderTrackColorAnimation",
272         )
273 
274     val indicatorColor by
275         animateColorAsState(
276             colors.getIndicatorColor(enabled),
277             label = "PlatformSliderIndicatorColorAnimation",
278         )
279     Canvas(modifier.fillMaxSize()) {
280         val trackCornerRadius = CornerRadius(size.height / 2, size.height / 2)
281         val trackPath = Path()
282         trackPath.addRoundRect(
283             RoundRect(
284                 left = 0f,
285                 top = 0f,
286                 right = drawingState.totalWidth,
287                 bottom = drawingState.totalHeight,
288                 cornerRadius = trackCornerRadius,
289             )
290         )
291         drawPath(path = trackPath, color = trackColor)
292 
293         val indicatorCornerRadius = CornerRadius(indicatorRadiusDp.toPx(), indicatorRadiusDp.toPx())
294         clipPath(trackPath) {
295             val indicatorPath = Path()
296             indicatorPath.addRoundRect(
297                 RoundRect(
298                     left = drawingState.indicatorLeft,
299                     top = drawingState.indicatorTop,
300                     right = drawingState.indicatorRight,
301                     bottom = drawingState.indicatorBottom,
302                     topLeftCornerRadius = trackCornerRadius,
303                     topRightCornerRadius = indicatorCornerRadius,
304                     bottomRightCornerRadius = indicatorCornerRadius,
305                     bottomLeftCornerRadius = trackCornerRadius,
306                 )
307             )
308             drawPath(path = indicatorPath, color = indicatorColor)
309         }
310     }
311 }
312 
313 /** Measures track components sizes and calls [onDrawingStateMeasured] when it's done. */
314 private class TrackMeasurePolicy(
315     private val sliderState: SliderState,
316     private val enabled: Boolean,
317     private val thumbSize: Int,
318     private val isRtl: Boolean,
319     private val onDrawingStateMeasured: (DrawingState) -> Unit,
320 ) : MeasurePolicy {
321 
measurenull322     override fun MeasureScope.measure(
323         measurables: List<Measurable>,
324         constraints: Constraints
325     ): MeasureResult {
326         // Slider adds a paddings to the Track to make spase for thumb
327         val desiredWidth = constraints.maxWidth + thumbSize
328         val desiredHeight = constraints.maxHeight
329         val backgroundPlaceable: Placeable =
330             measurables
331                 .fastFirst { it.layoutId == TrackComponent.Background }
332                 .measure(Constraints(desiredWidth, desiredWidth, desiredHeight, desiredHeight))
333 
334         val iconPlaceable: Placeable? =
335             measurables
336                 .fastFirstOrNull { it.layoutId == TrackComponent.Icon }
337                 ?.measure(
338                     Constraints(
339                         minWidth = desiredHeight,
340                         maxWidth = desiredHeight,
341                         minHeight = desiredHeight,
342                         maxHeight = desiredHeight,
343                     )
344                 )
345 
346         val iconSize = iconPlaceable?.width ?: 0
347         val labelMaxWidth = if (enabled) (desiredWidth - iconSize) / 2 else desiredWidth - iconSize
348         val labelPlaceable: Placeable? =
349             measurables
350                 .fastFirstOrNull { it.layoutId == TrackComponent.Label }
351                 ?.measure(
352                     Constraints(
353                         minWidth = 0,
354                         maxWidth = labelMaxWidth,
355                         minHeight = desiredHeight,
356                         maxHeight = desiredHeight,
357                     )
358                 )
359 
360         val drawingState =
361             if (isRtl) {
362                 DrawingState(
363                     isRtl = true,
364                     totalWidth = desiredWidth.toFloat(),
365                     totalHeight = desiredHeight.toFloat(),
366                     indicatorLeft =
367                         (desiredWidth - iconSize) * (1 - sliderState.coercedNormalizedValue),
368                     indicatorTop = 0f,
369                     indicatorRight = desiredWidth.toFloat(),
370                     indicatorBottom = desiredHeight.toFloat(),
371                     iconWidth = iconSize.toFloat(),
372                     labelWidth = labelPlaceable?.width?.toFloat() ?: 0f,
373                 )
374             } else {
375                 DrawingState(
376                     isRtl = false,
377                     totalWidth = desiredWidth.toFloat(),
378                     totalHeight = desiredHeight.toFloat(),
379                     indicatorLeft = 0f,
380                     indicatorTop = 0f,
381                     indicatorRight =
382                         iconSize + (desiredWidth - iconSize) * sliderState.coercedNormalizedValue,
383                     indicatorBottom = desiredHeight.toFloat(),
384                     iconWidth = iconSize.toFloat(),
385                     labelWidth = labelPlaceable?.width?.toFloat() ?: 0f,
386                 )
387             }
388 
389         onDrawingStateMeasured(drawingState)
390 
391         return layout(desiredWidth, desiredHeight) {
392             backgroundPlaceable.placeRelative(0, 0, TrackComponent.Background.zIndex)
393 
394             iconPlaceable?.placeRelative(0, 0, TrackComponent.Icon.zIndex)
395             labelPlaceable?.placeRelative(0, 0, TrackComponent.Label.zIndex)
396         }
397     }
398 }
399 
400 private data class DrawingState(
401     val isRtl: Boolean = false,
402     val totalWidth: Float = 0f,
403     val totalHeight: Float = 0f,
404     val indicatorLeft: Float = 0f,
405     val indicatorTop: Float = 0f,
406     val indicatorRight: Float = 0f,
407     val indicatorBottom: Float = 0f,
408     val iconWidth: Float = 0f,
409     val labelWidth: Float = 0f,
410 )
411 
412 private val DrawingState.isLabelOnTopOfIndicator: Boolean
413     get() = labelWidth < indicatorRight - indicatorLeft - iconWidth
414 
415 /** [SliderState.value] normalized using [SliderState.valueRange]. The result belongs to [0, 1] */
416 private val SliderState.coercedNormalizedValue: Float
417     get() {
418         val dif = valueRange.endInclusive - valueRange.start
419         return if (dif == 0f) {
420             0f
421         } else {
422             val coercedValue = value.coerceIn(valueRange.start, valueRange.endInclusive)
423             (coercedValue - valueRange.start) / dif
424         }
425     }
426 
427 /**
428  * [PlatformSlider] color scheme.
429  *
430  * @param trackColor fills the track of the slider. This is a "background" of the slider
431  * @param indicatorColor fills the slider from the start to the value
432  * @param iconColor is the default icon color
433  * @param labelColorOnIndicator is the label color for when it's shown on top of the indicator
434  * @param labelColorOnTrack is the label color for when it's shown on top of the track
435  * @param disabledTrackColor is the [trackColor] when the PlatformSlider#enabled == false
436  * @param disabledIndicatorColor is the [indicatorColor] when the PlatformSlider#enabled == false
437  * @param disabledIconColor is the [iconColor] when the PlatformSlider#enabled == false
438  * @param disabledLabelColor is the label color when the PlatformSlider#enabled == false
439  */
440 data class PlatformSliderColors(
441     val trackColor: Color,
442     val indicatorColor: Color,
443     val iconColor: Color,
444     val labelColorOnIndicator: Color,
445     val labelColorOnTrack: Color,
446     val disabledTrackColor: Color,
447     val disabledIndicatorColor: Color,
448     val disabledIconColor: Color,
449     val disabledLabelColor: Color,
450 )
451 
452 object PlatformSliderDefaults {
453 
454     /** Indicator corner radius used when the user drags the [PlatformSlider]. */
455     val DefaultPlatformSliderDraggingCornerRadius = 8.dp
456 
457     @Composable
defaultPlatformSliderColorsnull458     fun defaultPlatformSliderColors(): PlatformSliderColors =
459         PlatformSliderColors(
460             trackColor = MaterialTheme.colorScheme.secondaryContainer,
461             indicatorColor = MaterialTheme.colorScheme.primary,
462             iconColor = MaterialTheme.colorScheme.onPrimary,
463             labelColorOnIndicator = MaterialTheme.colorScheme.onPrimary,
464             labelColorOnTrack = MaterialTheme.colorScheme.onSecondaryContainer,
465             disabledTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
466             disabledIndicatorColor = MaterialTheme.colorScheme.surfaceContainerHighest,
467             disabledIconColor = MaterialTheme.colorScheme.outline,
468             disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
469         )
470 }
471 
472 private fun PlatformSliderColors.getTrackColor(isEnabled: Boolean): Color =
473     if (isEnabled) trackColor else disabledTrackColor
474 
475 private fun PlatformSliderColors.getIndicatorColor(isEnabled: Boolean): Color =
476     if (isEnabled) indicatorColor else disabledIndicatorColor
477 
478 private fun PlatformSliderColors.getLabelColor(
479     isEnabled: Boolean,
480     isLabelOnTopOfTheIndicator: Boolean
481 ): Color {
482     return if (isEnabled) {
483         if (isLabelOnTopOfTheIndicator) labelColorOnIndicator else labelColorOnTrack
484     } else {
485         disabledLabelColor
486     }
487 }
488