net.peanuuutz.fork.ui.preset.Slider.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of fork-ui Show documentation
Show all versions of fork-ui Show documentation
Comprehensive API designed for Minecraft modders
The newest version!
/*
* Copyright 2020 The Android Open Source Project
* Modifications Copyright 2022 Peanuuutz
*
* 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 net.peanuuutz.fork.ui.preset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import net.peanuuutz.fork.ui.animation.spec.target.DefaultFloatAnimationSpec
import net.peanuuutz.fork.ui.animation.spec.target.composite.FiniteAnimationSpec
import net.peanuuutz.fork.ui.animation.spec.target.composite.SnapSpec
import net.peanuuutz.fork.ui.foundation.draw.BorderStroke
import net.peanuuutz.fork.ui.foundation.draw.background
import net.peanuuutz.fork.ui.foundation.draw.border
import net.peanuuutz.fork.ui.foundation.input.AxisAnchorThreshold
import net.peanuuutz.fork.ui.foundation.input.AxisAnchoredDragState
import net.peanuuutz.fork.ui.foundation.input.AxisAnchoredDragState.Companion.MinimumAnchorsSpacing
import net.peanuuutz.fork.ui.foundation.input.AxisAnchors
import net.peanuuutz.fork.ui.foundation.input.AxisDragState
import net.peanuuutz.fork.ui.foundation.input.LocalVisualIndication
import net.peanuuutz.fork.ui.foundation.input.axisAnchoredDraggable
import net.peanuuutz.fork.ui.foundation.input.detectDrag
import net.peanuuutz.fork.ui.foundation.input.detectPressRelease
import net.peanuuutz.fork.ui.foundation.input.focusable
import net.peanuuutz.fork.ui.foundation.input.hoverable
import net.peanuuutz.fork.ui.foundation.input.interaction.DragInteraction
import net.peanuuutz.fork.ui.foundation.input.interaction.MutableInteractionSource
import net.peanuuutz.fork.ui.foundation.input.interaction.PressInteraction
import net.peanuuutz.fork.ui.foundation.input.interaction.collectFocusState
import net.peanuuutz.fork.ui.foundation.input.interaction.collectHoverState
import net.peanuuutz.fork.ui.foundation.input.interaction.detectAndEmitDragInteractions
import net.peanuuutz.fork.ui.foundation.input.interaction.detectAndEmitPressInteractions
import net.peanuuutz.fork.ui.foundation.input.interaction.tryEmitCancelOnDrag
import net.peanuuutz.fork.ui.foundation.input.interaction.tryEmitCancelOnPress
import net.peanuuutz.fork.ui.foundation.input.pointerIconOnHover
import net.peanuuutz.fork.ui.foundation.input.rememberAxisAnchoredDragState
import net.peanuuutz.fork.ui.foundation.input.rememberAxisDragState
import net.peanuuutz.fork.ui.foundation.input.visualIndication
import net.peanuuutz.fork.ui.foundation.layout.Arrangement
import net.peanuuutz.fork.ui.foundation.layout.Box
import net.peanuuutz.fork.ui.foundation.layout.BoxScope
import net.peanuuutz.fork.ui.foundation.layout.NoPadding
import net.peanuuutz.fork.ui.foundation.layout.PaddingValues
import net.peanuuutz.fork.ui.foundation.layout.fillMaxHeight
import net.peanuuutz.fork.ui.foundation.layout.fillMaxWidth
import net.peanuuutz.fork.ui.foundation.layout.list.Row
import net.peanuuutz.fork.ui.foundation.layout.list.RowScope
import net.peanuuutz.fork.ui.foundation.layout.minHeight
import net.peanuuutz.fork.ui.foundation.layout.minWidth
import net.peanuuutz.fork.ui.foundation.layout.padding
import net.peanuuutz.fork.ui.foundation.layout.wrapContentSize
import net.peanuuutz.fork.ui.inspection.InspectInfo
import net.peanuuutz.fork.ui.preset.theme.Theme
import net.peanuuutz.fork.ui.ui.context.key.Key
import net.peanuuutz.fork.ui.ui.context.key.KeyEvent
import net.peanuuutz.fork.ui.ui.context.key.KeyboardModifier
import net.peanuuutz.fork.ui.ui.context.key.isCtrlPressed
import net.peanuuutz.fork.ui.ui.context.key.isReleased
import net.peanuuutz.fork.ui.ui.context.key.isShiftPressed
import net.peanuuutz.fork.ui.ui.context.pointer.PointerEvent
import net.peanuuutz.fork.ui.ui.context.pointer.PointerIcon
import net.peanuuutz.fork.ui.ui.draw.Painter
import net.peanuuutz.fork.ui.ui.layout.Alignment
import net.peanuuutz.fork.ui.ui.layout.Constraints
import net.peanuuutz.fork.ui.ui.layout.LayoutDirection
import net.peanuuutz.fork.ui.ui.layout.LayoutDirection.Down
import net.peanuuutz.fork.ui.ui.layout.LayoutDirection.Left
import net.peanuuutz.fork.ui.ui.layout.LayoutDirection.Right
import net.peanuuutz.fork.ui.ui.layout.LayoutDirection.Up
import net.peanuuutz.fork.ui.ui.layout.Measurable
import net.peanuuutz.fork.ui.ui.layout.MeasureResult
import net.peanuuutz.fork.ui.ui.layout.isHorizontal
import net.peanuuutz.fork.ui.ui.modifier.Modifier
import net.peanuuutz.fork.ui.ui.modifier.ModifierNodeElement
import net.peanuuutz.fork.ui.ui.modifier.conditional
import net.peanuuutz.fork.ui.ui.modifier.input.SuspendingPointerInputModifierNode
import net.peanuuutz.fork.ui.ui.node.BranchingModifierNode
import net.peanuuutz.fork.ui.ui.node.KeyEventPass
import net.peanuuutz.fork.ui.ui.node.KeyEventPass.Out
import net.peanuuutz.fork.ui.ui.node.KeyInputModifierNode
import net.peanuuutz.fork.ui.ui.node.LayoutCallbackModifierNode
import net.peanuuutz.fork.ui.ui.node.LayoutInfo
import net.peanuuutz.fork.ui.ui.node.LayoutModifierNode
import net.peanuuutz.fork.ui.ui.node.ModifierNode
import net.peanuuutz.fork.ui.ui.node.PointerEventPass
import net.peanuuutz.fork.ui.ui.node.PointerInputModifierNode
import net.peanuuutz.fork.ui.ui.unit.FloatOffset
import net.peanuuutz.fork.ui.ui.unit.IntOffset
import net.peanuuutz.fork.ui.ui.unit.IntSize
import net.peanuuutz.fork.util.common.Color
import net.peanuuutz.fork.util.common.ref
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun Slider(
value: Float,
onValueChanged: (Float) -> Unit,
range: ClosedFloatingPointRange,
modifier: Modifier = Modifier,
direction: LayoutDirection = Right,
isEnabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
thumbStyle: SliderThumbStyle = Theme.sliderThumb,
thumbPadding: PaddingValues = SliderDefaults.ThumbPadding,
thumbSizer: ThumbSizer = if (direction.isHorizontal) {
SliderDefaults.Horizontal.DefaultThumbSizer
} else {
SliderDefaults.Vertical.DefaultThumbSizer
},
trackStyle: SliderTrackStyle = Theme.sliderTrack,
trackContentPadding: PaddingValues = if (direction.isHorizontal) {
SliderDefaults.Horizontal.TrackContentPadding
} else {
SliderDefaults.Vertical.TrackContentPadding
},
trackContentArrangement: Arrangement.Horizontal = Arrangement.Center,
trackContent: (@Composable RowScope.(value: Float) -> Unit)? = null
) {
ContinuousSlider(
value = value,
onValueChanged = onValueChanged,
range = range,
direction = direction,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbStyle = thumbStyle,
thumbPadding = thumbPadding,
thumbSizer = thumbSizer,
trackStyle = trackStyle,
trackContentPadding = trackContentPadding,
trackContentArrangement = trackContentArrangement,
trackContent = trackContent,
modifier = modifier
)
}
@Composable
fun Slider(
value: T,
onValueChanged: (T) -> Unit,
values: List,
modifier: Modifier = Modifier,
direction: LayoutDirection = Right,
isEnabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
thumbStyle: SliderThumbStyle = Theme.sliderThumb,
thumbPadding: PaddingValues = SliderDefaults.ThumbPadding,
thumbSizer: ThumbSizer = if (direction.isHorizontal) {
SliderDefaults.Horizontal.DefaultThumbSizer
} else {
SliderDefaults.Vertical.DefaultThumbSizer
},
trackStyle: SliderTrackStyle = Theme.sliderTrack,
trackContentPadding: PaddingValues = if (direction.isHorizontal) {
SliderDefaults.Horizontal.TrackContentPadding
} else {
SliderDefaults.Vertical.TrackContentPadding
},
trackContentArrangement: Arrangement.Horizontal = Arrangement.Center,
trackContent: (@Composable RowScope.(value: T) -> Unit)? = null
) {
DiscreteSlider(
value = value,
onValueChanged = onValueChanged,
values = values,
direction = direction,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbStyle = thumbStyle,
thumbPadding = thumbPadding,
thumbSizer = thumbSizer,
trackStyle = trackStyle,
trackContentPadding = trackContentPadding,
trackContentArrangement = trackContentArrangement,
trackContent = trackContent,
modifier = modifier
)
}
object SliderDefaults {
val ThumbPadding: PaddingValues = NoPadding
object Horizontal {
const val MinHeight: Int = 8
const val DefaultHeight: Int = 20
val DefaultThumbSizer: ThumbSizer = object : ThumbSizer {
override fun calculateThumbSize(availableSpace: IntSize): IntSize {
return IntSize(
width = 8,
height = availableSpace.height
)
}
override fun toString(): String {
return "SliderDefaults.Horizontal.DefaultThumbSizer"
}
}
val TrackContentPadding: PaddingValues = PaddingValues(4, 1)
}
object Vertical {
const val MinWidth: Int = 8
const val DefaultWidth: Int = 20
val DefaultThumbSizer: ThumbSizer = object : ThumbSizer {
override fun calculateThumbSize(availableSpace: IntSize): IntSize {
return IntSize(
width = availableSpace.width,
height = 8
)
}
override fun toString(): String {
return "SliderDefaults.Vertical.DefaultThumbSizer"
}
}
val TrackContentPadding: PaddingValues = PaddingValues(1, 4)
}
}
@Stable
fun interface ThumbSizer {
fun calculateThumbSize(availableSpace: IntSize): IntSize
companion object {
@Stable
fun absolute(size: IntSize): ThumbSizer {
return AbsoluteThumbSizer(size)
}
@Stable
fun relative(
widthFraction: Float,
heightFraction: Float
): ThumbSizer {
return RelativeThumbSizer(
widthFraction = widthFraction,
heightFraction = heightFraction
)
}
}
}
@Stable
interface SliderTrackStyle {
@Composable
fun border(
isEnabled: Boolean,
isSelected: Boolean,
fraction: Float
): State
@Composable
fun background(
isEnabled: Boolean,
isSelected: Boolean,
fraction: Float
): State
@Composable
fun content(
isEnabled: Boolean,
fraction: Float
): State
@Stable
abstract class Delegated(
val delegate: SliderTrackStyle
) : SliderTrackStyle by delegate
}
@Stable
interface SliderThumbStyle {
@Composable
fun border(
isEnabled: Boolean,
isSelected: Boolean,
fraction: Float
): State
@Composable
fun background(
isEnabled: Boolean,
isSelected: Boolean,
fraction: Float
): State
@Stable
abstract class Delegated(
val delegate: SliderThumbStyle
) : SliderThumbStyle by delegate
}
val Theme.sliderTrack: SliderTrackStyle
@ReadOnlyComposable
@Composable
get() = LocalSliderTrack.current
val Theme.sliderThumb: SliderThumbStyle
@ReadOnlyComposable
@Composable
get() = LocalSliderThumb.current
@NonRestartableComposable
@Composable
fun SliderStyleProvider(
thumbStyle: SliderThumbStyle,
trackStyle: SliderTrackStyle,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalSliderThumb provides thumbStyle,
LocalSliderTrack provides trackStyle,
content = content
)
}
@Stable
class DefaultSliderTrackStyle(
val border: BorderStroke,
val background: Painter,
val contentColor: Color,
val selectedBorder: BorderStroke,
val selectedBackground: Painter,
val disabledBorder: BorderStroke,
val disabledBackground: Painter,
val disabledContentColor: Color
) : SliderTrackStyle {
@Composable
override fun border(isEnabled: Boolean, isSelected: Boolean, fraction: Float): State {
val borderStroke = when {
!isEnabled -> disabledBorder
!isSelected -> border
else -> selectedBorder
}
return rememberUpdatedState(borderStroke)
}
@Composable
override fun background(isEnabled: Boolean, isSelected: Boolean, fraction: Float): State {
val painter = when {
!isEnabled -> disabledBackground
!isSelected -> background
else -> selectedBackground
}
return rememberUpdatedState(painter)
}
@Composable
override fun content(isEnabled: Boolean, fraction: Float): State {
val color = if (isEnabled) contentColor else disabledContentColor
return rememberUpdatedState(color)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DefaultSliderTrackStyle) return false
if (border != other.border) return false
if (background != other.background) return false
if (contentColor != other.contentColor) return false
if (selectedBorder != other.selectedBorder) return false
if (selectedBackground != other.selectedBackground) return false
if (disabledBorder != other.disabledBorder) return false
if (disabledBackground != other.disabledBackground) return false
if (disabledContentColor != other.disabledContentColor) return false
return true
}
override fun hashCode(): Int {
var result = border.hashCode()
result = 31 * result + background.hashCode()
result = 31 * result + contentColor.hashCode()
result = 31 * result + selectedBorder.hashCode()
result = 31 * result + selectedBackground.hashCode()
result = 31 * result + disabledBorder.hashCode()
result = 31 * result + disabledBackground.hashCode()
result = 31 * result + disabledContentColor.hashCode()
return result
}
override fun toString(): String {
return "DefaultSliderTrackStyle(border=$border, background=$background, " +
"contentColor=$contentColor, " +
"selectedBorder=$selectedBorder, selectedBackground=$selectedBackground, " +
"disabledBorder=$disabledBorder, disabledBackground=$disabledBackground, " +
"disabledContentColor=$disabledContentColor)"
}
}
@Stable
class DefaultSliderThumbStyle(
val border: BorderStroke,
val background: Painter,
val selectedBorder: BorderStroke,
val selectedBackground: Painter,
val disabledBorder: BorderStroke,
val disabledBackground: Painter
) : SliderThumbStyle {
@Composable
override fun border(isEnabled: Boolean, isSelected: Boolean, fraction: Float): State {
val borderStroke = when {
!isEnabled -> disabledBorder
!isSelected -> border
else -> selectedBorder
}
return rememberUpdatedState(borderStroke)
}
@Composable
override fun background(isEnabled: Boolean, isSelected: Boolean, fraction: Float): State {
val painter = when {
!isEnabled -> disabledBackground
!isSelected -> background
else -> selectedBackground
}
return rememberUpdatedState(painter)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DefaultSliderThumbStyle) return false
if (border != other.border) return false
if (background != other.background) return false
if (selectedBorder != other.selectedBorder) return false
if (selectedBackground != other.selectedBackground) return false
if (disabledBorder != other.disabledBorder) return false
if (disabledBackground != other.disabledBackground) return false
return true
}
override fun hashCode(): Int {
var result = border.hashCode()
result = 31 * result + background.hashCode()
result = 31 * result + selectedBorder.hashCode()
result = 31 * result + selectedBackground.hashCode()
result = 31 * result + disabledBorder.hashCode()
result = 31 * result + disabledBackground.hashCode()
return result
}
override fun toString(): String {
return "DefaultSliderThumbStyle(border=$border, background=$background, " +
"selectedBorder=$selectedBorder, selectedBackground=$selectedBackground, " +
"disabledBorder=$disabledBorder, disabledBackground=$disabledBackground)"
}
}
// ======== Internal ========
// -------- Common --------
// ---- Constants ----
private val AxisDirections: Map = mapOf(
Right to FloatOffset.Right,
Up to FloatOffset.Up,
Down to FloatOffset.Down,
Left to FloatOffset.Left
)
internal val DefaultThresholds: (Any?, Any?) -> AxisAnchorThreshold = { _, _ -> DefaultThreshold }
private val DefaultThreshold: AxisAnchorThreshold = AxisAnchorThreshold.relative(0.3f)
private val WrapContentAlignments: Map = mapOf(
Right to Alignment.CenterLeft,
Up to Alignment.BottomCenter,
Down to Alignment.TopCenter,
Left to Alignment.CenterRight
)
// ---- Thumb Sizer Presets ----
private data class AbsoluteThumbSizer(val size: IntSize) : ThumbSizer {
init {
require(size.minDimension > 0) {
"Thumb should have a positive size, but found $this"
}
}
override fun calculateThumbSize(availableSpace: IntSize): IntSize {
return size
}
}
private data class RelativeThumbSizer(
val widthFraction: Float,
val heightFraction: Float
) : ThumbSizer {
init {
require(widthFraction > 0.0f && heightFraction > 0.0f) {
"Thumb should have a positive size, but found $this"
}
}
override fun calculateThumbSize(availableSpace: IntSize): IntSize {
return IntSize(
width = (availableSpace.width * widthFraction).roundToInt(),
height = (availableSpace.height * heightFraction).roundToInt()
)
}
}
// ---- Base Slider Track ----
@Composable
private fun BaseSliderTrack(
valueProvider: () -> T,
fractionProvider: () -> Float,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
trackStyle: SliderTrackStyle,
trackContentPadding: PaddingValues,
trackContentArrangement: Arrangement.Horizontal,
trackContent: (@Composable RowScope.(value: T) -> Unit)?,
modifier: Modifier
) {
val fraction = fractionProvider()
val isFocused by interactionSource.collectFocusState(TrackThumbInteractions.Track)
val isSelected = isFocused
val border by trackStyle.border(
isEnabled = isEnabled,
isSelected = isSelected,
fraction = fraction
)
val background by trackStyle.background(
isEnabled = isEnabled,
isSelected = isSelected,
fraction = fraction
)
Row(
modifier = modifier
.border(border)
.background(background)
.padding(trackContentPadding),
horizontalArrangement = trackContentArrangement,
verticalAlignment = Alignment.CenterVertically
) {
if (trackContent != null) {
val contentColor by trackStyle.content(
isEnabled = isEnabled,
fraction = fraction
)
CompositionLocalProvider(
LocalContentColor provides contentColor
) {
trackContent(valueProvider())
}
}
}
}
// ---- Base Slider Thumb ----
@Composable
private fun BaseSliderThumb(
fractionProvider: () -> Float,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
thumbStyle: SliderThumbStyle,
modifier: Modifier
) {
val fraction = fractionProvider()
val isHovered by interactionSource.collectHoverState(TrackThumbInteractions.Thumb)
val isFocused by interactionSource.collectFocusState(TrackThumbInteractions.Thumb)
val isSelected = isHovered || isFocused
val border by thumbStyle.border(
isEnabled = isEnabled,
isSelected = isSelected,
fraction = fraction
)
val background by thumbStyle.background(
isEnabled = isEnabled,
isSelected = isSelected,
fraction = fraction
)
Box(
modifier = modifier
.border(border)
.background(background)
)
}
// ---- Slider Default Size Modifier ----
private fun Modifier.sliderDefaultSize(direction: LayoutDirection): Modifier {
return if (direction.isHorizontal) {
this
.fillMaxWidth()
.minHeight(SliderDefaults.Horizontal.MinHeight)
} else {
this
.fillMaxHeight()
.minWidth(SliderDefaults.Vertical.MinWidth)
}
}
// ---- Slider Track Selector Modifier ----
private sealed class SliderTrackSelectorModifierNode(
val thumbSizeProvider: () -> IntSize,
var direction: LayoutDirection,
interactionSource: MutableInteractionSource,
var thumbPadding: PaddingValues
) : BranchingModifierNode(), PointerInputModifierNode {
var interactionSource: MutableInteractionSource = interactionSource
set(value) {
if (field == value) {
return
}
field.tryEmitCancelOnDrag(
stateProvider = this::dragState,
stateUpdater = this::dragState::set
)
field.tryEmitCancelOnPress(
stateProvider = this::pressState,
stateUpdater = this::pressState::set
)
field = value
}
private val pointerInputHandler: SuspendingPointerInputModifierNode = branch {
SuspendingPointerInputModifierNode {
coroutineScope {
launch {
detectAndEmitDragInteractions(
interactionSourceProvider = this@SliderTrackSelectorModifierNode::interactionSource,
stateProvider = this@SliderTrackSelectorModifierNode::dragState,
stateUpdater = this@SliderTrackSelectorModifierNode::dragState::set,
labelProvider = { TrackThumbInteractions.Thumb }
)
}
launch {
detectDrag(
onDrag = { moveEvent ->
emitValueFromSliderLocalPosition(
sliderSize = scopeInfo.size,
sliderLocalOffset = moveEvent.position
)
}
)
}
launch {
detectAndEmitPressInteractions(
interactionSourceProvider = this@SliderTrackSelectorModifierNode::interactionSource,
stateProvider = this@SliderTrackSelectorModifierNode::pressState,
stateUpdater = this@SliderTrackSelectorModifierNode::pressState::set,
labelProvider = { TrackThumbInteractions.Thumb }
)
}
launch {
detectPressRelease(
onPress = { tapEvent ->
emitValueFromSliderLocalPosition(
sliderSize = scopeInfo.size,
sliderLocalOffset = tapEvent.position
)
}
)
}
}
}
}
private var dragState: DragInteraction.Start? = null
private var pressState: PressInteraction.Press? = null
private fun emitValueFromSliderLocalPosition(
sliderSize: IntSize,
sliderLocalOffset: FloatOffset
) {
val thumbSize = thumbSizeProvider()
val direction = direction
val thumbPadding = thumbPadding
val maxOffset: Float
val offset: Float
if (direction.isHorizontal) {
val sliderWidth = sliderSize.width.toFloat()
val sliderLocalX = sliderLocalOffset.x
val thumbWidth = thumbSize.width.toFloat()
val left = thumbPadding.calculateLeftPadding()
val right = thumbPadding.calculateRightPadding()
val startOffset = sliderLocalX - left - thumbWidth / 2
maxOffset = sliderWidth - left - thumbWidth - right
offset = if (direction == Right) startOffset else maxOffset - startOffset
} else {
val sliderHeight = sliderSize.height.toFloat()
val sliderLocalY = sliderLocalOffset.y
val thumbHeight = thumbSize.height.toFloat()
val top = thumbPadding.calculateTopPadding()
val bottom = thumbPadding.calculateBottomPadding()
val startOffset = sliderLocalY - top - thumbHeight / 2
maxOffset = sliderHeight - top - thumbHeight - bottom
offset = if (direction == Up) maxOffset - startOffset else startOffset
}
emitValueFromOffset(
maxOffset = maxOffset,
offset = offset
)
}
protected abstract fun emitValueFromOffset(
maxOffset: Float,
offset: Float
)
override fun onPointerEvent(pass: PointerEventPass, pointerEvent: PointerEvent) {
pointerInputHandler.onPointerEvent(pass, pointerEvent)
}
}
// ---- Slider Track Pointer Icon Modifier ----
private fun Modifier.sliderTrackPointerIcon(direction: LayoutDirection): Modifier {
val pointerIcon = if (direction.isHorizontal) {
PointerIcon.MoveHorizontally
} else {
PointerIcon.MoveVertically
}
return pointerIconOnHover(pointerIcon)
}
// ---- Slider Thumb Offset Modifier ----
private fun Modifier.sliderThumbOffset(
offsetProvider: () -> Int,
direction: LayoutDirection
): Modifier {
val element = SliderThumbOffsetModifier(
offsetProvider = offsetProvider,
direction = direction
)
return this then element
}
private data class SliderThumbOffsetModifier(
val offsetProvider: () -> Int,
val direction: LayoutDirection
) : ModifierNodeElement() {
override fun create(): SliderThumbOffsetModifierNode {
return SliderThumbOffsetModifierNode(
offsetProvider = offsetProvider,
direction = direction
)
}
override fun update(node: SliderThumbOffsetModifierNode) {
node.offsetProvider = offsetProvider
node.direction = direction
}
override fun InspectInfo.inspect() {
set("direction", direction)
}
}
private class SliderThumbOffsetModifierNode(
var offsetProvider: () -> Int,
var direction: LayoutDirection
) : ModifierNode(), LayoutModifierNode {
override fun measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val placeable = measurable.measure(constraints)
return MeasureResult(placeable.width, placeable.height) {
val offset = offsetProvider()
val position = when (direction) {
Right -> IntOffset(offset, 0)
Up -> IntOffset(0, -offset)
Down -> IntOffset(0, offset)
Left -> IntOffset(-offset, 0)
}
placeable.place(position)
}
}
}
// ---- Thumb Sizer Modifier ----
internal fun Modifier.thumbSizer(
thumbSizeUpdater: (IntSize) -> Unit,
sizer: ThumbSizer
): Modifier {
val element = ThumbSizerModifier(
thumbSizeUpdater = thumbSizeUpdater,
sizer = sizer
)
return this then element
}
private data class ThumbSizerModifier(
val thumbSizeUpdater: (IntSize) -> Unit,
val sizer: ThumbSizer
) : ModifierNodeElement() {
override fun create(): ThumbSizerModifierNode {
return ThumbSizerModifierNode(
thumbSizeUpdater = thumbSizeUpdater,
sizer = sizer
)
}
override fun update(node: ThumbSizerModifierNode) {
node.sizer = sizer
}
override fun InspectInfo.inspect() {
set("sizer", sizer)
}
}
private class ThumbSizerModifierNode(
val thumbSizeUpdater: (IntSize) -> Unit,
var sizer: ThumbSizer
) : ModifierNode(),
LayoutModifierNode,
LayoutCallbackModifierNode
{
override fun measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val availableSpace = IntSize(constraints.maxWidth, constraints.maxHeight)
val thumbSize = sizer.calculateThumbSize(availableSpace)
val contentConstraints = Constraints.fixed(thumbSize)
val placeable = measurable.measure(contentConstraints)
return MeasureResult(placeable.width, placeable.height) {
placeable.place(IntOffset.Zero)
}
}
override fun onSizeChanged(size: IntSize) {
thumbSizeUpdater(size)
}
}
// ---- Slider Thumb Key Selector Modifier ----
private sealed class SliderThumbKeySelectorModifierNode(
var direction: LayoutDirection
) : ModifierNode(), KeyInputModifierNode {
override fun onKeyEvent(pass: KeyEventPass, keyEvent: KeyEvent): Boolean {
if (pass != Out || keyEvent.isReleased) {
return false
}
return when (keyEvent.key) {
Key.Right -> {
when (direction) {
Right -> next(keyEvent.modifier)
Left -> previous(keyEvent.modifier)
else -> false
}
}
Key.Left -> {
when (direction) {
Right -> previous(keyEvent.modifier)
Left -> next(keyEvent.modifier)
else -> false
}
}
Key.Up -> {
when (direction) {
Up -> next(keyEvent.modifier)
Down -> previous(keyEvent.modifier)
else -> false
}
}
Key.Down -> {
when (direction) {
Up -> previous(keyEvent.modifier)
Down -> next(keyEvent.modifier)
else -> false
}
}
Key.Home -> toStart()
Key.End -> toEnd()
else -> false
}
}
protected abstract fun next(modifier: KeyboardModifier): Boolean
protected abstract fun previous(modifier: KeyboardModifier): Boolean
protected abstract fun toStart(): Boolean
protected abstract fun toEnd(): Boolean
}
// ---- Max Drag Offset ----
internal fun calculateMaxDragOffset(
thumbInfo: LayoutInfo,
direction: LayoutDirection,
thumbPadding: PaddingValues
): Int {
return if (direction.isHorizontal) {
val left = thumbPadding.calculateLeftPadding()
val right = thumbPadding.calculateRightPadding()
require(left >= 0 && right >= 0) {
"Cannot have negative padding, but found $thumbPadding"
}
val sliderWidth = thumbInfo.parentInfo!!.size.width
val thumbWidth = thumbInfo.size.width
sliderWidth - left - thumbWidth - right
} else {
val top = thumbPadding.calculateTopPadding()
val bottom = thumbPadding.calculateBottomPadding()
require(top >= 0 && bottom >= 0) {
"Cannot have negative padding, but found $thumbPadding"
}
val sliderHeight = thumbInfo.parentInfo!!.size.height
val thumbHeight = thumbInfo.size.height
sliderHeight - top - thumbHeight - bottom
}
}
// -------- Continuous Slider --------
@Composable
private fun ContinuousSlider(
value: Float,
onValueChanged: (Float) -> Unit,
range: ClosedFloatingPointRange,
direction: LayoutDirection,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
thumbStyle: SliderThumbStyle,
thumbPadding: PaddingValues,
thumbSizer: ThumbSizer,
trackStyle: SliderTrackStyle,
trackContentPadding: PaddingValues,
trackContentArrangement: Arrangement.Horizontal,
trackContent: (@Composable RowScope.(value: Float) -> Unit)?,
modifier: Modifier
) {
Box(
modifier = modifier.sliderDefaultSize(direction),
propagateMinConstraints = true
) {
val dragState = rememberAxisDragState().apply {
updateAxisDirection(AxisDirections.getValue(direction))
}
val thumbSizeRef = remember { ref(IntSize.Zero) }
val thumbSizeProvider = remember { thumbSizeRef::value }
val thumbSizeUpdater = remember { thumbSizeRef::value::set }
val valueState = rememberUpdatedState(value)
val valueProvider = remember { valueState::value }
val rangeStart = range.start
val rangeLength = (range.endInclusive - rangeStart).coerceAtLeast(0.0f)
val fraction = if (rangeLength != 0.0f) {
val fraction = (value - rangeStart) / rangeLength
fraction.coerceIn(0.0f, 1.0f)
} else {
0.0f
}
val fractionState = rememberUpdatedState(fraction)
val fractionProvider = remember { fractionState::value }
LaunchedEffect(Unit) {
snapshotFlow { fractionProvider() }.collect { fraction ->
dragState.offset = dragState.maxOffset * fraction
}
}
ContinuousSliderTrack(
thumbSizeProvider = thumbSizeProvider,
valueProvider = valueProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
fractionProvider = fractionProvider,
direction = direction,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbPadding = thumbPadding,
trackStyle = trackStyle,
trackContentPadding = trackContentPadding,
trackContentArrangement = trackContentArrangement,
trackContent = trackContent,
modifier = Modifier
)
ContinuousSliderThumb(
dragState = dragState,
thumbSizeUpdater = thumbSizeUpdater,
valueProvider = valueProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
fractionProvider = fractionProvider,
direction = direction,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbStyle = thumbStyle,
thumbPadding = thumbPadding,
thumbSizer = thumbSizer,
modifier = Modifier
)
}
}
@Composable
private fun BoxScope.ContinuousSliderTrack(
thumbSizeProvider: () -> IntSize,
valueProvider: () -> Float,
onValueChanged: (Float) -> Unit,
rangeStart: Float,
rangeLength: Float,
fractionProvider: () -> Float,
direction: LayoutDirection,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
thumbPadding: PaddingValues,
trackStyle: SliderTrackStyle,
trackContentPadding: PaddingValues,
trackContentArrangement: Arrangement.Horizontal,
trackContent: (@Composable RowScope.(value: Float) -> Unit)?,
modifier: Modifier
) {
BaseSliderTrack(
valueProvider = valueProvider,
fractionProvider = fractionProvider,
isEnabled = isEnabled,
interactionSource = interactionSource,
trackStyle = trackStyle,
trackContentPadding = trackContentPadding,
trackContentArrangement = trackContentArrangement,
trackContent = trackContent,
modifier = modifier
.matchParentSize()
.conditional(isEnabled) {
this
.continuousSliderTrackSelector(
thumbSizeProvider = thumbSizeProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
)
.sliderTrackPointerIcon(direction)
.hoverable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Thumb
)
.focusable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Track
)
}
)
}
@Composable
private fun BoxScope.ContinuousSliderThumb(
dragState: AxisDragState,
thumbSizeUpdater: (IntSize) -> Unit,
valueProvider: () -> Float,
onValueChanged: (Float) -> Unit,
rangeStart: Float,
rangeLength: Float,
fractionProvider: () -> Float,
direction: LayoutDirection,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
thumbStyle: SliderThumbStyle,
thumbPadding: PaddingValues,
thumbSizer: ThumbSizer,
modifier: Modifier
) {
BaseSliderThumb(
fractionProvider = fractionProvider,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbStyle = thumbStyle,
modifier = modifier
.matchParentSize()
.wrapContentSize(WrapContentAlignments.getValue(direction))
.padding(thumbPadding)
.sliderThumbOffset(
offsetProvider = dragState::roundedOffset,
direction = direction
)
.thumbSizer(
thumbSizeUpdater = thumbSizeUpdater,
sizer = thumbSizer
)
.continuousSliderMaxDragOffsetCalculator(
dragState = dragState,
direction = direction,
thumbPadding = thumbPadding
)
.conditional(isEnabled) {
this
.hoverable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Thumb
)
.continuousSliderThumbSelector(
valueProvider = valueProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
direction = direction
)
.focusable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Thumb
)
.visualIndication(
interactionSource = interactionSource,
indication = LocalVisualIndication.current,
label = TrackThumbInteractions.Thumb
)
}
)
}
// ---- Continuous Slider Track Selector Modifier ----
private fun Modifier.continuousSliderTrackSelector(
thumbSizeProvider: () -> IntSize,
onValueChanged: (Float) -> Unit,
rangeStart: Float,
rangeLength: Float,
direction: LayoutDirection,
interactionSource: MutableInteractionSource,
thumbPadding: PaddingValues
): Modifier {
val element = ContinuousSliderTrackSelectorModifier(
thumbSizeProvider = thumbSizeProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
)
return this then element
}
private data class ContinuousSliderTrackSelectorModifier(
val thumbSizeProvider: () -> IntSize,
val onValueChanged: (Float) -> Unit,
val rangeStart: Float,
val rangeLength: Float,
val direction: LayoutDirection,
val interactionSource: MutableInteractionSource,
val thumbPadding: PaddingValues
) : ModifierNodeElement() {
override fun create(): ContinuousSliderTrackSelectorModifierNode {
return ContinuousSliderTrackSelectorModifierNode(
thumbSizeProvider = thumbSizeProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
)
}
override fun update(node: ContinuousSliderTrackSelectorModifierNode) {
node.onValueChanged = onValueChanged
node.rangeStart = rangeStart
node.rangeLength = rangeLength
node.direction = direction
node.interactionSource = interactionSource
node.thumbPadding = thumbPadding
}
override fun InspectInfo.inspect() {
set("rangeStart", rangeStart)
set("rangeLength", rangeLength)
set("direction", direction)
set("thumbPadding", thumbPadding)
}
}
private class ContinuousSliderTrackSelectorModifierNode(
thumbSizeProvider: () -> IntSize,
var onValueChanged: (Float) -> Unit,
var rangeStart: Float,
var rangeLength: Float,
direction: LayoutDirection,
interactionSource: MutableInteractionSource,
thumbPadding: PaddingValues
) : SliderTrackSelectorModifierNode(
thumbSizeProvider = thumbSizeProvider,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
) {
override fun emitValueFromOffset(
maxOffset: Float,
offset: Float
) {
val fraction = offset / maxOffset
val coercedFraction = fraction.coerceIn(0.0f, 1.0f)
val value = rangeStart + rangeLength * coercedFraction
onValueChanged(value)
}
}
// ---- Continuous Slider Max Drag Offset Calculator Modifier ----
private fun Modifier.continuousSliderMaxDragOffsetCalculator(
dragState: AxisDragState,
direction: LayoutDirection,
thumbPadding: PaddingValues
): Modifier {
val element = ContinuousSliderMaxDragOffsetCalculatorModifier(
dragState = dragState,
direction = direction,
thumbPadding = thumbPadding
)
return this then element
}
private data class ContinuousSliderMaxDragOffsetCalculatorModifier(
val dragState: AxisDragState,
val direction: LayoutDirection,
val thumbPadding: PaddingValues
) : ModifierNodeElement() {
override fun create(): ContinuousSliderMaxDragOffsetCalculatorModifierNode {
return ContinuousSliderMaxDragOffsetCalculatorModifierNode(
dragState = dragState,
direction = direction,
thumbPadding = thumbPadding
)
}
override fun update(node: ContinuousSliderMaxDragOffsetCalculatorModifierNode) {
node.direction = direction
node.thumbPadding = thumbPadding
}
override fun InspectInfo.inspect() {
set("direction", direction)
set("thumbPadding", thumbPadding)
}
}
private class ContinuousSliderMaxDragOffsetCalculatorModifierNode(
val dragState: AxisDragState,
direction: LayoutDirection,
thumbPadding: PaddingValues
) : ModifierNode(), LayoutCallbackModifierNode {
var direction: LayoutDirection = direction
set(value) {
if (field == value) {
return
}
field = value
shouldResetDragDistance = true
}
var thumbPadding: PaddingValues = thumbPadding
set(value) {
if (field == value) {
return
}
field = value
shouldResetDragDistance = true
}
private var shouldResetDragDistance: Boolean = true
override fun onSizeChanged(size: IntSize) {
shouldResetDragDistance = true
}
override fun onPlaced(info: LayoutInfo) {
if (shouldResetDragDistance) {
shouldResetDragDistance = false
resetDragDistance(info)
}
}
private fun resetDragDistance(info: LayoutInfo) {
val intMaxOffset = calculateMaxDragOffset(
thumbInfo = info,
direction = direction,
thumbPadding = thumbPadding
)
dragState.maxOffset = intMaxOffset.toFloat()
}
}
// ---- Continuous Slider Thumb Selector Modifier ----
private fun Modifier.continuousSliderThumbSelector(
valueProvider: () -> Float,
onValueChanged: (Float) -> Unit,
rangeStart: Float,
rangeLength: Float,
direction: LayoutDirection
): Modifier {
val element = ContinuousSliderThumbSelectorModifier(
valueProvider = valueProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
direction = direction
)
return this then element
}
private data class ContinuousSliderThumbSelectorModifier(
val valueProvider: () -> Float,
val onValueChanged: (Float) -> Unit,
val rangeStart: Float,
val rangeLength: Float,
val direction: LayoutDirection
) : ModifierNodeElement() {
override fun create(): ContinuousSliderThumbSelectorModifierNode {
return ContinuousSliderThumbSelectorModifierNode(
valueProvider = valueProvider,
onValueChanged = onValueChanged,
rangeStart = rangeStart,
rangeLength = rangeLength,
direction = direction
)
}
override fun update(node: ContinuousSliderThumbSelectorModifierNode) {
node.onValueChanged = onValueChanged
node.rangeStart = rangeStart
node.rangeLength = rangeLength
node.direction = direction
}
override fun InspectInfo.inspect() {
set("rangeStart", rangeStart)
set("rangeLength", rangeLength)
set("direction", direction)
}
}
private class ContinuousSliderThumbSelectorModifierNode(
val valueProvider: () -> Float,
var onValueChanged: (Float) -> Unit,
var rangeStart: Float,
var rangeLength: Float,
direction: LayoutDirection
) : SliderThumbKeySelectorModifierNode(direction) {
override fun next(modifier: KeyboardModifier): Boolean {
val value = valueProvider()
val rangeEnd = rangeStart + rangeLength
if (value >= rangeEnd) {
return false
}
val valueStep = calculatePseudoValueStep(
rangeLength = rangeLength,
modifier = modifier
)
val next = (value + valueStep).coerceAtMost(rangeEnd)
onValueChanged(next)
return true
}
override fun previous(modifier: KeyboardModifier): Boolean {
val value = valueProvider()
val rangeStart = rangeStart
if (value <= rangeStart) {
return false
}
val valueStep = calculatePseudoValueStep(
rangeLength = rangeLength,
modifier = modifier
)
val previous = (value - valueStep).coerceAtLeast(rangeStart)
onValueChanged(previous)
return true
}
override fun toStart(): Boolean {
onValueChanged(rangeStart)
return true
}
override fun toEnd(): Boolean {
onValueChanged(rangeStart + rangeLength)
return true
}
}
private fun calculatePseudoValueStep(
rangeLength: Float,
modifier: KeyboardModifier
): Float {
fun Float.getBaseValueStep(): Float {
return when {
this <= 0.1f -> 0.001f
this <= 1.0f -> 0.01f
this <= 10.0f -> 0.1f
this <= 100.0f -> 1.0f
this <= 1000.0f -> 10.0f
else -> 100.0f
}
}
fun KeyboardModifier.getValueStepModifier(): Float {
val isShiftPressed = isShiftPressed
val isCtrlPressed = isCtrlPressed
return when {
isShiftPressed && isCtrlPressed -> 1.0f
isShiftPressed -> 5.0f
isCtrlPressed -> 0.2f
else -> 1.0f
}
}
val baseValueStep = rangeLength.getBaseValueStep()
val valueStepModifier = modifier.getValueStepModifier()
return baseValueStep * valueStepModifier
}
// -------- Discrete Slider --------
@Composable
private fun DiscreteSlider(
value: T,
onValueChanged: (T) -> Unit,
values: List,
direction: LayoutDirection,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
thumbStyle: SliderThumbStyle,
thumbPadding: PaddingValues,
thumbSizer: ThumbSizer,
trackStyle: SliderTrackStyle,
trackContentPadding: PaddingValues,
trackContentArrangement: Arrangement.Horizontal,
trackContent: (@Composable RowScope.(value: T) -> Unit)?,
modifier: Modifier
) {
Box(
modifier = modifier.sliderDefaultSize(direction),
propagateMinConstraints = true
) {
val dragState = rememberAxisAnchoredDragState(value).apply {
updateCandidatePredicate { candidate ->
onValueChanged(candidate)
true
}
updateAxisDirection(AxisDirections.getValue(direction))
updateThresholds(DefaultThresholds)
}
val thumbSizeRef = remember { ref(IntSize.Zero) }
val thumbSizeProvider = remember { thumbSizeRef::value }
val thumbSizeUpdater = remember { thumbSizeRef::value::set }
val valueState = rememberUpdatedState(value)
val valueProvider = remember { valueState::value }
val fractionProvider = remember { dragState::fraction }
LaunchedEffect(value) {
if (value != dragState.targetValue) {
dragState.animateTo(value)
}
}
DiscreteSliderTrack(
dragState = dragState,
thumbSizeProvider = thumbSizeProvider,
valueProvider = valueProvider,
onValueChanged = onValueChanged,
fractionProvider = fractionProvider,
direction = direction,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbPadding = thumbPadding,
trackStyle = trackStyle,
trackContentPadding = trackContentPadding,
trackContentArrangement = trackContentArrangement,
trackContent = trackContent,
modifier = Modifier
)
DiscreteSliderThumb(
dragState = dragState,
thumbSizeUpdater = thumbSizeUpdater,
valueProvider = valueProvider,
onValueChanged = onValueChanged,
values = values,
fractionProvider = fractionProvider,
direction = direction,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbStyle = thumbStyle,
thumbPadding = thumbPadding,
thumbSizer = thumbSizer,
modifier = Modifier
)
}
}
@Composable
private fun BoxScope.DiscreteSliderTrack(
dragState: AxisAnchoredDragState,
thumbSizeProvider: () -> IntSize,
valueProvider: () -> T,
onValueChanged: (T) -> Unit,
fractionProvider: () -> Float,
direction: LayoutDirection,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
thumbPadding: PaddingValues,
trackStyle: SliderTrackStyle,
trackContentPadding: PaddingValues,
trackContentArrangement: Arrangement.Horizontal,
trackContent: (@Composable RowScope.(value: T) -> Unit)?,
modifier: Modifier
) {
BaseSliderTrack(
valueProvider = valueProvider,
fractionProvider = fractionProvider,
isEnabled = isEnabled,
interactionSource = interactionSource,
trackStyle = trackStyle,
trackContentPadding = trackContentPadding,
trackContentArrangement = trackContentArrangement,
trackContent = trackContent,
modifier = modifier
.matchParentSize()
.conditional(isEnabled) {
this
.discreteSliderTrackSelector(
dragState = dragState,
thumbSizeProvider = thumbSizeProvider,
onValueChanged = onValueChanged,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
)
.sliderTrackPointerIcon(direction)
.hoverable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Thumb
)
.focusable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Track
)
}
)
}
@Composable
private fun BoxScope.DiscreteSliderThumb(
dragState: AxisAnchoredDragState,
thumbSizeUpdater: (IntSize) -> Unit,
valueProvider: () -> T,
onValueChanged: (T) -> Unit,
values: List,
fractionProvider: () -> Float,
direction: LayoutDirection,
isEnabled: Boolean,
interactionSource: MutableInteractionSource,
thumbStyle: SliderThumbStyle,
thumbPadding: PaddingValues,
thumbSizer: ThumbSizer,
modifier: Modifier
) {
BaseSliderThumb(
fractionProvider = fractionProvider,
isEnabled = isEnabled,
interactionSource = interactionSource,
thumbStyle = thumbStyle,
modifier = modifier
.matchParentSize()
.wrapContentSize(WrapContentAlignments.getValue(direction))
.padding(thumbPadding)
.sliderThumbOffset(
offsetProvider = dragState::roundedOffset,
direction = direction
)
.thumbSizer(
thumbSizeUpdater = thumbSizeUpdater,
sizer = thumbSizer
)
.discreteSliderAnchorsCalculator(
dragState = dragState,
values = values,
direction = direction,
thumbPadding = thumbPadding
)
.conditional(isEnabled) {
this
.hoverable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Thumb
)
.axisAnchoredDraggable(
state = dragState,
interactionSource = interactionSource,
label = TrackThumbInteractions.Thumb
)
.discreteSliderThumbSelector(
valueProvider = valueProvider,
onValueChanged = onValueChanged,
values = values,
direction = direction
)
.focusable(
interactionSource = interactionSource,
label = TrackThumbInteractions.Thumb
)
.visualIndication(
interactionSource = interactionSource,
indication = LocalVisualIndication.current,
label = TrackThumbInteractions.Thumb
)
}
)
}
// ---- Discrete Slider Track Selector Modifier ----
private fun Modifier.discreteSliderTrackSelector(
dragState: AxisAnchoredDragState,
thumbSizeProvider: () -> IntSize,
onValueChanged: (T) -> Unit,
direction: LayoutDirection,
interactionSource: MutableInteractionSource,
thumbPadding: PaddingValues
): Modifier {
val element = DiscreteSliderTrackSelectorModifier(
dragState = dragState,
thumbSizeProvider = thumbSizeProvider,
onValueChanged = onValueChanged,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
)
return this then element
}
private data class DiscreteSliderTrackSelectorModifier(
val dragState: AxisAnchoredDragState,
val thumbSizeProvider: () -> IntSize,
val onValueChanged: (T) -> Unit,
val direction: LayoutDirection,
val interactionSource: MutableInteractionSource,
val thumbPadding: PaddingValues
) : ModifierNodeElement>() {
override fun create(): DiscreteSliderTrackSelectorModifierNode {
return DiscreteSliderTrackSelectorModifierNode(
dragState = dragState,
thumbSizeProvider = thumbSizeProvider,
onValueChanged = onValueChanged,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
)
}
override fun update(node: DiscreteSliderTrackSelectorModifierNode) {
node.onValueChanged = onValueChanged
node.direction = direction
node.interactionSource = interactionSource
node.thumbPadding = thumbPadding
}
override fun InspectInfo.inspect() {
set("direction", direction)
set("thumbPadding", thumbPadding)
}
}
private class DiscreteSliderTrackSelectorModifierNode(
val dragState: AxisAnchoredDragState,
thumbSizeProvider: () -> IntSize,
var onValueChanged: (T) -> Unit,
direction: LayoutDirection,
interactionSource: MutableInteractionSource,
thumbPadding: PaddingValues
) : SliderTrackSelectorModifierNode(
thumbSizeProvider = thumbSizeProvider,
direction = direction,
interactionSource = interactionSource,
thumbPadding = thumbPadding
) {
override fun emitValueFromOffset(
maxOffset: Float,
offset: Float
) {
val anchors = dragState.anchors
val coercedOffset = anchors.keys.minBy { anchorOffset ->
abs(anchorOffset - offset)
}
val value = anchors.getValue(coercedOffset)
onValueChanged(value)
}
}
// ---- Discrete Slider Anchors Calculator Modifier ----
private const val AnimatableAnchorsSpacing: Float = 10.0f
private val ThumbSnapSpec: FiniteAnimationSpec = SnapSpec()
private fun Modifier.discreteSliderAnchorsCalculator(
dragState: AxisAnchoredDragState,
values: List,
direction: LayoutDirection,
thumbPadding: PaddingValues
): Modifier {
val element = DiscreteSliderAnchorsCalculatorModifier(
dragState = dragState,
values = values,
direction = direction,
thumbPadding = thumbPadding
)
return this then element
}
private data class DiscreteSliderAnchorsCalculatorModifier(
val dragState: AxisAnchoredDragState,
val values: List,
val direction: LayoutDirection,
val thumbPadding: PaddingValues
) : ModifierNodeElement>() {
override fun create(): DiscreteSliderAnchorsCalculatorModifierNode {
return DiscreteSliderAnchorsCalculatorModifierNode(
dragState = dragState,
values = values,
direction = direction,
thumbPadding = thumbPadding
)
}
override fun update(node: DiscreteSliderAnchorsCalculatorModifierNode) {
node.values = values
node.direction = direction
node.thumbPadding = thumbPadding
}
override fun InspectInfo.inspect() {
set("values", values)
set("direction", direction)
set("thumbPadding", thumbPadding)
}
}
private class DiscreteSliderAnchorsCalculatorModifierNode(
val dragState: AxisAnchoredDragState,
values: List,
direction: LayoutDirection,
thumbPadding: PaddingValues,
) : ModifierNode(), LayoutCallbackModifierNode {
var values: List = values
set(value) {
if (field == value) {
return
}
field = value
shouldResetAnchors = true
}
var direction: LayoutDirection = direction
set(value) {
if (field == value) {
return
}
field = value
shouldResetAnchors = true
}
var thumbPadding: PaddingValues = thumbPadding
set(value) {
if (field == value) {
return
}
field = value
shouldResetAnchors = true
}
private var shouldResetAnchors: Boolean = true
override fun onSizeChanged(size: IntSize) {
shouldResetAnchors = true
}
override fun onPlaced(info: LayoutInfo) {
if (shouldResetAnchors) {
shouldResetAnchors = false
resetAnchors(info)
}
}
private fun resetAnchors(info: LayoutInfo) {
// AxisAnchoredDragState requires actual dragging distance on
// created, but we can't know this before all the following
// values are determined, so we do a callback to set it properly
val values = values
val offsetStep: Float
val anchors: AxisAnchors
if (values.size > 1) {
val spacingCount = values.size - 1
val intMaxOffset = calculateMaxDragOffset(
thumbInfo = info,
direction = direction,
thumbPadding = thumbPadding
)
val maxOffset = intMaxOffset.toFloat()
val maxSpacingCount = (maxOffset / MinimumAnchorsSpacing).toInt()
val coercedSpacingCount = spacingCount.coerceAtMost(maxSpacingCount)
offsetStep = maxOffset / coercedSpacingCount
anchors = buildMap(coercedSpacingCount + 1) {
var currentOffset = 0.0f
repeat(coercedSpacingCount + 1) { index ->
put(currentOffset, values[index]) // TODO Skip certain values if too many
currentOffset += offsetStep
}
}
} else {
offsetStep = Float.POSITIVE_INFINITY
anchors = mapOf(0.0f to values.first())
}
nodeScope.launch {
val animationSpec = if (offsetStep >= AnimatableAnchorsSpacing) {
DefaultFloatAnimationSpec
} else {
// Animation won't work properly if anchors are too close to each other,
// as it will be frequently interrupted by user's gestures, which then
// causes the thumb to stand still instead of moving towards the target
ThumbSnapSpec
}
with(dragState) {
updateAnchors(anchors)
updateDefaultAnimationSpec(animationSpec)
}
}
}
}
// ---- Discrete Slider Thumb Selector Modifier ----
private fun Modifier.discreteSliderThumbSelector(
valueProvider: () -> T,
onValueChanged: (T) -> Unit,
values: List,
direction: LayoutDirection
): Modifier {
val element = DiscreteSliderThumbSelectorModifier(
valueProvider = valueProvider,
onValueChanged = onValueChanged,
values = values,
direction = direction
)
return this then element
}
private data class DiscreteSliderThumbSelectorModifier(
val valueProvider: () -> T,
val onValueChanged: (T) -> Unit,
val values: List,
val direction: LayoutDirection
) : ModifierNodeElement>() {
override fun create(): DiscreteSliderThumbSelectorModifierNode {
return DiscreteSliderThumbSelectorModifierNode(
valueProvider = valueProvider,
onValueChanged = onValueChanged,
values = values,
direction = direction
)
}
override fun update(node: DiscreteSliderThumbSelectorModifierNode) {
node.onValueChanged = onValueChanged
node.values = values
node.direction = direction
}
override fun InspectInfo.inspect() {
set("values", values)
set("direction", direction)
}
}
private class DiscreteSliderThumbSelectorModifierNode(
val valueProvider: () -> T,
var onValueChanged: (T) -> Unit,
var values: List,
direction: LayoutDirection
) : SliderThumbKeySelectorModifierNode(direction) {
override fun next(modifier: KeyboardModifier): Boolean {
val value = valueProvider()
val values = values
val currentIndex = values.indexOf(value)
if (currentIndex == values.lastIndex) {
return false
}
val next = values[currentIndex + 1]
onValueChanged(next)
return true
}
override fun previous(modifier: KeyboardModifier): Boolean {
val value = valueProvider()
val values = values
val currentIndex = values.indexOf(value)
if (currentIndex == 0) {
return false
}
val previous = values[currentIndex - 1]
onValueChanged(previous)
return true
}
override fun toStart(): Boolean {
onValueChanged(values.first())
return true
}
override fun toEnd(): Boolean {
onValueChanged(values.last())
return true
}
}