blob: a4e033157bef5dc1ab0888b26a8ff65e10101e9a [file] [log] [blame]
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.DragScope
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.GestureCancellationException
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.PressGestureScope
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.tokens.SliderTokens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import androidx.compose.ui.util.lerp
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
* <a href="https://m3.material.io/components/sliders/overview" class="external" target="_blank">Material Design slider</a>.
*
* Sliders allow users to make selections from a range of values.
*
* It uses [SliderDefaults.Thumb] and [SliderDefaults.Track] as the thumb and track.
*
* Sliders reflect a range of values along a bar, from which users may select a single value.
* They are ideal for adjusting settings such as volume, brightness, or applying image filters.
*
* ![Sliders image](https://developer.android.com/images/reference/androidx/compose/material3/sliders.png)
*
* Use continuous sliders to allow users to make meaningful selections that don’t
* require a specific value:
*
* @sample androidx.compose.material3.samples.SliderSample
*
* You can allow the user to choose only between predefined set of values by specifying the amount
* of steps between min and max values:
*
* @sample androidx.compose.material3.samples.StepsSliderSample
*
* @param value current value of the slider. If outside of [valueRange] provided, value will be
* coerced to this range.
* @param onValueChange callback in which value should be updated
* @param modifier the [Modifier] to be applied to this slider
* @param enabled controls the enabled state of this slider. When `false`, this component will not
* respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param valueRange range of values that this slider can take. The passed [value] will be coerced
* to this range.
* @param steps if greater than 0, specifies the amount of discrete allowable values, evenly
* distributed across the whole value range. If 0, the slider will behave continuously and allow any
* value from the range specified. Must not be negative.
* @param onValueChangeFinished called when value change has ended. This should not be used to
* update the slider value (use [onValueChange] instead), but rather to know when the user has
* completed selecting a new value by ending a drag or a click.
* @param colors [SliderColors] that will be used to resolve the colors used for this slider in
* different states. See [SliderDefaults.colors].
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
* for this slider. You can create and pass in your own `remember`ed instance to observe
* [Interaction]s and customize the appearance / behavior of this slider in different states.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Slider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
/*@IntRange(from = 0)*/
steps: Int = 0,
onValueChangeFinished: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
Slider(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
onValueChangeFinished = onValueChangeFinished,
colors = colors,
interactionSource = interactionSource,
steps = steps,
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = colors,
enabled = enabled
)
},
track = { sliderState ->
SliderDefaults.Track(
colors = colors,
enabled = enabled,
sliderState = sliderState
)
},
valueRange = valueRange
)
}
/**
* <a href="https://m3.material.io/components/sliders/overview" class="external" target="_blank">Material Design slider</a>.
*
* Sliders allow users to make selections from a range of values.
*
* Sliders reflect a range of values along a bar, from which users may select a single value.
* They are ideal for adjusting settings such as volume, brightness, or applying image filters.
*
* ![Sliders image](https://developer.android.com/images/reference/androidx/compose/material3/sliders.png)
*
* Use continuous sliders to allow users to make meaningful selections that don’t
* require a specific value:
*
* @sample androidx.compose.material3.samples.SliderSample
*
* You can allow the user to choose only between predefined set of values by specifying the amount
* of steps between min and max values:
*
* @sample androidx.compose.material3.samples.StepsSliderSample
*
* Slider using a custom thumb:
*
* @sample androidx.compose.material3.samples.SliderWithCustomThumbSample
*
* Slider using custom track and thumb:
*
* @sample androidx.compose.material3.samples.SliderWithCustomTrackAndThumb
*
* @param value current value of the slider. If outside of [valueRange] provided, value will be
* coerced to this range.
* @param onValueChange callback in which value should be updated
* @param modifier the [Modifier] to be applied to this slider
* @param enabled controls the enabled state of this slider. When `false`, this component will not
* respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param onValueChangeFinished called when value change has ended. This should not be used to
* update the slider value (use [onValueChange] instead), but rather to know when the user has
* completed selecting a new value by ending a drag or a click.
* @param colors [SliderColors] that will be used to resolve the colors used for this slider in
* different states. See [SliderDefaults.colors].
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
* for this slider. You can create and pass in your own `remember`ed instance to observe
* [Interaction]s and customize the appearance / behavior of this slider in different states.
* @param steps if greater than 0, specifies the amount of discrete allowable values, evenly
* distributed across the whole value range. If 0, the slider will behave continuously and allow any
* value from the range specified. Must not be negative.
* @param thumb the thumb to be displayed on the slider, it is placed on top of the track. The
* lambda receives a [SliderState] which is used to obtain the current active track.
* @param track the track to be displayed on the slider, it is placed underneath the thumb. The
* lambda receives a [SliderState] which is used to obtain the current active track.
* @param valueRange range of values that this slider can take. The passed [value] will be coerced
* to this range.
*/
@Composable
@ExperimentalMaterial3Api
fun Slider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onValueChangeFinished: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
/*@IntRange(from = 0)*/
steps: Int = 0,
thumb: @Composable (SliderState) -> Unit = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = colors,
enabled = enabled
)
},
track: @Composable (SliderState) -> Unit = { sliderState ->
SliderDefaults.Track(
colors = colors,
enabled = enabled,
sliderState = sliderState
)
},
valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) {
val state = remember(
steps,
valueRange
) {
SliderState(
value,
onValueChange,
steps,
valueRange,
onValueChangeFinished
)
}
state.value = value
state.onValueChange = onValueChange
state.onValueChangeFinished = onValueChangeFinished
Slider(
state = state,
modifier = modifier,
enabled = enabled,
interactionSource = interactionSource,
thumb = thumb,
track = track
)
}
/**
* <a href="https://m3.material.io/components/sliders/overview" class="external" target="_blank">Material Design slider</a>.
*
* Sliders allow users to make selections from a range of values.
*
* Sliders reflect a range of values along a bar, from which users may select a single value.
* They are ideal for adjusting settings such as volume, brightness, or applying image filters.
*
* ![Sliders image](https://developer.android.com/images/reference/androidx/compose/material3/sliders.png)
*
* Use continuous sliders to allow users to make meaningful selections that don’t
* require a specific value:
*
* @sample androidx.compose.material3.samples.SliderSample
*
* You can allow the user to choose only between predefined set of values by specifying the amount
* of steps between min and max values:
*
* @sample androidx.compose.material3.samples.StepsSliderSample
*
* Slider using a custom thumb:
*
* @sample androidx.compose.material3.samples.SliderWithCustomThumbSample
*
* Slider using custom track and thumb:
*
* @sample androidx.compose.material3.samples.SliderWithCustomTrackAndThumb
*
* @param state [SliderState] which contains the slider's current value.
* @param modifier the [Modifier] to be applied to this slider
* @param enabled controls the enabled state of this slider. When `false`, this component will not
* respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param colors [SliderColors] that will be used to resolve the colors used for this slider in
* different states. See [SliderDefaults.colors].
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
* for this slider. You can create and pass in your own `remember`ed instance to observe
* [Interaction]s and customize the appearance / behavior of this slider in different states.
* @param thumb the thumb to be displayed on the slider, it is placed on top of the track. The
* lambda receives a [SliderState] which is used to obtain the current active track.
* @param track the track to be displayed on the slider, it is placed underneath the thumb. The
* lambda receives a [SliderState] which is used to obtain the current active track.
*/
@Composable
@ExperimentalMaterial3Api
fun Slider(
state: SliderState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: SliderColors = SliderDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
thumb: @Composable (SliderState) -> Unit = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = colors,
enabled = enabled
)
},
track: @Composable (SliderState) -> Unit = { sliderState ->
SliderDefaults.Track(
colors = colors,
enabled = enabled,
sliderState = sliderState
)
}
) {
require(state.steps >= 0) { "steps should be >= 0" }
SliderImpl(
state = state,
modifier = modifier,
enabled = enabled,
interactionSource = interactionSource,
thumb = thumb,
track = track
)
}
/**
* <a href="https://m3.material.io/components/sliders/overview" class="external" target="_blank">Material Design Range slider</a>.
*
* Range Sliders expand upon [Slider] using the same concepts but allow the user to select 2 values.
*
* The two values are still bounded by the value range but they also cannot cross each other.
*
* Use continuous Range Sliders to allow users to make meaningful selections that don’t
* require a specific values:
*
* @sample androidx.compose.material3.samples.RangeSliderSample
*
* You can allow the user to choose only between predefined set of values by specifying the amount
* of steps between min and max values:
*
* @sample androidx.compose.material3.samples.StepRangeSliderSample
*
* @param value current values of the RangeSlider. If either value is outside of [valueRange]
* provided, it will be coerced to this range.
* @param onValueChange lambda in which values should be updated
* @param modifier modifiers for the Range Slider layout
* @param enabled whether or not component is enabled and can we interacted with or not
* @param valueRange range of values that Range Slider values can take. Passed [value] will be
* coerced to this range
* @param steps if greater than 0, specifies the amounts of discrete values, evenly distributed
* between across the whole value range. If 0, range slider will behave as a continuous slider and
* allow to choose any value from the range specified. Must not be negative.
* @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
* shouldn't be used to update the range slider values (use [onValueChange] for that), but rather to
* know when the user has completed selecting a new value by ending a drag or a click.
* @param colors [SliderColors] that will be used to determine the color of the Range Slider
* parts in different state. See [SliderDefaults.colors] to customize.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RangeSlider(
value: ClosedFloatingPointRange<Float>,
onValueChange: (ClosedFloatingPointRange<Float>) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
/*@IntRange(from = 0)*/
steps: Int = 0,
onValueChangeFinished: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors()
) {
val startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }
val endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }
RangeSlider(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
valueRange = valueRange,
steps = steps,
onValueChangeFinished = onValueChangeFinished,
startInteractionSource = startInteractionSource,
endInteractionSource = endInteractionSource,
startThumb = {
SliderDefaults.Thumb(
interactionSource = startInteractionSource,
colors = colors,
enabled = enabled
)
},
endThumb = {
SliderDefaults.Thumb(
interactionSource = endInteractionSource,
colors = colors,
enabled = enabled
)
},
track = { rangeSliderState ->
SliderDefaults.Track(
colors = colors,
enabled = enabled,
rangeSliderState = rangeSliderState
)
}
)
}
/**
* <a href="https://m3.material.io/components/sliders/overview" class="external" target="_blank">Material Design Range slider</a>.
*
* Range Sliders expand upon [Slider] using the same concepts but allow the user to select 2 values.
*
* The two values are still bounded by the value range but they also cannot cross each other.
*
* It uses the provided startThumb for the slider's start thumb and endThumb for the
* slider's end thumb. It also uses the provided track for the slider's track. If nothing is
* passed for these parameters, it will use [SliderDefaults.Thumb] and [SliderDefaults.Track]
* for the thumbs and track.
*
* Use continuous Range Sliders to allow users to make meaningful selections that don’t
* require a specific values:
*
* @sample androidx.compose.material3.samples.RangeSliderSample
*
* You can allow the user to choose only between predefined set of values by specifying the amount
* of steps between min and max values:
*
* @sample androidx.compose.material3.samples.StepRangeSliderSample
*
* A custom start/end thumb and track can be provided:
*
* @sample androidx.compose.material3.samples.RangeSliderWithCustomComponents
*
* @param value current values of the RangeSlider. If either value is outside of [valueRange]
* provided, it will be coerced to this range.
* @param onValueChange lambda in which values should be updated
* @param modifier modifiers for the Range Slider layout
* @param enabled whether or not component is enabled and can we interacted with or not
* @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
* shouldn't be used to update the range slider values (use [onValueChange] for that), but rather to
* know when the user has completed selecting a new value by ending a drag or a click.
* @param colors [SliderColors] that will be used to determine the color of the Range Slider
* parts in different state. See [SliderDefaults.colors] to customize.
* @param startInteractionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for the start thumb. You can create and pass in your own
* `remember`ed instance to observe.
* @param endInteractionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for the end thumb. You can create and pass in your own
* `remember`ed instance to observe.
* @param steps if greater than 0, specifies the amounts of discrete values, evenly distributed
* between across the whole value range. If 0, range slider will behave as a continuous slider and
* allow to choose any value from the range specified. Must not be negative.
* @param startThumb the start thumb to be displayed on the Range Slider. The lambda receives a
* [RangeSliderState] which is used to obtain the current active track.
* @param endThumb the end thumb to be displayed on the Range Slider. The lambda receives a
* [RangeSliderState] which is used to obtain the current active track.
* @param track the track to be displayed on the range slider, it is placed underneath the thumb.
* The lambda receives a [RangeSliderState] which is used to obtain the current active track.
* @param valueRange range of values that Range Slider values can take. Passed [value] will be
* coerced to this range.
*/
@Composable
@ExperimentalMaterial3Api
fun RangeSlider(
value: ClosedFloatingPointRange<Float>,
onValueChange: (ClosedFloatingPointRange<Float>) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
onValueChangeFinished: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
startThumb: @Composable (RangeSliderState) -> Unit = {
SliderDefaults.Thumb(
interactionSource = startInteractionSource,
colors = colors,
enabled = enabled
)
},
endThumb: @Composable (RangeSliderState) -> Unit = {
SliderDefaults.Thumb(
interactionSource = endInteractionSource,
colors = colors,
enabled = enabled
)
},
track: @Composable (RangeSliderState) -> Unit = { rangeSliderState ->
SliderDefaults.Track(
colors = colors,
enabled = enabled,
rangeSliderState = rangeSliderState
)
},
/*@IntRange(from = 0)*/
steps: Int = 0
) {
val state = remember(
steps,
valueRange
) {
RangeSliderState(
value.start,
value.endInclusive,
onValueChange,
steps,
valueRange,
onValueChangeFinished,
)
}
state.activeRangeStart = value.start
state.activeRangeEnd = value.endInclusive
state.onValueChange = onValueChange
state.onValueChangeFinished = onValueChangeFinished
RangeSlider(
modifier = modifier,
state = state,
enabled = enabled,
startInteractionSource = startInteractionSource,
endInteractionSource = endInteractionSource,
startThumb = startThumb,
endThumb = endThumb,
track = track
)
}
/**
* <a href="https://m3.material.io/components/sliders/overview" class="external" target="_blank">Material Design Range slider</a>.
*
* Range Sliders expand upon [Slider] using the same concepts but allow the user to select 2 values.
*
* The two values are still bounded by the value range but they also cannot cross each other.
*
* It uses the provided startThumb for the slider's start thumb and endThumb for the
* slider's end thumb. It also uses the provided track for the slider's track. If nothing is
* passed for these parameters, it will use [SliderDefaults.Thumb] and [SliderDefaults.Track]
* for the thumbs and track.
*
* Use continuous Range Sliders to allow users to make meaningful selections that don’t
* require a specific values:
*
* @sample androidx.compose.material3.samples.RangeSliderSample
*
* You can allow the user to choose only between predefined set of values by specifying the amount
* of steps between min and max values:
*
* @sample androidx.compose.material3.samples.StepRangeSliderSample
*
* A custom start/end thumb and track can be provided:
*
* @sample androidx.compose.material3.samples.RangeSliderWithCustomComponents
*
* @param state [RangeSliderState] which contains the current values of the RangeSlider.
* @param modifier modifiers for the Range Slider layout
* @param enabled whether or not component is enabled and can we interacted with or not
* @param colors [SliderColors] that will be used to determine the color of the Range Slider
* parts in different state. See [SliderDefaults.colors] to customize.
* @param startInteractionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for the start thumb. You can create and pass in your own
* `remember`ed instance to observe.
* @param endInteractionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for the end thumb. You can create and pass in your own
* `remember`ed instance to observe.
* @param startThumb the start thumb to be displayed on the Range Slider. The lambda receives a
* [RangeSliderState] which is used to obtain the current active track.
* @param endThumb the end thumb to be displayed on the Range Slider. The lambda receives a
* [RangeSliderState] which is used to obtain the current active track.
* @param track the track to be displayed on the range slider, it is placed underneath the thumb.
* The lambda receives a [RangeSliderState] which is used to obtain the current active track.
*/
@Composable
@ExperimentalMaterial3Api
fun RangeSlider(
state: RangeSliderState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: SliderColors = SliderDefaults.colors(),
startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
startThumb: @Composable (RangeSliderState) -> Unit = {
state.activeRangeStart
SliderDefaults.Thumb(
interactionSource = startInteractionSource,
colors = colors,
enabled = enabled
)
},
endThumb: @Composable (RangeSliderState) -> Unit = {
SliderDefaults.Thumb(
interactionSource = endInteractionSource,
colors = colors,
enabled = enabled
)
},
track: @Composable (RangeSliderState) -> Unit = { rangeSliderState ->
SliderDefaults.Track(
colors = colors,
enabled = enabled,
rangeSliderState = rangeSliderState
)
}
) {
require(state.steps >= 0) { "steps should be >= 0" }
RangeSliderImpl(
modifier = modifier,
state = state,
enabled = enabled,
startInteractionSource = startInteractionSource,
endInteractionSource = endInteractionSource,
startThumb = startThumb,
endThumb = endThumb,
track = track
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SliderImpl(
modifier: Modifier,
state: SliderState,
enabled: Boolean,
interactionSource: MutableInteractionSource,
thumb: @Composable (SliderState) -> Unit,
track: @Composable (SliderState) -> Unit
) {
state.isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val press = Modifier.sliderTapModifier(
state,
interactionSource,
enabled
)
val drag = Modifier.draggable(
orientation = Orientation.Horizontal,
reverseDirection = state.isRtl,
enabled = enabled,
interactionSource = interactionSource,
onDragStopped = { state.gestureEndAction() },
startDragImmediately = state.draggableState.isDragging,
state = state.draggableState
)
Layout(
{
Box(modifier = Modifier.layoutId(SliderComponents.THUMB)) {
thumb(state)
}
Box(modifier = Modifier.layoutId(SliderComponents.TRACK)) {
track(state)
}
},
modifier = modifier
.minimumInteractiveComponentSize()
.requiredSizeIn(
minWidth = SliderTokens.HandleWidth,
minHeight = SliderTokens.HandleHeight
)
.sliderSemantics(
state,
enabled
)
.focusable(enabled, interactionSource)
.then(press)
.then(drag)
) { measurables, constraints ->
val thumbPlaceable = measurables.first {
it.layoutId == SliderComponents.THUMB
}.measure(constraints)
val trackPlaceable = measurables.first {
it.layoutId == SliderComponents.TRACK
}.measure(
constraints.offset(
horizontal = - thumbPlaceable.width
).copy(minHeight = 0)
)
val sliderWidth = thumbPlaceable.width + trackPlaceable.width
val sliderHeight = max(trackPlaceable.height, thumbPlaceable.height)
state.updateDimensions(
thumbPlaceable.width.toFloat(),
sliderWidth
)
val trackOffsetX = thumbPlaceable.width / 2
val thumbOffsetX = ((trackPlaceable.width) * state.coercedValueAsFraction).roundToInt()
val trackOffsetY = (sliderHeight - trackPlaceable.height) / 2
val thumbOffsetY = (sliderHeight - thumbPlaceable.height) / 2
layout(
sliderWidth,
sliderHeight
) {
trackPlaceable.placeRelative(
trackOffsetX,
trackOffsetY
)
thumbPlaceable.placeRelative(
thumbOffsetX,
thumbOffsetY
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RangeSliderImpl(
modifier: Modifier,
state: RangeSliderState,
enabled: Boolean,
startInteractionSource: MutableInteractionSource,
endInteractionSource: MutableInteractionSource,
startThumb: @Composable ((RangeSliderState) -> Unit),
endThumb: @Composable ((RangeSliderState) -> Unit),
track: @Composable ((RangeSliderState) -> Unit)
) {
state.isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val pressDrag = Modifier.rangeSliderPressDragModifier(
state,
startInteractionSource,
endInteractionSource,
enabled
)
val startThumbSemantics = Modifier.rangeSliderStartThumbSemantics(
state,
enabled
)
val endThumbSemantics = Modifier.rangeSliderEndThumbSemantics(
state,
enabled
)
val startContentDescription = getString(Strings.SliderRangeStart)
val endContentDescription = getString(Strings.SliderRangeEnd)
Layout(
{
Box(modifier = Modifier
.layoutId(RangeSliderComponents.STARTTHUMB)
.semantics(mergeDescendants = true) {
contentDescription = startContentDescription
}
.focusable(enabled, startInteractionSource)
.then(startThumbSemantics)
) { startThumb(state) }
Box(modifier = Modifier
.layoutId(RangeSliderComponents.ENDTHUMB)
.semantics(mergeDescendants = true) {
contentDescription = endContentDescription
}
.focusable(enabled, endInteractionSource)
.then(endThumbSemantics)
) { endThumb(state) }
Box(modifier = Modifier.layoutId(RangeSliderComponents.TRACK)) {
track(state)
}
},
modifier = modifier
.minimumInteractiveComponentSize()
.requiredSizeIn(
minWidth = SliderTokens.HandleWidth,
minHeight = SliderTokens.HandleHeight
)
.then(pressDrag)
) { measurables, constraints ->
val startThumbPlaceable = measurables.first {
it.layoutId == RangeSliderComponents.STARTTHUMB
}.measure(
constraints
)
val endThumbPlaceable = measurables.first {
it.layoutId == RangeSliderComponents.ENDTHUMB
}.measure(
constraints
)
val trackPlaceable = measurables.first {
it.layoutId == RangeSliderComponents.TRACK
}.measure(
constraints.offset(
horizontal = - (startThumbPlaceable.width + endThumbPlaceable.width) / 2
).copy(minHeight = 0)
)
val sliderWidth = trackPlaceable.width +
(startThumbPlaceable.width + endThumbPlaceable.width) / 2
val sliderHeight = maxOf(
trackPlaceable.height,
startThumbPlaceable.height,
endThumbPlaceable.height
)
state.startThumbWidth = startThumbPlaceable.width.toFloat()
state.endThumbWidth = endThumbPlaceable.width.toFloat()
state.totalWidth = sliderWidth
state.updateMinMaxPx()
val trackOffsetX = startThumbPlaceable.width / 2
val startThumbOffsetX = (trackPlaceable.width * state.coercedActiveRangeStartAsFraction)
.roundToInt()
// When start thumb and end thumb have different widths,
// we need to add a correction for the centering of the slider.
val endCorrection = (state.startThumbWidth - state.endThumbWidth) / 2
val endThumbOffsetX =
(trackPlaceable.width * state.coercedActiveRangeEndAsFraction + endCorrection)
.roundToInt()
val trackOffsetY = (sliderHeight - trackPlaceable.height) / 2
val startThumbOffsetY = (sliderHeight - startThumbPlaceable.height) / 2
val endThumbOffsetY = (sliderHeight - endThumbPlaceable.height) / 2
layout(
sliderWidth,
sliderHeight
) {
trackPlaceable.placeRelative(
trackOffsetX,
trackOffsetY
)
startThumbPlaceable.placeRelative(
startThumbOffsetX,
startThumbOffsetY
)
endThumbPlaceable.placeRelative(
endThumbOffsetX,
endThumbOffsetY
)
}
}
}
/**
* Object to hold defaults used by [Slider]
*/
@Stable
object SliderDefaults {
/**
* Creates a [SliderColors] that represents the different colors used in parts of the
* [Slider] in different states.
*
* For the name references below the words "active" and "inactive" are used. Active part of
* the slider is filled with progress, so if slider's progress is 30% out of 100%, left (or
* right in RTL) 30% of the track will be active, while the rest is inactive.
*
* @param thumbColor thumb color when enabled
* @param activeTrackColor color of the track in the part that is "active", meaning that the
* thumb is ahead of it
* @param activeTickColor colors to be used to draw tick marks on the active track, if `steps`
* is specified
* @param inactiveTrackColor color of the track in the part that is "inactive", meaning that the
* thumb is before it
* @param inactiveTickColor colors to be used to draw tick marks on the inactive track, if
* `steps` are specified on the Slider is specified
* @param disabledThumbColor thumb colors when disabled
* @param disabledActiveTrackColor color of the track in the "active" part when the Slider is
* disabled
* @param disabledActiveTickColor colors to be used to draw tick marks on the active track
* when Slider is disabled and when `steps` are specified on it
* @param disabledInactiveTrackColor color of the track in the "inactive" part when the
* Slider is disabled
* @param disabledInactiveTickColor colors to be used to draw tick marks on the inactive part
* of the track when Slider is disabled and when `steps` are specified on it
*/
@Composable
fun colors(
thumbColor: Color = SliderTokens.HandleColor.toColor(),
activeTrackColor: Color = SliderTokens.ActiveTrackColor.toColor(),
activeTickColor: Color = SliderTokens.TickMarksActiveContainerColor
.toColor()
.copy(alpha = SliderTokens.TickMarksActiveContainerOpacity),
inactiveTrackColor: Color = SliderTokens.InactiveTrackColor.toColor(),
inactiveTickColor: Color = SliderTokens.TickMarksInactiveContainerColor.toColor()
.copy(alpha = SliderTokens.TickMarksInactiveContainerOpacity),
disabledThumbColor: Color = SliderTokens.DisabledHandleColor
.toColor()
.copy(alpha = SliderTokens.DisabledHandleOpacity)
.compositeOver(MaterialTheme.colorScheme.surface),
disabledActiveTrackColor: Color =
SliderTokens.DisabledActiveTrackColor
.toColor()
.copy(alpha = SliderTokens.DisabledActiveTrackOpacity),
disabledActiveTickColor: Color = SliderTokens.TickMarksDisabledContainerColor
.toColor()
.copy(alpha = SliderTokens.TickMarksDisabledContainerOpacity),
disabledInactiveTrackColor: Color =
SliderTokens.DisabledInactiveTrackColor
.toColor()
.copy(alpha = SliderTokens.DisabledInactiveTrackOpacity),
disabledInactiveTickColor: Color = SliderTokens.TickMarksDisabledContainerColor.toColor()
.copy(alpha = SliderTokens.TickMarksDisabledContainerOpacity)
): SliderColors = SliderColors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
activeTickColor = activeTickColor,
inactiveTrackColor = inactiveTrackColor,
inactiveTickColor = inactiveTickColor,
disabledThumbColor = disabledThumbColor,
disabledActiveTrackColor = disabledActiveTrackColor,
disabledActiveTickColor = disabledActiveTickColor,
disabledInactiveTrackColor = disabledInactiveTrackColor,
disabledInactiveTickColor = disabledInactiveTickColor
)
/**
* The Default thumb for [Slider] and [RangeSlider]
*
* @param interactionSource the [MutableInteractionSource] representing the stream of
* [Interaction]s for this thumb. You can create and pass in your own `remember`ed
* instance to observe
* @param modifier the [Modifier] to be applied to the thumb.
* @param colors [SliderColors] that will be used to resolve the colors used for this thumb in
* different states. See [SliderDefaults.colors].
* @param enabled controls the enabled state of this slider. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
*/
@Composable
fun Thumb(
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
colors: SliderColors = colors(),
enabled: Boolean = true,
thumbSize: DpSize = ThumbSize
) {
val interactions = remember { mutableStateListOf<Interaction>() }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> interactions.add(interaction)
is PressInteraction.Release -> interactions.remove(interaction.press)
is PressInteraction.Cancel -> interactions.remove(interaction.press)
is DragInteraction.Start -> interactions.add(interaction)
is DragInteraction.Stop -> interactions.remove(interaction.start)
is DragInteraction.Cancel -> interactions.remove(interaction.start)
}
}
}
val elevation = if (interactions.isNotEmpty()) {
ThumbPressedElevation
} else {
ThumbDefaultElevation
}
val shape = SliderTokens.HandleShape.toShape()
Spacer(
modifier
.size(thumbSize)
.indication(
interactionSource = interactionSource,
indication = rememberRipple(
bounded = false,
radius = SliderTokens.StateLayerSize / 2
)
)
.hoverable(interactionSource = interactionSource)
.shadow(if (enabled) elevation else 0.dp, shape, clip = false)
.background(colors.thumbColor(enabled).value, shape)
)
}
/**
* The Default track for [Slider] and [RangeSlider]
*
* @param sliderPositions [SliderPositions] which is used to obtain the current active track
* and the tick positions if the slider is discrete.
* @param modifier the [Modifier] to be applied to the track.
* @param colors [SliderColors] that will be used to resolve the colors used for this track in
* different states. See [SliderDefaults.colors].
* @param enabled controls the enabled state of this slider. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
*/
@Composable
fun Track(
sliderPositions: SliderPositions,
modifier: Modifier = Modifier,
colors: SliderColors = colors(),
enabled: Boolean = true,
) {
val inactiveTrackColor = colors.trackColor(enabled, active = false)
val activeTrackColor = colors.trackColor(enabled, active = true)
val inactiveTickColor = colors.tickColor(enabled, active = false)
val activeTickColor = colors.tickColor(enabled, active = true)
Canvas(modifier
.fillMaxWidth()
.height(TrackHeight)
) {
val isRtl = layoutDirection == LayoutDirection.Rtl
val sliderLeft = Offset(0f, center.y)
val sliderRight = Offset(size.width, center.y)
val sliderStart = if (isRtl) sliderRight else sliderLeft
val sliderEnd = if (isRtl) sliderLeft else sliderRight
val tickSize = TickSize.toPx()
val trackStrokeWidth = TrackHeight.toPx()
drawLine(
inactiveTrackColor.value,
sliderStart,
sliderEnd,
trackStrokeWidth,
StrokeCap.Round
)
val sliderValueEnd = Offset(
sliderStart.x +
(sliderEnd.x - sliderStart.x) * sliderPositions.activeRange.endInclusive,
center.y
)
val sliderValueStart = Offset(
sliderStart.x +
(sliderEnd.x - sliderStart.x) * sliderPositions.activeRange.start,
center.y
)
drawLine(
activeTrackColor.value,
sliderValueStart,
sliderValueEnd,
trackStrokeWidth,
StrokeCap.Round
)
sliderPositions.tickFractions.groupBy {
it > sliderPositions.activeRange.endInclusive ||
it < sliderPositions.activeRange.start
}.forEach { (outsideFraction, list) ->
drawPoints(
list.map {
Offset(lerp(sliderStart, sliderEnd, it).x, center.y)
},
PointMode.Points,
(if (outsideFraction) inactiveTickColor else activeTickColor).value,
tickSize,
StrokeCap.Round
)
}
}
}
/**
* The Default track for [Slider]
*
* @param sliderState [SliderState] which is used to obtain the current active track.
* @param modifier the [Modifier] to be applied to the track.
* @param colors [SliderColors] that will be used to resolve the colors used for this track in
* different states. See [SliderDefaults.colors].
* @param enabled controls the enabled state of this slider. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
*/
@Composable
@ExperimentalMaterial3Api
fun Track(
sliderState: SliderState,
modifier: Modifier = Modifier,
colors: SliderColors = colors(),
enabled: Boolean = true
) {
val inactiveTrackColor by colors.trackColor(enabled, active = false)
val activeTrackColor by colors.trackColor(enabled, active = true)
val inactiveTickColor by colors.tickColor(enabled, active = false)
val activeTickColor by colors.tickColor(enabled, active = true)
Canvas(
modifier
.fillMaxWidth()
.height(TrackHeight)
) {
drawTrack(
sliderState.tickFractions,
0f,
sliderState.coercedValueAsFraction,
inactiveTrackColor,
activeTrackColor,
inactiveTickColor,
activeTickColor
)
}
}
/**
* The Default track for [RangeSlider]
*
* @param rangeSliderState [RangeSliderState] which is used to obtain the current active track.
* @param modifier the [Modifier] to be applied to the track.
* @param colors [SliderColors] that will be used to resolve the colors used for this track in
* different states. See [SliderDefaults.colors].
* @param enabled controls the enabled state of this slider. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to
* accessibility services.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Track(
rangeSliderState: RangeSliderState,
modifier: Modifier = Modifier,
colors: SliderColors = colors(),
enabled: Boolean = true
) {
val inactiveTrackColor by colors.trackColor(enabled, active = false)
val activeTrackColor by colors.trackColor(enabled, active = true)
val inactiveTickColor by colors.tickColor(enabled, active = false)
val activeTickColor by colors.tickColor(enabled, active = true)
Canvas(
modifier
.fillMaxWidth()
.height(TrackHeight)
) {
drawTrack(
rangeSliderState.tickFractions,
rangeSliderState.coercedActiveRangeStartAsFraction,
rangeSliderState.coercedActiveRangeEndAsFraction,
inactiveTrackColor,
activeTrackColor,
inactiveTickColor,
activeTickColor
)
}
}
private fun DrawScope.drawTrack(
tickFractions: FloatArray,
activeRangeStart: Float,
activeRangeEnd: Float,
inactiveTrackColor: Color,
activeTrackColor: Color,
inactiveTickColor: Color,
activeTickColor: Color
) {
val isRtl = layoutDirection == LayoutDirection.Rtl
val sliderLeft = Offset(0f, center.y)
val sliderRight = Offset(size.width, center.y)
val sliderStart = if (isRtl) sliderRight else sliderLeft
val sliderEnd = if (isRtl) sliderLeft else sliderRight
val tickSize = TickSize.toPx()
val trackStrokeWidth = TrackHeight.toPx()
drawLine(
inactiveTrackColor,
sliderStart,
sliderEnd,
trackStrokeWidth,
StrokeCap.Round
)
val sliderValueEnd = Offset(
sliderStart.x +
(sliderEnd.x - sliderStart.x) * activeRangeEnd,
center.y
)
val sliderValueStart = Offset(
sliderStart.x +
(sliderEnd.x - sliderStart.x) * activeRangeStart,
center.y
)
drawLine(
activeTrackColor,
sliderValueStart,
sliderValueEnd,
trackStrokeWidth,
StrokeCap.Round
)
tickFractions.groupBy {
it > activeRangeEnd ||
it < activeRangeStart
}.forEach { (outsideFraction, list) ->
drawPoints(
list.map {
Offset(lerp(sliderStart, sliderEnd, it).x, center.y)
},
PointMode.Points,
(if (outsideFraction) inactiveTickColor else activeTickColor),
tickSize,
StrokeCap.Round
)
}
}
}
private fun snapValueToTick(
current: Float,
tickFractions: FloatArray,
minPx: Float,
maxPx: Float
): Float {
// target is a closest anchor to the `current`, if exists
return tickFractions
.minByOrNull { abs(lerp(minPx, maxPx, it) - current) }
?.run { lerp(minPx, maxPx, this) }
?: current
}
private suspend fun AwaitPointerEventScope.awaitSlop(
id: PointerId,
type: PointerType
): Pair<PointerInputChange, Float>? {
var initialDelta = 0f
val postPointerSlop = { pointerInput: PointerInputChange, offset: Float ->
pointerInput.consume()
initialDelta = offset
}
val afterSlopResult = awaitHorizontalPointerSlopOrCancellation(id, type, postPointerSlop)
return if (afterSlopResult != null) afterSlopResult to initialDelta else null
}
private fun stepsToTickFractions(steps: Int): FloatArray {
return if (steps == 0) floatArrayOf() else FloatArray(steps + 2) { it.toFloat() / (steps + 1) }
}
// Scale x1 from a1..b1 range to a2..b2 range
private fun scale(a1: Float, b1: Float, x1: Float, a2: Float, b2: Float) =
lerp(a2, b2, calcFraction(a1, b1, x1))
// Scale x.start, x.endInclusive from a1..b1 range to a2..b2 range
private fun scale(a1: Float, b1: Float, x: ClosedFloatingPointRange<Float>, a2: Float, b2: Float) =
scale(a1, b1, x.start, a2, b2)..scale(a1, b1, x.endInclusive, a2, b2)
// Calculate the 0..1 fraction that `pos` value represents between `a` and `b`
private fun calcFraction(a: Float, b: Float, pos: Float) =
(if (b - a == 0f) 0f else (pos - a) / (b - a)).coerceIn(0f, 1f)
@OptIn(ExperimentalMaterial3Api::class)
private fun Modifier.sliderSemantics(
state: SliderState,
enabled: Boolean
): Modifier {
val coerced = state.value.coerceIn(state.valueRange.start, state.valueRange.endInclusive)
return semantics {
if (!enabled) disabled()
setProgress(
action = { targetValue ->
var newValue = targetValue.coerceIn(
state.valueRange.start,
state.valueRange.endInclusive
)
val originalVal = newValue
val resolvedValue = if (state.steps > 0) {
var distance: Float = newValue
for (i in 0..state.steps + 1) {
val stepValue = lerp(
state.valueRange.start,
state.valueRange.endInclusive,
i.toFloat() / (state.steps + 1)
)
if (abs(stepValue - originalVal) <= distance) {
distance = abs(stepValue - originalVal)
newValue = stepValue
}
}
newValue
} else {
newValue
}
// This is to keep it consistent with AbsSeekbar.java: return false if no
// change from current.
if (resolvedValue == coerced) {
false
} else {
state.onValueChange(resolvedValue)
state.onValueChangeFinished?.invoke()
true
}
}
)
}.progressSemantics(state.value, state.valueRange, state.steps)
}
@OptIn(ExperimentalMaterial3Api::class)
private fun Modifier.rangeSliderStartThumbSemantics(
state: RangeSliderState,
enabled: Boolean
): Modifier {
val valueRange = state.valueRange.start..state.coercedEnd
val coerced = state.coercedStart.coerceIn(
valueRange.start,
valueRange.endInclusive
)
return semantics {
if (!enabled) disabled()
setProgress(
action = { targetValue ->
var newValue = targetValue.coerceIn(
valueRange.start,
valueRange.endInclusive
)
val originalVal = newValue
val resolvedValue = if (state.startSteps > 0) {
var distance: Float = newValue
for (i in 0..state.startSteps + 1) {
val stepValue = lerp(
valueRange.start,
valueRange.endInclusive,
i.toFloat() / (state.startSteps + 1)
)
if (abs(stepValue - originalVal) <= distance) {
distance = abs(stepValue - originalVal)
newValue = stepValue
}
}
newValue
} else {
newValue
}
// This is to keep it consistent with AbsSeekbar.java: return false if no
// change from current.
if (resolvedValue == coerced) {
false
} else {
state.onValueChange(resolvedValue..state.coercedEnd)
state.onValueChangeFinished?.invoke()
true
}
}
)
}.progressSemantics(state.coercedStart, valueRange, state.startSteps)
}
@OptIn(ExperimentalMaterial3Api::class)
private fun Modifier.rangeSliderEndThumbSemantics(
state: RangeSliderState,
enabled: Boolean
): Modifier {
val valueRange = state.coercedStart..state.valueRange.endInclusive
val coerced = state.coercedEnd.coerceIn(
valueRange.start,
valueRange.endInclusive
)
return semantics {
if (!enabled) disabled()
setProgress(
action = { targetValue ->
var newValue = targetValue.coerceIn(valueRange.start, valueRange.endInclusive)
val originalVal = newValue
val resolvedValue = if (state.endSteps > 0) {
var distance: Float = newValue
for (i in 0..state.endSteps + 1) {
val stepValue = lerp(
valueRange.start,
valueRange.endInclusive,
i.toFloat() / (state.endSteps + 1)
)
if (abs(stepValue - originalVal) <= distance) {
distance = abs(stepValue - originalVal)
newValue = stepValue
}
}
newValue
} else {
newValue
}
// This is to keep it consistent with AbsSeekbar.java: return false if no
// change from current.
if (resolvedValue == coerced) {
false
} else {
state.onValueChange(state.coercedStart..resolvedValue)
state.onValueChangeFinished?.invoke()
true
}
}
)
}.progressSemantics(state.coercedEnd, valueRange, state.endSteps)
}
@OptIn(ExperimentalMaterial3Api::class)
private fun Modifier.sliderTapModifier(
state: SliderState,
interactionSource: MutableInteractionSource,
enabled: Boolean
) = composed(
factory = {
if (enabled) {
val scope = rememberCoroutineScope()
pointerInput(state.draggableState, interactionSource, state.totalWidth, state.isRtl) {
detectTapGestures(
onPress = state.press,
onTap = {
scope.launch {
state.draggableState.drag(MutatePriority.UserInput) {
// just trigger animation, press offset will be applied
dragBy(0f)
}
state.gestureEndAction()
}
}
)
}
} else {
this
}
},
inspectorInfo = debugInspectorInfo {
name = "sliderTapModifier"
properties["state"] = state
properties["interactionSource"] = interactionSource
properties["enabled"] = enabled
})
@OptIn(ExperimentalMaterial3Api::class)
private fun Modifier.rangeSliderPressDragModifier(
state: RangeSliderState,
startInteractionSource: MutableInteractionSource,
endInteractionSource: MutableInteractionSource,
enabled: Boolean
): Modifier =
if (enabled) {
pointerInput(
startInteractionSource,
endInteractionSource,
state.totalWidth,
state.isRtl,
state.valueRange
) {
val rangeSliderLogic = RangeSliderLogic(
state,
startInteractionSource,
endInteractionSource
)
coroutineScope {
awaitEachGesture {
val event = awaitFirstDown(requireUnconsumed = false)
val interaction = DragInteraction.Start()
var posX = if (state.isRtl)
state.totalWidth - event.position.x else event.position.x
val compare = rangeSliderLogic.compareOffsets(posX)
var draggingStart = if (compare != 0) {
compare < 0
} else {
state.rawOffsetStart > posX
}
awaitSlop(event.id, event.type)?.let {
val slop = viewConfiguration.pointerSlop(event.type)
val shouldUpdateCapturedThumb = abs(state.rawOffsetEnd - posX) < slop &&
abs(state.rawOffsetStart - posX) < slop
if (shouldUpdateCapturedThumb) {
val dir = it.second
draggingStart = if (state.isRtl) dir >= 0f else dir < 0f
posX += it.first.positionChange().x
}
}
rangeSliderLogic.captureThumb(
draggingStart,
posX,
interaction,
this@coroutineScope
)
val finishInteraction = try {
val success = horizontalDrag(pointerId = event.id) {
val deltaX = it.positionChange().x
state.onDrag.invoke(draggingStart, if (state.isRtl) -deltaX else deltaX)
}
if (success) {
DragInteraction.Stop(interaction)
} else {
DragInteraction.Cancel(interaction)
}
} catch (e: CancellationException) {
DragInteraction.Cancel(interaction)
}
state.gestureEndAction(draggingStart)
launch {
rangeSliderLogic
.activeInteraction(draggingStart)
.emit(finishInteraction)
}
}
}
}
} else {
this
}
@OptIn(ExperimentalMaterial3Api::class)
private class RangeSliderLogic constructor(
val state: RangeSliderState,
val startInteractionSource: MutableInteractionSource,
val endInteractionSource: MutableInteractionSource
) {
fun activeInteraction(draggingStart: Boolean): MutableInteractionSource =
if (draggingStart) startInteractionSource else endInteractionSource
fun compareOffsets(eventX: Float): Int {
val diffStart = abs(state.rawOffsetStart - eventX)
val diffEnd = abs(state.rawOffsetEnd - eventX)
return diffStart.compareTo(diffEnd)
}
fun captureThumb(
draggingStart: Boolean,
posX: Float,
interaction: Interaction,
scope: CoroutineScope
) {
state.onDrag.invoke(
draggingStart,
posX - if (draggingStart) state.rawOffsetStart else state.rawOffsetEnd
)
scope.launch {
activeInteraction(draggingStart).emit(interaction)
}
}
}
/**
* Represents the color used by a [Slider] in different states.
*
* @constructor create an instance with arbitrary colors.
* See [SliderDefaults.colors] for the default implementation that follows Material
* specifications.
*
* @param thumbColor thumb color when enabled
* @param activeTrackColor color of the track in the part that is "active", meaning that the
* thumb is ahead of it
* @param activeTickColor colors to be used to draw tick marks on the active track, if `steps`
* is specified
* @param inactiveTrackColor color of the track in the part that is "inactive", meaning that the
* thumb is before it
* @param inactiveTickColor colors to be used to draw tick marks on the inactive track, if
* `steps` are specified on the Slider is specified
* @param disabledThumbColor thumb colors when disabled
* @param disabledActiveTrackColor color of the track in the "active" part when the Slider is
* disabled
* @param disabledActiveTickColor colors to be used to draw tick marks on the active track
* when Slider is disabled and when `steps` are specified on it
* @param disabledInactiveTrackColor color of the track in the "inactive" part when the
* Slider is disabled
* @param disabledInactiveTickColor colors to be used to draw tick marks on the inactive part
* of the track when Slider is disabled and when `steps` are specified on it
*/
@Immutable
class SliderColors constructor(
val thumbColor: Color,
val activeTrackColor: Color,
val activeTickColor: Color,
val inactiveTrackColor: Color,
val inactiveTickColor: Color,
val disabledThumbColor: Color,
val disabledActiveTrackColor: Color,
val disabledActiveTickColor: Color,
val disabledInactiveTrackColor: Color,
val disabledInactiveTickColor: Color
) {
@Composable
internal fun thumbColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(if (enabled) thumbColor else disabledThumbColor)
}
@Composable
internal fun trackColor(enabled: Boolean, active: Boolean): State<Color> {
return rememberUpdatedState(
if (enabled) {
if (active) activeTrackColor else inactiveTrackColor
} else {
if (active) disabledActiveTrackColor else disabledInactiveTrackColor
}
)
}
@Composable
internal fun tickColor(enabled: Boolean, active: Boolean): State<Color> {
return rememberUpdatedState(
if (enabled) {
if (active) activeTickColor else inactiveTickColor
} else {
if (active) disabledActiveTickColor else disabledInactiveTickColor
}
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || other !is SliderColors) return false
if (thumbColor != other.thumbColor) return false
if (activeTrackColor != other.activeTrackColor) return false
if (activeTickColor != other.activeTickColor) return false
if (inactiveTrackColor != other.inactiveTrackColor) return false
if (inactiveTickColor != other.inactiveTickColor) return false
if (disabledThumbColor != other.disabledThumbColor) return false
if (disabledActiveTrackColor != other.disabledActiveTrackColor) return false
if (disabledActiveTickColor != other.disabledActiveTickColor) return false
if (disabledInactiveTrackColor != other.disabledInactiveTrackColor) return false
if (disabledInactiveTickColor != other.disabledInactiveTickColor) return false
return true
}
override fun hashCode(): Int {
var result = thumbColor.hashCode()
result = 31 * result + activeTrackColor.hashCode()
result = 31 * result + activeTickColor.hashCode()
result = 31 * result + inactiveTrackColor.hashCode()
result = 31 * result + inactiveTickColor.hashCode()
result = 31 * result + disabledThumbColor.hashCode()
result = 31 * result + disabledActiveTrackColor.hashCode()
result = 31 * result + disabledActiveTickColor.hashCode()
result = 31 * result + disabledInactiveTrackColor.hashCode()
result = 31 * result + disabledInactiveTickColor.hashCode()
return result
}
}
// Internal to be referred to in tests
internal val ThumbWidth = SliderTokens.HandleWidth
private val ThumbHeight = SliderTokens.HandleHeight
private val ThumbSize = DpSize(ThumbWidth, ThumbHeight)
private val ThumbDefaultElevation = 1.dp
private val ThumbPressedElevation = 6.dp
private val TickSize = SliderTokens.TickMarksContainerSize
// Internal to be referred to in tests
internal val TrackHeight = SliderTokens.InactiveTrackHeight
private val SliderHeight = 48.dp
private val SliderMinWidth = 144.dp // TODO: clarify min width
internal class SliderDraggableState(
val onDelta: (Float) -> Unit
) : DraggableState {
var isDragging by mutableStateOf(false)
private set
private val dragScope: DragScope = object : DragScope {
override fun dragBy(pixels: Float): Unit = onDelta(pixels)
}
private val scrollMutex = MutatorMutex()
override suspend fun drag(
dragPriority: MutatePriority,
block: suspend DragScope.() -> Unit
): Unit = coroutineScope {
isDragging = true
scrollMutex.mutateWith(dragScope, dragPriority, block)
isDragging = false
}
override fun dispatchRawDelta(delta: Float) {
return onDelta(delta)
}
}
private enum class SliderComponents {
THUMB,
TRACK
}
private enum class RangeSliderComponents {
ENDTHUMB,
STARTTHUMB,
TRACK
}
/**
* Class that holds information about [Slider]'s and [RangeSlider]'s active track
* and fractional positions where the discrete ticks should be drawn on the track.
*/
@Stable
class SliderPositions(
initialActiveRange: ClosedFloatingPointRange<Float> = 0f..1f,
initialTickFractions: FloatArray = floatArrayOf()
) {
/**
* [ClosedFloatingPointRange] that indicates the current active range for the
* start to thumb for a [Slider] and start thumb to end thumb for a [RangeSlider].
*/
var activeRange: ClosedFloatingPointRange<Float> by mutableStateOf(initialActiveRange)
internal set
/**
* The discrete points where a tick should be drawn on the track.
* Each value of tickFractions should be within the range [0f, 1f]. If
* the track is continuous, then tickFractions will be an empty [FloatArray].
*/
var tickFractions: FloatArray by mutableStateOf(initialTickFractions)
internal set
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SliderPositions) return false
if (activeRange != other.activeRange) return false
if (!tickFractions.contentEquals(other.tickFractions)) return false
return true
}
override fun hashCode(): Int {
var result = activeRange.hashCode()
result = 31 * result + tickFractions.contentHashCode()
return result
}
}
/**
* Class that holds information about [Slider]'s active range.
*
* @param initialValue [Float] that indicates the initial
* position of the thumb. If outside of [valueRange]
* provided, value will be coerced to this range.
* @param initialOnValueChange callback in which [value] should be updated.
* @param steps if greater than 0, specifies the amounts of discrete values, evenly distributed
* between across the whole value range. If 0, range slider will behave as a continuous slider and
* allow to choose any value from the range specified. Must not be negative.
* @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
* shouldn't be used to update the range slider values (use [onValueChange] for that),
* but rather to know when the user has completed selecting a new value by ending a drag or a click.
* @param valueRange range of values that Slider values can take. [value] will be
* coerced to this range.
*/
@Stable
@ExperimentalMaterial3Api
class SliderState(
initialValue: Float = 0f,
initialOnValueChange: ((Float) -> Unit)? = null,
/*@IntRange(from = 0)*/
val steps: Int = 0,
val valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
var onValueChangeFinished: (() -> Unit)? = null
) {
private var valueState by mutableFloatStateOf(initialValue)
/**
* [Float] that indicates the current value that the thumb
* currently is in respect to the track.
*/
var value: Float
set(newVal) {
val coercedValue = newVal.coerceIn(valueRange.start, valueRange.endInclusive)
val snappedValue = snapValueToTick(
coercedValue,
tickFractions,
valueRange.start,
valueRange.endInclusive
)
valueState = snappedValue
}
get() = valueState
/**
* callback in which value should be updated
*/
internal var onValueChange: (Float) -> Unit = {
if (it != value) {
initialOnValueChange?.invoke(it) ?: defaultOnValueChange(it)
}
}
internal val tickFractions = stepsToTickFractions(steps)
private var thumbWidth by mutableFloatStateOf(ThumbWidth.value)
internal var totalWidth by mutableIntStateOf(0)
private var rawOffset by mutableFloatStateOf(scaleToOffset(0f, 0f, value))
private var pressOffset by mutableFloatStateOf(0f)
internal var isRtl = false
internal val coercedValueAsFraction
get() = calcFraction(
valueRange.start,
valueRange.endInclusive,
value.coerceIn(valueRange.start, valueRange.endInclusive)
)
internal val draggableState =
SliderDraggableState {
val maxPx = max(totalWidth - thumbWidth / 2, 0f)
val minPx = min(thumbWidth / 2, maxPx)
rawOffset = (rawOffset + it + pressOffset)
pressOffset = 0f
val offsetInTrack = snapValueToTick(rawOffset, tickFractions, minPx, maxPx)
onValueChange(scaleToUserValue(minPx, maxPx, offsetInTrack))
}
internal val gestureEndAction = {
if (!draggableState.isDragging) {
// check isDragging in case the change is still in progress (touch -> drag case)
onValueChangeFinished?.invoke()
}
}
internal val press: suspend PressGestureScope.(Offset) -> Unit = { pos ->
val to = if (isRtl) totalWidth - pos.x else pos.x
pressOffset = to - rawOffset
try {
awaitRelease()
} catch (_: GestureCancellationException) {
pressOffset = 0f
}
}
internal fun updateDimensions(
newThumbWidth: Float,
newTotalWidth: Int
) {
thumbWidth = newThumbWidth
totalWidth = newTotalWidth
}
private fun defaultOnValueChange(newVal: Float) { value = newVal }
private fun scaleToUserValue(minPx: Float, maxPx: Float, offset: Float) =
scale(minPx, maxPx, offset, valueRange.start, valueRange.endInclusive)
private fun scaleToOffset(minPx: Float, maxPx: Float, userValue: Float) =
scale(valueRange.start, valueRange.endInclusive, userValue, minPx, maxPx)
}
/**
* Class that holds information about [RangeSlider]'s active range.
*
* @param initialActiveRangeStart [Float] that indicates the initial
* start of the active range of the slider. If outside of [valueRange]
* provided, value will be coerced to this range.
* @param initialActiveRangeEnd [Float] that indicates the initial
* end of the active range of the slider. If outside of [valueRange]
* provided, value will be coerced to this range.
* @param initialOnValueChange callback in which [activeRangeStart] and
* [activeRangeEnd] should be updated.
* @param steps if greater than 0, specifies the amounts of discrete values, evenly distributed
* between across the whole value range. If 0, range slider will behave as a continuous slider and
* allow to choose any value from the range specified. Must not be negative.
* @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
* shouldn't be used to update the range slider values (use [onValueChange] for that), but rather
* to know when the user has completed selecting a new value by ending a drag or a click.
* @param valueRange range of values that Range Slider values can take. [activeRangeStart]
* and [activeRangeEnd] will be coerced to this range.
*/
@Stable
@ExperimentalMaterial3Api
class RangeSliderState(
initialActiveRangeStart: Float = 0f,
initialActiveRangeEnd: Float = 1f,
initialOnValueChange: ((ClosedFloatingPointRange<Float>) -> Unit)? = null,
/*@IntRange(from = 0)*/
val steps: Int = 0,
val valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
var onValueChangeFinished: (() -> Unit)? = null,
) {
private var activeRangeStartState by mutableFloatStateOf(initialActiveRangeStart)
private var activeRangeEndState by mutableFloatStateOf(initialActiveRangeEnd)
/**
* [Float]s that indicates the current active range for the
* start thumb and end thumb for a [RangeSlider].
*/
var activeRangeStart: Float
set(newVal) {
val coercedValue = newVal.coerceIn(valueRange.start, activeRangeEnd)
val snappedValue = snapValueToTick(
coercedValue,
tickFractions,
valueRange.start,
valueRange.endInclusive
)
activeRangeStartState = snappedValue
}
get() = activeRangeStartState
var activeRangeEnd: Float
set(newVal) {
val coercedValue = newVal.coerceIn(activeRangeStart, valueRange.endInclusive)
val snappedValue = snapValueToTick(
coercedValue,
tickFractions,
valueRange.start,
valueRange.endInclusive
)
activeRangeEndState = snappedValue
}
get() = activeRangeEndState
internal var onValueChange: (ClosedFloatingPointRange<Float>) -> Unit = {
if (it != activeRangeStart..activeRangeEnd) {
initialOnValueChange?.invoke(it) ?: defaultOnValueChange(it)
}
}
internal val tickFractions = stepsToTickFractions(steps)
internal var startThumbWidth by mutableFloatStateOf(ThumbWidth.value)
internal var endThumbWidth by mutableFloatStateOf(ThumbWidth.value)
internal var totalWidth by mutableIntStateOf(0)
internal var rawOffsetStart by mutableFloatStateOf(0f)
internal var rawOffsetEnd by mutableFloatStateOf(0f)
internal var isRtl = false
internal val gestureEndAction: (Boolean) -> Unit = {
onValueChangeFinished?.invoke()
}
private var maxPx by mutableFloatStateOf(max(totalWidth - endThumbWidth / 2, 0f))
private var minPx by mutableFloatStateOf(min(startThumbWidth / 2, maxPx))
@Suppress("PrimitiveInLambda")
internal val onDrag: (Boolean, Float) -> Unit = { isStart, offset ->
val offsetRange = if (isStart) {
rawOffsetStart = (rawOffsetStart + offset)
rawOffsetEnd = scaleToOffset(minPx, maxPx, activeRangeEnd)
val offsetEnd = rawOffsetEnd
var offsetStart = rawOffsetStart.coerceIn(minPx, offsetEnd)
offsetStart = snapValueToTick(offsetStart, tickFractions, minPx, maxPx)
offsetStart..offsetEnd
} else {
rawOffsetEnd = (rawOffsetEnd + offset)
rawOffsetStart = scaleToOffset(minPx, maxPx, activeRangeStart)
val offsetStart = rawOffsetStart
var offsetEnd = rawOffsetEnd.coerceIn(offsetStart, maxPx)
offsetEnd = snapValueToTick(offsetEnd, tickFractions, minPx, maxPx)
offsetStart..offsetEnd
}
onValueChange(scaleToUserValue(minPx, maxPx, offsetRange))
}
internal val coercedStart
get() = activeRangeStart.coerceIn(valueRange.start, activeRangeEnd)
internal val coercedEnd
get() = activeRangeEnd.coerceIn(activeRangeStart, valueRange.endInclusive)
internal val coercedActiveRangeStartAsFraction
get() = calcFraction(
valueRange.start,
valueRange.endInclusive,
coercedStart
)
internal val coercedActiveRangeEndAsFraction
get() = calcFraction(
valueRange.start,
valueRange.endInclusive,
coercedEnd
)
internal val startSteps
get() = floor(steps * coercedActiveRangeEndAsFraction).toInt()
internal val endSteps
get() = floor(steps * (1f - coercedActiveRangeStartAsFraction)).toInt()
private fun defaultOnValueChange(newRange: ClosedFloatingPointRange<Float>) {
activeRangeStart = newRange.start
activeRangeEnd = newRange.endInclusive
}
// scales range offset from within minPx..maxPx to within valueRange.start..valueRange.end
private fun scaleToUserValue(
minPx: Float,
maxPx: Float,
offset:
ClosedFloatingPointRange<Float>
) = scale(minPx, maxPx, offset, valueRange.start, valueRange.endInclusive)
// scales float userValue within valueRange.start..valueRange.end to within minPx..maxPx
private fun scaleToOffset(minPx: Float, maxPx: Float, userValue: Float) =
scale(valueRange.start, valueRange.endInclusive, userValue, minPx, maxPx)
internal fun updateMinMaxPx() {
val newMaxPx = max(totalWidth - endThumbWidth / 2, 0f)
val newMinPx = min(startThumbWidth / 2, maxPx)
if (minPx != newMinPx || maxPx != newMaxPx) {
minPx = newMinPx
maxPx = newMaxPx
rawOffsetStart = scaleToOffset(
minPx,
maxPx,
activeRangeStart
)
rawOffsetEnd = scaleToOffset(
minPx,
maxPx,
activeRangeEnd
)
}
}
}