1 /*
<lambda>null2 * Copyright 2019 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 androidx.compose.material
18
19 import androidx.annotation.IntRange
20 import androidx.compose.animation.core.Animatable
21 import androidx.compose.animation.core.TweenSpec
22 import androidx.compose.foundation.Canvas
23 import androidx.compose.foundation.MutatePriority
24 import androidx.compose.foundation.MutatorMutex
25 import androidx.compose.foundation.background
26 import androidx.compose.foundation.focusable
27 import androidx.compose.foundation.gestures.DragScope
28 import androidx.compose.foundation.gestures.DraggableState
29 import androidx.compose.foundation.gestures.GestureCancellationException
30 import androidx.compose.foundation.gestures.Orientation
31 import androidx.compose.foundation.gestures.awaitEachGesture
32 import androidx.compose.foundation.gestures.awaitFirstDown
33 import androidx.compose.foundation.gestures.detectTapGestures
34 import androidx.compose.foundation.gestures.draggable
35 import androidx.compose.foundation.gestures.horizontalDrag
36 import androidx.compose.foundation.hoverable
37 import androidx.compose.foundation.indication
38 import androidx.compose.foundation.interaction.DragInteraction
39 import androidx.compose.foundation.interaction.Interaction
40 import androidx.compose.foundation.interaction.MutableInteractionSource
41 import androidx.compose.foundation.interaction.PressInteraction
42 import androidx.compose.foundation.layout.Box
43 import androidx.compose.foundation.layout.BoxScope
44 import androidx.compose.foundation.layout.BoxWithConstraints
45 import androidx.compose.foundation.layout.Spacer
46 import androidx.compose.foundation.layout.fillMaxSize
47 import androidx.compose.foundation.layout.heightIn
48 import androidx.compose.foundation.layout.padding
49 import androidx.compose.foundation.layout.requiredSizeIn
50 import androidx.compose.foundation.layout.size
51 import androidx.compose.foundation.layout.widthIn
52 import androidx.compose.foundation.progressSemantics
53 import androidx.compose.foundation.shape.CircleShape
54 import androidx.compose.runtime.Composable
55 import androidx.compose.runtime.Immutable
56 import androidx.compose.runtime.LaunchedEffect
57 import androidx.compose.runtime.MutableState
58 import androidx.compose.runtime.SideEffect
59 import androidx.compose.runtime.Stable
60 import androidx.compose.runtime.State
61 import androidx.compose.runtime.getValue
62 import androidx.compose.runtime.mutableFloatStateOf
63 import androidx.compose.runtime.mutableStateListOf
64 import androidx.compose.runtime.mutableStateOf
65 import androidx.compose.runtime.remember
66 import androidx.compose.runtime.rememberCoroutineScope
67 import androidx.compose.runtime.rememberUpdatedState
68 import androidx.compose.runtime.setValue
69 import androidx.compose.ui.Alignment
70 import androidx.compose.ui.Modifier
71 import androidx.compose.ui.composed
72 import androidx.compose.ui.draw.shadow
73 import androidx.compose.ui.geometry.Offset
74 import androidx.compose.ui.geometry.lerp
75 import androidx.compose.ui.graphics.Color
76 import androidx.compose.ui.graphics.PointMode
77 import androidx.compose.ui.graphics.StrokeCap
78 import androidx.compose.ui.graphics.compositeOver
79 import androidx.compose.ui.input.key.Key
80 import androidx.compose.ui.input.key.KeyEventType
81 import androidx.compose.ui.input.key.key
82 import androidx.compose.ui.input.key.onKeyEvent
83 import androidx.compose.ui.input.key.type
84 import androidx.compose.ui.input.pointer.AwaitPointerEventScope
85 import androidx.compose.ui.input.pointer.PointerId
86 import androidx.compose.ui.input.pointer.PointerInputChange
87 import androidx.compose.ui.input.pointer.PointerType
88 import androidx.compose.ui.input.pointer.pointerInput
89 import androidx.compose.ui.input.pointer.positionChange
90 import androidx.compose.ui.platform.LocalDensity
91 import androidx.compose.ui.platform.LocalLayoutDirection
92 import androidx.compose.ui.platform.debugInspectorInfo
93 import androidx.compose.ui.semantics.contentDescription
94 import androidx.compose.ui.semantics.disabled
95 import androidx.compose.ui.semantics.semantics
96 import androidx.compose.ui.semantics.setProgress
97 import androidx.compose.ui.unit.Dp
98 import androidx.compose.ui.unit.LayoutDirection
99 import androidx.compose.ui.unit.dp
100 import androidx.compose.ui.util.fastCoerceIn
101 import androidx.compose.ui.util.fastMap
102 import androidx.compose.ui.util.fastMinByOrNull
103 import androidx.compose.ui.util.lerp
104 import kotlin.math.abs
105 import kotlin.math.floor
106 import kotlin.math.max
107 import kotlin.math.min
108 import kotlinx.coroutines.CancellationException
109 import kotlinx.coroutines.CoroutineScope
110 import kotlinx.coroutines.coroutineScope
111 import kotlinx.coroutines.launch
112
113 /**
114 * [Material Design slider](https://material.io/components/sliders)
115 *
116 * Sliders allow users to make selections from a range of values.
117 *
118 * Sliders reflect a range of values along a bar, from which users may select a single value. They
119 * are ideal for adjusting settings such as volume, brightness, or applying image filters.
120 *
121 * 
123 *
124 * Use continuous sliders to allow users to make meaningful selections that don’t require a specific
125 * value:
126 *
127 * @sample androidx.compose.material.samples.SliderSample
128 *
129 * You can allow the user to choose only between predefined set of values by specifying the amount
130 * of steps between min and max values:
131 *
132 * @sample androidx.compose.material.samples.StepsSliderSample
133 * @param value current value of the Slider. If outside of [valueRange] provided, value will be
134 * coerced to this range.
135 * @param onValueChange lambda in which value should be updated
136 * @param modifier modifiers for the Slider layout
137 * @param enabled whether or not component is enabled and can be interacted with or not
138 * @param valueRange range of values that Slider value can take. Passed [value] will be coerced to
139 * this range
140 * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
141 * of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
142 * distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
143 * continuously and allow any value from the range. Must not be negative.
144 * @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
145 * shouldn't be used to update the slider value (use [onValueChange] for that), but rather to know
146 * when the user has completed selecting a new value by ending a drag or a click.
147 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
148 * emitting [Interaction]s for this slider. You can use this to change the slider's appearance or
149 * preview the slider in different states. Note that if `null` is provided, interactions will
150 * still happen internally.
151 * @param colors [SliderColors] that will be used to determine the color of the Slider parts in
152 * different state. See [SliderDefaults.colors] to customize.
153 */
154 @Composable
155 fun Slider(
156 value: Float,
157 onValueChange: (Float) -> Unit,
158 modifier: Modifier = Modifier,
159 enabled: Boolean = true,
160 valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
161 @IntRange(from = 0) steps: Int = 0,
162 onValueChangeFinished: (() -> Unit)? = null,
163 interactionSource: MutableInteractionSource? = null,
164 colors: SliderColors = SliderDefaults.colors()
165 ) {
166 @Suppress("NAME_SHADOWING")
167 val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
168 require(steps >= 0) { "steps should be >= 0" }
169 val onValueChangeState = rememberUpdatedState(onValueChange)
170 val onValueChangeFinishedState = rememberUpdatedState(onValueChangeFinished)
171 val tickFractions = remember(steps) { stepsToTickFractions(steps) }
172
173 BoxWithConstraints(
174 modifier
175 .minimumInteractiveComponentSize()
176 .requiredSizeIn(minWidth = ThumbRadius * 2, minHeight = ThumbRadius * 2)
177 .sliderSemantics(
178 value,
179 enabled,
180 onValueChange,
181 onValueChangeFinished,
182 valueRange,
183 steps
184 )
185 .focusable(enabled, interactionSource)
186 .slideOnKeyEvents(
187 enabled,
188 steps,
189 valueRange,
190 value,
191 LocalLayoutDirection.current == LayoutDirection.Rtl,
192 onValueChangeState,
193 onValueChangeFinishedState
194 )
195 ) {
196 val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
197 val widthPx = constraints.maxWidth.toFloat()
198 val maxPx: Float
199 val minPx: Float
200
201 with(LocalDensity.current) {
202 maxPx = max(widthPx - ThumbRadius.toPx(), 0f)
203 minPx = min(ThumbRadius.toPx(), maxPx)
204 }
205
206 fun scaleToUserValue(offset: Float) =
207 scale(minPx, maxPx, offset, valueRange.start, valueRange.endInclusive)
208
209 fun scaleToOffset(userValue: Float) =
210 scale(valueRange.start, valueRange.endInclusive, userValue, minPx, maxPx)
211
212 val scope = rememberCoroutineScope()
213 val rawOffset = remember { mutableFloatStateOf(scaleToOffset(value)) }
214 val pressOffset = remember { mutableFloatStateOf(0f) }
215
216 val draggableState =
217 remember(minPx, maxPx, valueRange) {
218 SliderDraggableState {
219 rawOffset.floatValue = (rawOffset.floatValue + it + pressOffset.floatValue)
220 pressOffset.floatValue = 0f
221 val offsetInTrack = rawOffset.floatValue.coerceIn(minPx, maxPx)
222 onValueChangeState.value.invoke(scaleToUserValue(offsetInTrack))
223 }
224 }
225
226 CorrectValueSideEffect(::scaleToOffset, valueRange, minPx..maxPx, rawOffset, value)
227
228 val gestureEndAction =
229 rememberUpdatedState<(Float) -> Unit> { velocity: Float ->
230 val current = rawOffset.floatValue
231 val target = snapValueToTick(current, tickFractions, minPx, maxPx)
232 if (current != target) {
233 scope.launch {
234 animateToTarget(draggableState, current, target, velocity)
235 onValueChangeFinished?.invoke()
236 }
237 } else if (!draggableState.isDragging) {
238 // check ifDragging in case the change is still in progress (touch -> drag case)
239 onValueChangeFinished?.invoke()
240 }
241 }
242 val press =
243 Modifier.sliderTapModifier(
244 draggableState,
245 interactionSource,
246 widthPx,
247 isRtl,
248 rawOffset,
249 gestureEndAction,
250 pressOffset,
251 enabled
252 )
253
254 val drag =
255 Modifier.draggable(
256 orientation = Orientation.Horizontal,
257 reverseDirection = isRtl,
258 enabled = enabled,
259 interactionSource = interactionSource,
260 onDragStopped = { velocity -> gestureEndAction.value.invoke(velocity) },
261 startDragImmediately = draggableState.isDragging,
262 state = draggableState
263 )
264
265 val coerced = value.coerceIn(valueRange.start, valueRange.endInclusive)
266 val fraction = calcFraction(valueRange.start, valueRange.endInclusive, coerced)
267 SliderImpl(
268 enabled,
269 fraction,
270 tickFractions,
271 colors,
272 maxPx - minPx,
273 interactionSource,
274 modifier = press.then(drag)
275 )
276 }
277 }
278
Modifiernull279 private fun Modifier.slideOnKeyEvents(
280 enabled: Boolean,
281 steps: Int,
282 valueRange: ClosedFloatingPointRange<Float>,
283 value: Float,
284 isRtl: Boolean,
285 onValueChangeState: State<(Float) -> Unit>,
286 onValueChangeFinishedState: State<(() -> Unit)?>
287 ): Modifier {
288 require(steps >= 0) { "steps should be >= 0" }
289 return this.onKeyEvent {
290 if (!enabled) return@onKeyEvent false
291 when (it.type) {
292 KeyEventType.KeyDown -> {
293 val rangeLength = abs(valueRange.endInclusive - valueRange.start)
294 // When steps == 0, it means that a user is not limited by a step length (delta)
295 // when using touch or mouse. But it is not possible to adjust the value
296 // continuously when using keyboard buttons - the delta has to be discrete.
297 // In this case, 1% of the valueRange seems to make sense.
298 val actualSteps = if (steps > 0) steps + 1 else 100
299 val delta = rangeLength / actualSteps
300 when (it.key) {
301 Key.DirectionUp -> {
302 onValueChangeState.value((value + delta).coerceIn(valueRange))
303 true
304 }
305 Key.DirectionDown -> {
306 onValueChangeState.value((value - delta).coerceIn(valueRange))
307 true
308 }
309 Key.DirectionRight -> {
310 val sign = if (isRtl) -1 else 1
311 onValueChangeState.value((value + sign * delta).coerceIn(valueRange))
312 true
313 }
314 Key.DirectionLeft -> {
315 val sign = if (isRtl) -1 else 1
316 onValueChangeState.value((value - sign * delta).coerceIn(valueRange))
317 true
318 }
319 Key.MoveHome -> {
320 onValueChangeState.value(valueRange.start)
321 true
322 }
323 Key.MoveEnd -> {
324 onValueChangeState.value(valueRange.endInclusive)
325 true
326 }
327 Key.PageUp -> {
328 val page = (actualSteps / 10).coerceIn(1, 10)
329 onValueChangeState.value((value - page * delta).coerceIn(valueRange))
330 true
331 }
332 Key.PageDown -> {
333 val page = (actualSteps / 10).coerceIn(1, 10)
334 onValueChangeState.value((value + page * delta).coerceIn(valueRange))
335 true
336 }
337 else -> false
338 }
339 }
340 KeyEventType.KeyUp -> {
341 when (it.key) {
342 Key.DirectionUp,
343 Key.DirectionDown,
344 Key.DirectionRight,
345 Key.DirectionLeft,
346 Key.MoveHome,
347 Key.MoveEnd,
348 Key.PageUp,
349 Key.PageDown -> {
350 onValueChangeFinishedState.value?.invoke()
351 true
352 }
353 else -> false
354 }
355 }
356 else -> false
357 }
358 }
359 }
360
361 /**
362 * [Material Design slider](https://material.io/components/sliders)
363 *
364 * Range Sliders expand upon [Slider] using the same concepts but allow the user to select 2 values.
365 *
366 * The two values are still bounded by the value range but they also cannot cross each other.
367 *
368 * Use continuous Range Sliders to allow users to make meaningful selections that don’t require a
369 * specific values:
370 *
371 * @sample androidx.compose.material.samples.RangeSliderSample
372 *
373 * You can allow the user to choose only between predefined set of values by specifying the amount
374 * of steps between min and max values:
375 *
376 * @sample androidx.compose.material.samples.StepRangeSliderSample
377 * @param value current values of the RangeSlider. If either value is outside of [valueRange]
378 * provided, it will be coerced to this range.
379 * @param onValueChange lambda in which values should be updated
380 * @param modifier modifiers for the Range Slider layout
381 * @param enabled whether or not component is enabled and can we interacted with or not
382 * @param valueRange range of values that Range Slider values can take. Passed [value] will be
383 * coerced to this range
384 * @param steps if positive, specifies the amount of discrete allowable values between the endpoints
385 * of [valueRange]. For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly
386 * distributed between 0 and 10 (i.e., 2, 4, 6, 8). If [steps] is 0, the slider will behave
387 * continuously and allow any value from the range. Must not be negative.
388 * @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
389 * shouldn't be used to update the range slider values (use [onValueChange] for that), but rather
390 * to know when the user has completed selecting a new value by ending a drag or a click.
391 * @param colors [SliderColors] that will be used to determine the color of the Range Slider parts
392 * in different state. See [SliderDefaults.colors] to customize.
393 */
394 @Composable
395 @ExperimentalMaterialApi
RangeSlidernull396 fun RangeSlider(
397 value: ClosedFloatingPointRange<Float>,
398 onValueChange: (ClosedFloatingPointRange<Float>) -> Unit,
399 modifier: Modifier = Modifier,
400 enabled: Boolean = true,
401 valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
402 @IntRange(from = 0) steps: Int = 0,
403 onValueChangeFinished: (() -> Unit)? = null,
404 colors: SliderColors = SliderDefaults.colors()
405 ) {
406 val startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }
407 val endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }
408
409 require(steps >= 0) { "steps should be >= 0" }
410 val onValueChangeState = rememberUpdatedState(onValueChange)
411 val tickFractions = remember(steps) { stepsToTickFractions(steps) }
412
413 BoxWithConstraints(
414 modifier =
415 modifier
416 .minimumInteractiveComponentSize()
417 .requiredSizeIn(minWidth = ThumbRadius * 4, minHeight = ThumbRadius * 2)
418 ) {
419 val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
420 val widthPx = constraints.maxWidth.toFloat()
421 val maxPx: Float
422 val minPx: Float
423
424 with(LocalDensity.current) {
425 maxPx = widthPx - ThumbRadius.toPx()
426 minPx = ThumbRadius.toPx()
427 }
428
429 fun scaleToUserValue(offset: ClosedFloatingPointRange<Float>) =
430 scale(minPx, maxPx, offset, valueRange.start, valueRange.endInclusive)
431
432 fun scaleToOffset(userValue: Float) =
433 scale(valueRange.start, valueRange.endInclusive, userValue, minPx, maxPx)
434
435 val rawOffsetStart = remember { mutableFloatStateOf(scaleToOffset(value.start)) }
436 val rawOffsetEnd = remember { mutableFloatStateOf(scaleToOffset(value.endInclusive)) }
437
438 CorrectValueSideEffect(
439 ::scaleToOffset,
440 valueRange,
441 minPx..maxPx,
442 rawOffsetStart,
443 value.start
444 )
445 CorrectValueSideEffect(
446 ::scaleToOffset,
447 valueRange,
448 minPx..maxPx,
449 rawOffsetEnd,
450 value.endInclusive
451 )
452
453 val scope = rememberCoroutineScope()
454 val gestureEndAction =
455 rememberUpdatedState<(Boolean) -> Unit> { isStart ->
456 val current = (if (isStart) rawOffsetStart else rawOffsetEnd).floatValue
457 // target is a closest anchor to the `current`, if exists
458 val target = snapValueToTick(current, tickFractions, minPx, maxPx)
459 if (current == target) {
460 onValueChangeFinished?.invoke()
461 return@rememberUpdatedState
462 }
463
464 scope.launch {
465 Animatable(initialValue = current).animateTo(
466 target,
467 SliderToTickAnimation,
468 0f
469 ) {
470 (if (isStart) rawOffsetStart else rawOffsetEnd).floatValue = this.value
471 onValueChangeState.value.invoke(
472 scaleToUserValue(rawOffsetStart.floatValue..rawOffsetEnd.floatValue)
473 )
474 }
475
476 onValueChangeFinished?.invoke()
477 }
478 }
479
480 val onDrag =
481 rememberUpdatedState<(Boolean, Float) -> Unit> { isStart, offset ->
482 val offsetRange =
483 if (isStart) {
484 rawOffsetStart.floatValue = (rawOffsetStart.floatValue + offset)
485 rawOffsetEnd.floatValue = scaleToOffset(value.endInclusive)
486 val offsetEnd = rawOffsetEnd.floatValue
487 val offsetStart = rawOffsetStart.floatValue.coerceIn(minPx, offsetEnd)
488 offsetStart..offsetEnd
489 } else {
490 rawOffsetEnd.floatValue = (rawOffsetEnd.floatValue + offset)
491 rawOffsetStart.floatValue = scaleToOffset(value.start)
492 val offsetStart = rawOffsetStart.floatValue
493 val offsetEnd = rawOffsetEnd.floatValue.coerceIn(offsetStart, maxPx)
494 offsetStart..offsetEnd
495 }
496
497 onValueChangeState.value.invoke(scaleToUserValue(offsetRange))
498 }
499
500 val pressDrag =
501 Modifier.rangeSliderPressDragModifier(
502 startInteractionSource,
503 endInteractionSource,
504 rawOffsetStart,
505 rawOffsetEnd,
506 enabled,
507 isRtl,
508 widthPx,
509 valueRange,
510 gestureEndAction,
511 onDrag,
512 )
513
514 // The positions of the thumbs are dependant on each other.
515 val coercedStart = value.start.coerceIn(valueRange.start, value.endInclusive)
516 val coercedEnd = value.endInclusive.coerceIn(value.start, valueRange.endInclusive)
517 val fractionStart = calcFraction(valueRange.start, valueRange.endInclusive, coercedStart)
518 val fractionEnd = calcFraction(valueRange.start, valueRange.endInclusive, coercedEnd)
519 val startSteps = floor(steps * fractionEnd).toInt()
520 val endSteps = floor(steps * (1f - fractionStart)).toInt()
521
522 val startThumbSemantics =
523 Modifier.sliderSemantics(
524 coercedStart,
525 enabled,
526 { value -> onValueChangeState.value.invoke(value..coercedEnd) },
527 onValueChangeFinished,
528 valueRange.start..coercedEnd,
529 startSteps
530 )
531 val endThumbSemantics =
532 Modifier.sliderSemantics(
533 coercedEnd,
534 enabled,
535 { value -> onValueChangeState.value.invoke(coercedStart..value) },
536 onValueChangeFinished,
537 coercedStart..valueRange.endInclusive,
538 endSteps
539 )
540
541 RangeSliderImpl(
542 enabled,
543 fractionStart,
544 fractionEnd,
545 tickFractions,
546 colors,
547 maxPx - minPx,
548 startInteractionSource,
549 endInteractionSource,
550 modifier = pressDrag,
551 startThumbSemantics,
552 endThumbSemantics
553 )
554 }
555 }
556
557 /** Object to hold defaults used by [Slider] */
558 object SliderDefaults {
559
560 /**
561 * Creates a [SliderColors] that represents the different colors used in parts of the [Slider]
562 * in different states.
563 *
564 * For the name references below the words "active" and "inactive" are used. Active part of the
565 * slider is filled with progress, so if slider's progress is 30% out of 100%, left (or right in
566 * RTL) 30% of the track will be active, the rest is not active.
567 *
568 * @param thumbColor thumb color when enabled
569 * @param disabledThumbColor thumb colors when disabled
570 * @param activeTrackColor color of the track in the part that is "active", meaning that the
571 * thumb is ahead of it
572 * @param inactiveTrackColor color of the track in the part that is "inactive", meaning that the
573 * thumb is before it
574 * @param disabledActiveTrackColor color of the track in the "active" part when the Slider is
575 * disabled
576 * @param disabledInactiveTrackColor color of the track in the "inactive" part when the Slider
577 * is disabled
578 * @param activeTickColor colors to be used to draw tick marks on the active track, if `steps`
579 * is specified
580 * @param inactiveTickColor colors to be used to draw tick marks on the inactive track, if
581 * `steps` are specified on the Slider is specified
582 * @param disabledActiveTickColor colors to be used to draw tick marks on the active track when
583 * Slider is disabled and when `steps` are specified on it
584 * @param disabledInactiveTickColor colors to be used to draw tick marks on the inactive part of
585 * the track when Slider is disabled and when `steps` are specified on it
586 */
587 @Composable
colorsnull588 fun colors(
589 thumbColor: Color = MaterialTheme.colors.primary,
590 disabledThumbColor: Color =
591 MaterialTheme.colors.onSurface
592 .copy(alpha = ContentAlpha.disabled)
593 .compositeOver(MaterialTheme.colors.surface),
594 activeTrackColor: Color = MaterialTheme.colors.primary,
595 inactiveTrackColor: Color = activeTrackColor.copy(alpha = InactiveTrackAlpha),
596 disabledActiveTrackColor: Color =
597 MaterialTheme.colors.onSurface.copy(alpha = DisabledActiveTrackAlpha),
598 disabledInactiveTrackColor: Color =
599 disabledActiveTrackColor.copy(alpha = DisabledInactiveTrackAlpha),
600 activeTickColor: Color = contentColorFor(activeTrackColor).copy(alpha = TickAlpha),
601 inactiveTickColor: Color = activeTrackColor.copy(alpha = TickAlpha),
602 disabledActiveTickColor: Color = activeTickColor.copy(alpha = DisabledTickAlpha),
603 disabledInactiveTickColor: Color =
604 disabledInactiveTrackColor.copy(alpha = DisabledTickAlpha)
605 ): SliderColors =
606 DefaultSliderColors(
607 thumbColor = thumbColor,
608 disabledThumbColor = disabledThumbColor,
609 activeTrackColor = activeTrackColor,
610 inactiveTrackColor = inactiveTrackColor,
611 disabledActiveTrackColor = disabledActiveTrackColor,
612 disabledInactiveTrackColor = disabledInactiveTrackColor,
613 activeTickColor = activeTickColor,
614 inactiveTickColor = inactiveTickColor,
615 disabledActiveTickColor = disabledActiveTickColor,
616 disabledInactiveTickColor = disabledInactiveTickColor
617 )
618
619 /** Default alpha of the inactive part of the track */
620 const val InactiveTrackAlpha = 0.24f
621
622 /** Default alpha for the track when it is disabled but active */
623 const val DisabledInactiveTrackAlpha = 0.12f
624
625 /** Default alpha for the track when it is disabled and inactive */
626 const val DisabledActiveTrackAlpha = 0.32f
627
628 /** Default alpha of the ticks that are drawn on top of the track */
629 const val TickAlpha = 0.54f
630
631 /** Default alpha for tick marks when they are disabled */
632 const val DisabledTickAlpha = 0.12f
633 }
634
635 /**
636 * Represents the colors used by a [Slider] and its parts in different states
637 *
638 * See [SliderDefaults.colors] for the default implementation that follows Material specifications.
639 */
640 @Stable
641 interface SliderColors {
642
643 /**
644 * Represents the color used for the sliders's thumb, depending on [enabled].
645 *
646 * @param enabled whether the [Slider] is enabled or not
647 */
648 @Composable fun thumbColor(enabled: Boolean): State<Color>
649
650 /**
651 * Represents the color used for the sliders's track, depending on [enabled] and [active].
652 *
653 * Active part is filled with progress, so if sliders progress is 30% out of 100%, left (or
654 * right in RTL) 30% of the track will be active, the rest is not active.
655 *
656 * @param enabled whether the [Slider] is enabled or not
657 * @param active whether the part of the track is active of not
658 */
659 @Composable fun trackColor(enabled: Boolean, active: Boolean): State<Color>
660
661 /**
662 * Represents the color used for the sliders's tick which is the dot separating steps, if they
663 * are set on the slider, depending on [enabled] and [active].
664 *
665 * Active tick is the tick that is in the part of the track filled with progress, so if sliders
666 * progress is 30% out of 100%, left (or right in RTL) 30% of the track and the ticks in this
667 * 30% will be active, the rest is not active.
668 *
669 * @param enabled whether the [Slider] is enabled or not
670 * @param active whether the part of the track this tick is in is active of not
671 */
672 @Composable fun tickColor(enabled: Boolean, active: Boolean): State<Color>
673 }
674
675 @Composable
SliderImplnull676 private fun SliderImpl(
677 enabled: Boolean,
678 positionFraction: Float,
679 tickFractions: List<Float>,
680 colors: SliderColors,
681 width: Float,
682 interactionSource: MutableInteractionSource,
683 modifier: Modifier
684 ) {
685 Box(modifier.then(DefaultSliderConstraints)) {
686 val trackStrokeWidth: Float
687 val thumbPx: Float
688 val widthDp: Dp
689 with(LocalDensity.current) {
690 trackStrokeWidth = TrackHeight.toPx()
691 thumbPx = ThumbRadius.toPx()
692 widthDp = width.toDp()
693 }
694
695 val thumbSize = ThumbRadius * 2
696 val offset = widthDp * positionFraction
697
698 Track(
699 Modifier.fillMaxSize(),
700 colors,
701 enabled,
702 0f,
703 positionFraction,
704 tickFractions,
705 thumbPx,
706 trackStrokeWidth
707 )
708 SliderThumb(Modifier, offset, interactionSource, colors, enabled, thumbSize)
709 }
710 }
711
712 @Composable
RangeSliderImplnull713 private fun RangeSliderImpl(
714 enabled: Boolean,
715 positionFractionStart: Float,
716 positionFractionEnd: Float,
717 tickFractions: List<Float>,
718 colors: SliderColors,
719 width: Float,
720 startInteractionSource: MutableInteractionSource,
721 endInteractionSource: MutableInteractionSource,
722 modifier: Modifier,
723 startThumbSemantics: Modifier,
724 endThumbSemantics: Modifier
725 ) {
726
727 val startContentDescription = getString(Strings.SliderRangeStart)
728 val endContentDescription = getString(Strings.SliderRangeEnd)
729 Box(modifier.then(DefaultSliderConstraints)) {
730 val trackStrokeWidth: Float
731 val thumbPx: Float
732 val widthDp: Dp
733 with(LocalDensity.current) {
734 trackStrokeWidth = TrackHeight.toPx()
735 thumbPx = ThumbRadius.toPx()
736 widthDp = width.toDp()
737 }
738
739 val thumbSize = ThumbRadius * 2
740 val offsetStart = widthDp * positionFractionStart
741 val offsetEnd = widthDp * positionFractionEnd
742 Track(
743 Modifier.align(Alignment.CenterStart).fillMaxSize(),
744 colors,
745 enabled,
746 positionFractionStart,
747 positionFractionEnd,
748 tickFractions,
749 thumbPx,
750 trackStrokeWidth
751 )
752
753 SliderThumb(
754 Modifier.semantics(mergeDescendants = true) {
755 contentDescription = startContentDescription
756 }
757 .focusable(true, startInteractionSource)
758 .then(startThumbSemantics),
759 offsetStart,
760 startInteractionSource,
761 colors,
762 enabled,
763 thumbSize
764 )
765 SliderThumb(
766 Modifier.semantics(mergeDescendants = true) {
767 contentDescription = endContentDescription
768 }
769 .focusable(true, endInteractionSource)
770 .then(endThumbSemantics),
771 offsetEnd,
772 endInteractionSource,
773 colors,
774 enabled,
775 thumbSize
776 )
777 }
778 }
779
780 @Composable
SliderThumbnull781 private fun BoxScope.SliderThumb(
782 modifier: Modifier,
783 offset: Dp,
784 interactionSource: MutableInteractionSource,
785 colors: SliderColors,
786 enabled: Boolean,
787 thumbSize: Dp
788 ) {
789 Box(Modifier.padding(start = offset).align(Alignment.CenterStart)) {
790 val interactions = remember { mutableStateListOf<Interaction>() }
791 LaunchedEffect(interactionSource) {
792 interactionSource.interactions.collect { interaction ->
793 when (interaction) {
794 is PressInteraction.Press -> interactions.add(interaction)
795 is PressInteraction.Release -> interactions.remove(interaction.press)
796 is PressInteraction.Cancel -> interactions.remove(interaction.press)
797 is DragInteraction.Start -> interactions.add(interaction)
798 is DragInteraction.Stop -> interactions.remove(interaction.start)
799 is DragInteraction.Cancel -> interactions.remove(interaction.start)
800 }
801 }
802 }
803
804 val elevation =
805 if (interactions.isNotEmpty()) {
806 ThumbPressedElevation
807 } else {
808 ThumbDefaultElevation
809 }
810 Spacer(
811 modifier
812 .size(thumbSize, thumbSize)
813 .indication(
814 interactionSource = interactionSource,
815 indication = ripple(bounded = false, radius = ThumbRippleRadius)
816 )
817 .hoverable(interactionSource = interactionSource)
818 .shadow(if (enabled) elevation else 0.dp, CircleShape, clip = false)
819 .background(colors.thumbColor(enabled).value, CircleShape)
820 )
821 }
822 }
823
824 @Composable
Tracknull825 private fun Track(
826 modifier: Modifier,
827 colors: SliderColors,
828 enabled: Boolean,
829 positionFractionStart: Float,
830 positionFractionEnd: Float,
831 tickFractions: List<Float>,
832 thumbPx: Float,
833 trackStrokeWidth: Float
834 ) {
835 val inactiveTrackColor = colors.trackColor(enabled, active = false)
836 val activeTrackColor = colors.trackColor(enabled, active = true)
837 val inactiveTickColor = colors.tickColor(enabled, active = false)
838 val activeTickColor = colors.tickColor(enabled, active = true)
839 Canvas(modifier) {
840 val isRtl = layoutDirection == LayoutDirection.Rtl
841 val sliderLeft = Offset(thumbPx, center.y)
842 val sliderRight = Offset(size.width - thumbPx, center.y)
843 val sliderStart = if (isRtl) sliderRight else sliderLeft
844 val sliderEnd = if (isRtl) sliderLeft else sliderRight
845 drawLine(
846 inactiveTrackColor.value,
847 sliderStart,
848 sliderEnd,
849 trackStrokeWidth,
850 StrokeCap.Round
851 )
852 val sliderValueEnd =
853 Offset(sliderStart.x + (sliderEnd.x - sliderStart.x) * positionFractionEnd, center.y)
854
855 val sliderValueStart =
856 Offset(sliderStart.x + (sliderEnd.x - sliderStart.x) * positionFractionStart, center.y)
857
858 drawLine(
859 activeTrackColor.value,
860 sliderValueStart,
861 sliderValueEnd,
862 trackStrokeWidth,
863 StrokeCap.Round
864 )
865 @Suppress("ListIterator")
866 tickFractions
867 .groupBy { it > positionFractionEnd || it < positionFractionStart }
868 .forEach { (outsideFraction, list) ->
869 drawPoints(
870 list.fastMap { Offset(lerp(sliderStart, sliderEnd, it).x, center.y) },
871 PointMode.Points,
872 (if (outsideFraction) inactiveTickColor else activeTickColor).value,
873 trackStrokeWidth,
874 StrokeCap.Round
875 )
876 }
877 }
878 }
879
snapValueToTicknull880 private fun snapValueToTick(
881 current: Float,
882 tickFractions: List<Float>,
883 minPx: Float,
884 maxPx: Float
885 ): Float {
886 // target is a closest anchor to the `current`, if exists
887 return tickFractions
888 .fastMinByOrNull { abs(lerp(minPx, maxPx, it) - current) }
889 ?.run { lerp(minPx, maxPx, this) } ?: current
890 }
891
awaitSlopnull892 private suspend fun AwaitPointerEventScope.awaitSlop(
893 id: PointerId,
894 type: PointerType
895 ): Pair<PointerInputChange, Float>? {
896 var initialDelta = 0f
897 val postPointerSlop = { pointerInput: PointerInputChange, offset: Float ->
898 pointerInput.consume()
899 initialDelta = offset
900 }
901 val afterSlopResult = awaitHorizontalPointerSlopOrCancellation(id, type, postPointerSlop)
902 return if (afterSlopResult != null) afterSlopResult to initialDelta else null
903 }
904
stepsToTickFractionsnull905 private fun stepsToTickFractions(steps: Int): List<Float> {
906 return if (steps == 0) emptyList() else List(steps + 2) { it.toFloat() / (steps + 1) }
907 }
908
909 // Scale x1 from a1..b1 range to a2..b2 range
scalenull910 private fun scale(a1: Float, b1: Float, x1: Float, a2: Float, b2: Float) =
911 lerp(a2, b2, calcFraction(a1, b1, x1))
912
913 // Scale x.start, x.endInclusive from a1..b1 range to a2..b2 range
914 private fun scale(a1: Float, b1: Float, x: ClosedFloatingPointRange<Float>, a2: Float, b2: Float) =
915 scale(a1, b1, x.start, a2, b2)..scale(a1, b1, x.endInclusive, a2, b2)
916
917 // Calculate the 0..1 fraction that `pos` value represents between `a` and `b`
918 private fun calcFraction(a: Float, b: Float, pos: Float) =
919 (if (b - a == 0f) 0f else (pos - a) / (b - a)).fastCoerceIn(0f, 1f)
920
921 @Composable
922 private fun CorrectValueSideEffect(
923 scaleToOffset: (Float) -> Float,
924 valueRange: ClosedFloatingPointRange<Float>,
925 trackRange: ClosedFloatingPointRange<Float>,
926 valueState: MutableState<Float>,
927 value: Float
928 ) {
929 SideEffect {
930 val error = (valueRange.endInclusive - valueRange.start) / 1000
931 val newOffset = scaleToOffset(value)
932 if (abs(newOffset - valueState.value) > error) {
933 if (valueState.value in trackRange) {
934 valueState.value = newOffset
935 }
936 }
937 }
938 }
939
sliderSemanticsnull940 private fun Modifier.sliderSemantics(
941 value: Float,
942 enabled: Boolean,
943 onValueChange: (Float) -> Unit,
944 onValueChangeFinished: (() -> Unit)? = null,
945 valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
946 steps: Int = 0
947 ): Modifier {
948 val coerced = value.coerceIn(valueRange.start, valueRange.endInclusive)
949 return semantics {
950 if (!enabled) disabled()
951 setProgress(
952 action = { targetValue ->
953 var newValue = targetValue.coerceIn(valueRange.start, valueRange.endInclusive)
954 val originalVal = newValue
955 val resolvedValue =
956 if (steps > 0) {
957 var distance: Float = newValue
958 for (i in 0..steps + 1) {
959 val stepValue =
960 lerp(
961 valueRange.start,
962 valueRange.endInclusive,
963 i.toFloat() / (steps + 1)
964 )
965 if (abs(stepValue - originalVal) <= distance) {
966 distance = abs(stepValue - originalVal)
967 newValue = stepValue
968 }
969 }
970 newValue
971 } else {
972 newValue
973 }
974 // This is to keep it consistent with AbsSeekbar.java: return false if no
975 // change from current.
976 if (resolvedValue == coerced) {
977 false
978 } else {
979 onValueChange(resolvedValue)
980 onValueChangeFinished?.invoke()
981 true
982 }
983 }
984 )
985 }
986 .progressSemantics(value, valueRange, steps)
987 }
988
sliderTapModifiernull989 private fun Modifier.sliderTapModifier(
990 draggableState: DraggableState,
991 interactionSource: MutableInteractionSource,
992 maxPx: Float,
993 isRtl: Boolean,
994 rawOffset: State<Float>,
995 gestureEndAction: State<(Float) -> Unit>,
996 pressOffset: MutableState<Float>,
997 enabled: Boolean
998 ) =
999 composed(
1000 factory = {
1001 if (enabled) {
1002 val scope = rememberCoroutineScope()
1003 pointerInput(draggableState, interactionSource, maxPx, isRtl) {
1004 detectTapGestures(
1005 onPress = { pos ->
1006 val to = if (isRtl) maxPx - pos.x else pos.x
1007 pressOffset.value = to - rawOffset.value
1008 try {
1009 awaitRelease()
1010 } catch (_: GestureCancellationException) {
1011 pressOffset.value = 0f
1012 }
1013 },
1014 onTap = {
1015 scope.launch {
1016 draggableState.drag(MutatePriority.UserInput) {
1017 // just trigger animation, press offset will be applied
1018 dragBy(0f)
1019 }
1020 gestureEndAction.value.invoke(0f)
1021 }
1022 }
1023 )
1024 }
1025 } else {
1026 this
1027 }
1028 },
1029 inspectorInfo =
<lambda>null1030 debugInspectorInfo {
1031 name = "sliderTapModifier"
1032 properties["draggableState"] = draggableState
1033 properties["interactionSource"] = interactionSource
1034 properties["maxPx"] = maxPx
1035 properties["isRtl"] = isRtl
1036 properties["rawOffset"] = rawOffset
1037 properties["gestureEndAction"] = gestureEndAction
1038 properties["pressOffset"] = pressOffset
1039 properties["enabled"] = enabled
1040 }
1041 )
1042
animateToTargetnull1043 private suspend fun animateToTarget(
1044 draggableState: DraggableState,
1045 current: Float,
1046 target: Float,
1047 velocity: Float
1048 ) {
1049 draggableState.drag {
1050 var latestValue = current
1051 Animatable(initialValue = current).animateTo(target, SliderToTickAnimation, velocity) {
1052 dragBy(this.value - latestValue)
1053 latestValue = this.value
1054 }
1055 }
1056 }
1057
rangeSliderPressDragModifiernull1058 private fun Modifier.rangeSliderPressDragModifier(
1059 startInteractionSource: MutableInteractionSource,
1060 endInteractionSource: MutableInteractionSource,
1061 rawOffsetStart: State<Float>,
1062 rawOffsetEnd: State<Float>,
1063 enabled: Boolean,
1064 isRtl: Boolean,
1065 maxPx: Float,
1066 valueRange: ClosedFloatingPointRange<Float>,
1067 gestureEndAction: State<(Boolean) -> Unit>,
1068 onDrag: State<(Boolean, Float) -> Unit>,
1069 ): Modifier =
1070 if (enabled) {
1071 pointerInput(startInteractionSource, endInteractionSource, maxPx, isRtl, valueRange) {
1072 val rangeSliderLogic =
1073 RangeSliderLogic(
1074 startInteractionSource,
1075 endInteractionSource,
1076 rawOffsetStart,
1077 rawOffsetEnd,
1078 onDrag
1079 )
1080 coroutineScope {
1081 awaitEachGesture {
1082 val event = awaitFirstDown(requireUnconsumed = false)
1083 val interaction = DragInteraction.Start()
1084 var posX = if (isRtl) maxPx - event.position.x else event.position.x
1085 val compare = rangeSliderLogic.compareOffsets(posX)
1086 var draggingStart =
1087 if (compare != 0) {
1088 compare < 0
1089 } else {
1090 rawOffsetStart.value > posX
1091 }
1092
1093 awaitSlop(event.id, event.type)?.let {
1094 val slop = viewConfiguration.pointerSlop(event.type)
1095 val shouldUpdateCapturedThumb =
1096 abs(rawOffsetEnd.value - posX) < slop &&
1097 abs(rawOffsetStart.value - posX) < slop
1098 if (shouldUpdateCapturedThumb) {
1099 val dir = it.second
1100 draggingStart = if (isRtl) dir >= 0f else dir < 0f
1101 posX += it.first.positionChange().x
1102 }
1103 }
1104
1105 rangeSliderLogic.captureThumb(
1106 draggingStart,
1107 posX,
1108 interaction,
1109 this@coroutineScope
1110 )
1111
1112 val finishInteraction =
1113 try {
1114 val success =
1115 horizontalDrag(pointerId = event.id) {
1116 val deltaX = it.positionChange().x
1117 onDrag.value.invoke(
1118 draggingStart,
1119 if (isRtl) -deltaX else deltaX
1120 )
1121 }
1122 if (success) {
1123 DragInteraction.Stop(interaction)
1124 } else {
1125 DragInteraction.Cancel(interaction)
1126 }
1127 } catch (e: CancellationException) {
1128 DragInteraction.Cancel(interaction)
1129 }
1130
1131 gestureEndAction.value.invoke(draggingStart)
1132 launch {
1133 rangeSliderLogic.activeInteraction(draggingStart).emit(finishInteraction)
1134 }
1135 }
1136 }
1137 }
1138 } else {
1139 this
1140 }
1141
1142 private class RangeSliderLogic(
1143 val startInteractionSource: MutableInteractionSource,
1144 val endInteractionSource: MutableInteractionSource,
1145 val rawOffsetStart: State<Float>,
1146 val rawOffsetEnd: State<Float>,
1147 val onDrag: State<(Boolean, Float) -> Unit>,
1148 ) {
activeInteractionnull1149 fun activeInteraction(draggingStart: Boolean): MutableInteractionSource =
1150 if (draggingStart) startInteractionSource else endInteractionSource
1151
1152 fun compareOffsets(eventX: Float): Int {
1153 val diffStart = abs(rawOffsetStart.value - eventX)
1154 val diffEnd = abs(rawOffsetEnd.value - eventX)
1155 return diffStart.compareTo(diffEnd)
1156 }
1157
captureThumbnull1158 fun captureThumb(
1159 draggingStart: Boolean,
1160 posX: Float,
1161 interaction: Interaction,
1162 scope: CoroutineScope
1163 ) {
1164 onDrag.value.invoke(
1165 draggingStart,
1166 posX - if (draggingStart) rawOffsetStart.value else rawOffsetEnd.value
1167 )
1168 scope.launch { activeInteraction(draggingStart).emit(interaction) }
1169 }
1170 }
1171
1172 @Immutable
1173 private class DefaultSliderColors(
1174 private val thumbColor: Color,
1175 private val disabledThumbColor: Color,
1176 private val activeTrackColor: Color,
1177 private val inactiveTrackColor: Color,
1178 private val disabledActiveTrackColor: Color,
1179 private val disabledInactiveTrackColor: Color,
1180 private val activeTickColor: Color,
1181 private val inactiveTickColor: Color,
1182 private val disabledActiveTickColor: Color,
1183 private val disabledInactiveTickColor: Color
1184 ) : SliderColors {
1185
1186 @Composable
thumbColornull1187 override fun thumbColor(enabled: Boolean): State<Color> {
1188 return rememberUpdatedState(if (enabled) thumbColor else disabledThumbColor)
1189 }
1190
1191 @Composable
trackColornull1192 override fun trackColor(enabled: Boolean, active: Boolean): State<Color> {
1193 return rememberUpdatedState(
1194 if (enabled) {
1195 if (active) activeTrackColor else inactiveTrackColor
1196 } else {
1197 if (active) disabledActiveTrackColor else disabledInactiveTrackColor
1198 }
1199 )
1200 }
1201
1202 @Composable
tickColornull1203 override fun tickColor(enabled: Boolean, active: Boolean): State<Color> {
1204 return rememberUpdatedState(
1205 if (enabled) {
1206 if (active) activeTickColor else inactiveTickColor
1207 } else {
1208 if (active) disabledActiveTickColor else disabledInactiveTickColor
1209 }
1210 )
1211 }
1212
equalsnull1213 override fun equals(other: Any?): Boolean {
1214 if (this === other) return true
1215 if (other == null || this::class != other::class) return false
1216
1217 other as DefaultSliderColors
1218
1219 if (thumbColor != other.thumbColor) return false
1220 if (disabledThumbColor != other.disabledThumbColor) return false
1221 if (activeTrackColor != other.activeTrackColor) return false
1222 if (inactiveTrackColor != other.inactiveTrackColor) return false
1223 if (disabledActiveTrackColor != other.disabledActiveTrackColor) return false
1224 if (disabledInactiveTrackColor != other.disabledInactiveTrackColor) return false
1225 if (activeTickColor != other.activeTickColor) return false
1226 if (inactiveTickColor != other.inactiveTickColor) return false
1227 if (disabledActiveTickColor != other.disabledActiveTickColor) return false
1228 if (disabledInactiveTickColor != other.disabledInactiveTickColor) return false
1229
1230 return true
1231 }
1232
hashCodenull1233 override fun hashCode(): Int {
1234 var result = thumbColor.hashCode()
1235 result = 31 * result + disabledThumbColor.hashCode()
1236 result = 31 * result + activeTrackColor.hashCode()
1237 result = 31 * result + inactiveTrackColor.hashCode()
1238 result = 31 * result + disabledActiveTrackColor.hashCode()
1239 result = 31 * result + disabledInactiveTrackColor.hashCode()
1240 result = 31 * result + activeTickColor.hashCode()
1241 result = 31 * result + inactiveTickColor.hashCode()
1242 result = 31 * result + disabledActiveTickColor.hashCode()
1243 result = 31 * result + disabledInactiveTickColor.hashCode()
1244 return result
1245 }
1246 }
1247
1248 // Internal to be referred to in tests
1249 internal val ThumbRadius = 10.dp
1250 private val ThumbRippleRadius = 24.dp
1251 private val ThumbDefaultElevation = 1.dp
1252 private val ThumbPressedElevation = 6.dp
1253
1254 // Internal to be referred to in tests
1255 internal val TrackHeight = 4.dp
1256 private val SliderHeight = 48.dp
1257 private val SliderMinWidth = 144.dp // TODO: clarify min width
1258 private val DefaultSliderConstraints =
1259 Modifier.widthIn(min = SliderMinWidth).heightIn(max = SliderHeight)
1260
1261 private val SliderToTickAnimation = TweenSpec<Float>(durationMillis = 100)
1262
1263 private class SliderDraggableState(val onDelta: (Float) -> Unit) : DraggableState {
1264
1265 var isDragging by mutableStateOf(false)
1266 private set
1267
1268 private val dragScope: DragScope =
1269 object : DragScope {
dragBynull1270 override fun dragBy(pixels: Float): Unit = onDelta(pixels)
1271 }
1272
1273 private val scrollMutex = MutatorMutex()
1274
1275 override suspend fun drag(
1276 dragPriority: MutatePriority,
1277 block: suspend DragScope.() -> Unit
1278 ): Unit = coroutineScope {
1279 isDragging = true
1280 scrollMutex.mutateWith(dragScope, dragPriority, block)
1281 isDragging = false
1282 }
1283
dispatchRawDeltanull1284 override fun dispatchRawDelta(delta: Float) {
1285 return onDelta(delta)
1286 }
1287 }
1288