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