diff --git a/docs/components/slider.md b/docs/components/slider.md index 95068e73..84f97ce2 100644 --- a/docs/components/slider.md +++ b/docs/components/slider.md @@ -2,7 +2,9 @@ `Slider` is a basic interactive component in Miuix used for selecting values within a continuous range. Users can adjust values by dragging the slider, making it suitable for scenarios such as volume adjustment, brightness control, and progress display. -
+Miuix also provides `VerticalSlider` for vertical orientation and `RangeSlider` for selecting a range of values. + +
@@ -10,16 +12,43 @@ ```kotlin import top.yukonga.miuix.kmp.basic.Slider +import top.yukonga.miuix.kmp.basic.VerticalSlider +import top.yukonga.miuix.kmp.basic.RangeSlider ``` ## Basic Usage +### Slider + ```kotlin -var sliderValue by remember { mutableStateOf(0.5f) } +var sliderValue by remember { mutableFloatStateOf(0.5f) } Slider( - progress = sliderValue, - onProgressChange = { sliderValue = it } + value = sliderValue, + onValueChange = { sliderValue = it } +) +``` + +### VerticalSlider + +```kotlin +var sliderValue by remember { mutableFloatStateOf(0.5f) } + +VerticalSlider( + value = sliderValue, + onValueChange = { sliderValue = it }, + modifier = Modifier.height(200.dp) +) +``` + +### RangeSlider + +```kotlin +var rangeValue by remember { mutableStateOf(0.2f..0.8f) } + +RangeSlider( + value = rangeValue, + onValueChange = { rangeValue = it } ) ``` @@ -28,11 +57,11 @@ Slider( ### Disabled State ```kotlin -var progress by remember { mutableStateOf(0.5f) } +var value by remember { mutableFloatStateOf(0.5f) } Slider( - progress = progress, - onProgressChange = { progress = it }, + value = value, + onValueChange = { value = it }, enabled = false ) ``` @@ -42,11 +71,11 @@ Slider( Slider supports haptic feedback, which can be customized through the `hapticEffect` parameter. See [SliderHapticEffect](../components/slider#sliderhapticeffect) for details. ```kotlin -var progress by remember { mutableStateOf(0.5f) } +var value by remember { mutableFloatStateOf(0.5f) } Slider( - progress = progress, - onProgressChange = { progress = it }, + value = value, + onValueChange = { value = it }, hapticEffect = SliderHapticEffect.Step ) ``` @@ -55,19 +84,62 @@ Slider( ### Slider Properties -| Property Name | Type | Description | Default Value | Required | -| ---------------- | --------------------------------- | -------------------------------------- | ---------------------------------- | -------- | -| progress | Float | Current slider progress value | - | Yes | -| onProgressChange | (Float) -> Unit | Callback when progress value changes | - | Yes | -| modifier | Modifier | Modifier applied to the slider | Modifier | No | -| enabled | Boolean | Whether the slider is enabled | true | No | -| minValue | Float | Minimum value of the slider | 0f | No | -| maxValue | Float | Maximum value of the slider | 1f | No | -| height | Dp | Height of the slider | SliderDefaults.MinHeight | No | -| colors | SliderColors | Color configuration of the slider | SliderDefaults.sliderColors() | No | -| effect | Boolean | Whether to show special effects | false | No | -| decimalPlaces | Int | Decimal places shown in drag indicator | 2 | No | -| hapticEffect | SliderDefaults.SliderHapticEffect | Type of haptic feedback | SliderDefaults.DefaultHapticEffect | No | +| Property Name | Type | Description | Default Value | Required | +| --------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | -------- | +| value | Float | Current value of the slider. If outside of valueRange provided, value will be coerced to this range | - | Yes | +| onValueChange | (Float) -> Unit | Callback when value changes | - | Yes | +| modifier | Modifier | Modifier applied to the slider | Modifier | No | +| enabled | Boolean | Whether the slider is enabled | true | No | +| valueRange | ClosedFloatingPointRange\ | Range of values that this slider can take. The passed value will be coerced to this range | 0f..1f | No | +| steps | Int | Amount of discrete allowable values. If 0, the slider will behave continuously. Must not be negative | 0 | No | +| onValueChangeFinished | (() -> Unit)? | Called when value change has ended | null | No | +| reverseDirection | Boolean | Controls the direction of slider. When false, increases left to right. When true, increases right to left (RTL) | false | No | +| height | Dp | Height of the slider | SliderDefaults.MinHeight | No | +| colors | SliderColors | Color configuration of the slider | SliderDefaults.sliderColors() | No | +| effect | Boolean | Whether to show special effects | false | No | +| hapticEffect | SliderDefaults.SliderHapticEffect | Type of haptic feedback | SliderDefaults.DefaultHapticEffect | No | +| showKeyPoints | Boolean | Whether to show key point indicators on the slider. Only works when keyPoints is not null | false | No | +| keyPoints | List\? | Custom key point values to display on the slider. If null, uses step positions from steps parameter. Values should be within valueRange | null | No | +| magnetThreshold | Float | Magnetic snap threshold as a fraction (0.0 to 1.0). When slider value is within this distance from a key point, it will snap to that point. Only applies when keyPoints is set | 0.02f | No | + +### VerticalSlider Properties + +| Property Name | Type | Description | Default Value | Required | +| --------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | -------- | +| value | Float | Current value of the slider | - | Yes | +| onValueChange | (Float) -> Unit | Callback when value changes | - | Yes | +| modifier | Modifier | Modifier applied to the slider | Modifier | No | +| enabled | Boolean | Whether the slider is enabled | true | No | +| valueRange | ClosedFloatingPointRange\ | Range of values that this slider can take | 0f..1f | No | +| steps | Int | Amount of discrete allowable values | 0 | No | +| onValueChangeFinished | (() -> Unit)? | Called when value change has ended | null | No | +| reverseDirection | Boolean | Controls the direction of slider. When false, increases bottom to top. When true, increases top to bottom | false | No | +| width | Dp | Width of the vertical slider | SliderDefaults.MinHeight | No | +| colors | SliderColors | Color configuration of the slider | SliderDefaults.sliderColors() | No | +| effect | Boolean | Whether to show special effects | false | No | +| hapticEffect | SliderDefaults.SliderHapticEffect | Type of haptic feedback | SliderDefaults.DefaultHapticEffect | No | +| showKeyPoints | Boolean | Whether to show key point indicators on the slider. Only works when keyPoints is not null | false | No | +| keyPoints | List\? | Custom key point values to display on the slider. If null, uses step positions from steps parameter. Values should be within valueRange | null | No | +| magnetThreshold | Float | Magnetic snap threshold as a fraction (0.0 to 1.0). When slider value is within this distance from a key point, it will snap to that point. Only applies when keyPoints is set | 0.02f | No | + +### RangeSlider Properties + +| Property Name | Type | Description | Default Value | Required | +| --------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | -------- | +| value | ClosedFloatingPointRange\ | Current values of the RangeSlider. If either value is outside of valueRange, it will be coerced | - | Yes | +| onValueChange | (ClosedFloatingPointRange\) -> Unit | Lambda in which values should be updated | - | Yes | +| modifier | Modifier | Modifier applied to the slider | Modifier | No | +| enabled | Boolean | Whether the slider is enabled | true | No | +| valueRange | ClosedFloatingPointRange\ | Range of values that Range Slider values can take | 0f..1f | No | +| steps | Int | Amount of discrete allowable values | 0 | No | +| onValueChangeFinished | (() -> Unit)? | Called when value change has ended | null | No | +| height | Dp | Height of the slider | SliderDefaults.MinHeight | No | +| colors | SliderColors | Color configuration of the slider | SliderDefaults.sliderColors() | No | +| effect | Boolean | Whether to show special effects | false | No | +| hapticEffect | SliderDefaults.SliderHapticEffect | Type of haptic feedback | SliderDefaults.DefaultHapticEffect | No | +| showKeyPoints | Boolean | Whether to show key point indicators on the slider. Only works when keyPoints is not null | false | No | +| keyPoints | List\? | Custom key point values to display on the slider. If null, uses step positions from steps parameter. Values should be within valueRange | null | No | +| magnetThreshold | Float | Magnetic snap threshold as a fraction (0.0 to 1.0). When slider value is within this distance from a key point, it will snap to that point. Only applies when keyPoints is set | 0.02f | No | ### SliderDefaults Object @@ -82,11 +154,11 @@ The SliderDefaults object provides default configurations for the Slider compone ### SliderHapticEffect -| Value | Description | -| ----- | ------------------------------ | -| None | No haptic feedback | -| Edge | Haptic feedback at 0% and 100% | -| Step | Haptic feedback at each step | +| Value | Description | +| ----- | ---------------------------- | +| None | No haptic feedback | +| Edge | Haptic feedback at edges | +| Step | Haptic feedback at each step | #### Methods @@ -96,26 +168,42 @@ The SliderDefaults object provides default configurations for the Slider compone ### SliderColors Class -| Property Name | Type | Description | -| ----------------------- | ----- | ------------------------------ | -| foregroundColor | Color | Foreground color of the slider | -| disabledForegroundColor | Color | Foreground color when disabled | -| backgroundColor | Color | Background color of the slider | +| Property Name | Type | Description | +| ----------------------- | ----- | --------------------------------- | +| foregroundColor | Color | Foreground color of the slider | +| disabledForegroundColor | Color | Foreground color when disabled | +| backgroundColor | Color | Background color of the slider | +| keyPointColor | Color | Color of the key point indicators | ## Advanced Usage ### Custom Value Range ```kotlin -var temperature by remember { mutableStateOf(25f) } +var temperature by remember { mutableFloatStateOf(25f) } + +Column { + Text("Temperature: ${temperature.roundToInt()}°C") + Slider( + value = temperature, + onValueChange = { temperature = it }, + valueRange = 16f..32f + ) +} +``` + +### Discrete Steps + +```kotlin +var rating by remember { mutableFloatStateOf(3f) } Column { - Text("Temperature: $temperature°C") + Text("Rating: ${rating.roundToInt()}/5") Slider( - progress = temperature, - onProgressChange = { temperature = it }, - minValue = 16f, - maxValue = 32f + value = rating, + onValueChange = { rating = it }, + valueRange = 1f..5f, + steps = 3 // Creates 5 discrete values: 1, 2, 3, 4, 5 ) } ``` @@ -123,11 +211,11 @@ Column { ### Custom Colors ```kotlin -var volume by remember { mutableStateOf(0.7f) } +var volume by remember { mutableFloatStateOf(0.7f) } Slider( - progress = volume, - onProgressChange = { volume = it }, + value = volume, + onValueChange = { volume = it }, colors = SliderDefaults.sliderColors( foregroundColor = Color.Red, backgroundColor = Color.LightGray @@ -138,11 +226,11 @@ Slider( ### Custom Height and Effects ```kotlin -var brightness by remember { mutableStateOf(0.8f) } +var brightness by remember { mutableFloatStateOf(0.8f) } Slider( - progress = brightness, - onProgressChange = { brightness = it }, + value = brightness, + onValueChange = { brightness = it }, height = 40.dp, effect = true ) @@ -151,11 +239,96 @@ Slider( ### Slider with Haptic Feedback ```kotlin -var progress by remember { mutableStateOf(0.5f) } +var value by remember { mutableFloatStateOf(0.5f) } Slider( - progress = progress, - onProgressChange = { progress = it }, + value = value, + onValueChange = { value = it }, hapticEffect = SliderDefaults.SliderHapticEffect.Step ) ``` + +### VerticalSlider with Reverse Direction + +```kotlin +var volume by remember { mutableFloatStateOf(0.5f) } + +VerticalSlider( + value = volume, + onValueChange = { volume = it }, + modifier = Modifier.height(200.dp), + reverseDirection = true // Top to bottom +) +``` + +### RangeSlider for Price Filter + +```kotlin +var priceRange by remember { mutableStateOf(100f..500f) } + +Column { + Text("Price: $${priceRange.start.roundToInt()} - $${priceRange.endInclusive.roundToInt()}") + RangeSlider( + value = priceRange, + onValueChange = { priceRange = it }, + valueRange = 0f..1000f, + steps = 99 // 101 discrete values from 0 to 1000 + ) +} +``` + +### Complete Example with Value Change Callback + +```kotlin +var value by remember { mutableFloatStateOf(0.5f) } +var finalValue by remember { mutableFloatStateOf(0.5f) } + +Column { + Text("Current: $value") + Text("Final: $finalValue") + Slider( + value = value, + onValueChange = { value = it }, + onValueChangeFinished = { finalValue = value }, + valueRange = 0f..100f, + steps = 99 + ) +} +``` + +### Slider with Custom Key Points + +```kotlin +var value by remember { mutableFloatStateOf(50f) } + +Column { + Text("Value: ${value.roundToInt()}") + Slider( + value = value, + onValueChange = { value = it }, + valueRange = 0f..100f, + showKeyPoints = true, + keyPoints = listOf(0f, 25f, 50f, 75f, 100f), + magnetThreshold = 0.05f, // 5% snap threshold + hapticEffect = SliderDefaults.SliderHapticEffect.Step + ) +} +``` + +### RangeSlider with Key Points + +```kotlin +var range by remember { mutableStateOf(20f..80f) } + +Column { + Text("Range: ${range.start.roundToInt()} - ${range.endInclusive.roundToInt()}") + RangeSlider( + value = range, + onValueChange = { range = it }, + valueRange = 0f..100f, + showKeyPoints = true, + keyPoints = listOf(0f, 20f, 40f, 60f, 80f, 100f), + magnetThreshold = 0.03f + ) +} +``` diff --git a/docs/demo/src/commonMain/kotlin/SliderDemo.kt b/docs/demo/src/commonMain/kotlin/SliderDemo.kt index c5835ce3..60010fa4 100644 --- a/docs/demo/src/commonMain/kotlin/SliderDemo.kt +++ b/docs/demo/src/commonMain/kotlin/SliderDemo.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import top.yukonga.miuix.kmp.basic.RangeSlider import top.yukonga.miuix.kmp.basic.Slider @Composable @@ -39,15 +40,20 @@ fun SliderDemo() { ) { var slider1 by remember { mutableStateOf(0.5f) } var slider2 by remember { mutableStateOf(0.7f) } + var slider3 by remember { mutableStateOf(0.2f..0.8f) } Slider( - progress = slider1, - onProgressChange = { slider1 = it } + value = slider1, + onValueChange = { slider1 = it } ) Slider( - progress = slider2, - onProgressChange = { slider2 = it }, + value = slider2, + onValueChange = { slider2 = it }, enabled = false ) + RangeSlider( + value = slider3, + onValueChange = { slider3 = it } + ) } } } diff --git a/docs/zh_CN/components/slider.md b/docs/zh_CN/components/slider.md index 57193346..8026cfae 100644 --- a/docs/zh_CN/components/slider.md +++ b/docs/zh_CN/components/slider.md @@ -2,7 +2,9 @@ `Slider` 是 Miuix 中的基础交互组件,用于在连续的数值范围内进行选择。用户可以通过拖动滑块来调整值,适用于诸如音量调节、亮度控制、进度显示等场景。 -
+Miuix 还提供了 `VerticalSlider` 用于垂直方向的滑块,以及 `RangeSlider` 用于选择数值范围。 + +
@@ -10,16 +12,43 @@ ```kotlin import top.yukonga.miuix.kmp.basic.Slider +import top.yukonga.miuix.kmp.basic.VerticalSlider +import top.yukonga.miuix.kmp.basic.RangeSlider ``` ## 基本用法 +### Slider + ```kotlin -var sliderValue by remember { mutableStateOf(0.5f) } +var sliderValue by remember { mutableFloatStateOf(0.5f) } Slider( - progress = sliderValue, - onProgressChange = { sliderValue = it } + value = sliderValue, + onValueChange = { sliderValue = it } +) +``` + +### VerticalSlider + +```kotlin +var sliderValue by remember { mutableFloatStateOf(0.5f) } + +VerticalSlider( + value = sliderValue, + onValueChange = { sliderValue = it }, + modifier = Modifier.height(200.dp) +) +``` + +### RangeSlider + +```kotlin +var rangeValue by remember { mutableStateOf(0.2f..0.8f) } + +RangeSlider( + value = rangeValue, + onValueChange = { rangeValue = it } ) ``` @@ -28,11 +57,11 @@ Slider( ### 禁用状态 ```kotlin -var progress by remember { mutableStateOf(0.5f) } +var value by remember { mutableFloatStateOf(0.5f) } Slider( - progress = progress, - onProgressChange = { progress = it }, + value = value, + onValueChange = { value = it }, enabled = false ) ``` @@ -42,11 +71,11 @@ Slider( Slider 支持触觉反馈,可以通过 `hapticEffect` 参数自定义反馈效果,详见 [SliderHapticEffect](../components/slider#sliderhapticeffect)。 ```kotlin -var progress by remember { mutableStateOf(0.5f) } +var value by remember { mutableFloatStateOf(0.5f) } Slider( - progress = progress, - onProgressChange = { progress = it }, + value = value, + onValueChange = { value = it }, hapticEffect = SliderHapticEffect.Step ) ``` @@ -55,19 +84,62 @@ Slider( ### Slider 属性 -| 属性名 | 类型 | 说明 | 默认值 | 是否必须 | -| ---------------- | --------------------------------- | ------------------------ | ---------------------------------- | -------- | -| progress | Float | 当前滑块的进度值 | - | 是 | -| onProgressChange | (Float) -> Unit | 进度值变化时的回调函数 | - | 是 | -| modifier | Modifier | 应用于滑块的修饰符 | Modifier | 否 | -| enabled | Boolean | 是否启用滑块 | true | 否 | -| minValue | Float | 滑块的最小值 | 0f | 否 | -| maxValue | Float | 滑块的最大值 | 1f | 否 | -| height | Dp | 滑块的高度 | SliderDefaults.MinHeight | 否 | -| colors | SliderColors | 滑块的颜色配置 | SliderDefaults.sliderColors() | 否 | -| effect | Boolean | 是否显示特殊效果 | false | 否 | -| decimalPlaces | Int | 拖动指示器中显示的小数位 | 2 | 否 | -| hapticEffect | SliderDefaults.SliderHapticEffect | 滑块的触感反馈类型 | SliderDefaults.DefaultHapticEffect | 否 | +| 属性名 | 类型 | 说明 | 默认值 | 是否必须 | +| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------- | -------- | +| value | Float | 当前滑块的值。如果超出 valueRange,值将被强制限制在该范围内 | - | 是 | +| onValueChange | (Float) -> Unit | 值变化时的回调函数 | - | 是 | +| modifier | Modifier | 应用于滑块的修饰符 | Modifier | 否 | +| enabled | Boolean | 是否启用滑块 | true | 否 | +| valueRange | ClosedFloatingPointRange\ | 滑块可以采用的值范围。传递的值将被强制限制在此范围内 | 0f..1f | 否 | +| steps | Int | 离散值的数量。如果为 0,滑块将连续运行。必须非负 | 0 | 否 | +| onValueChangeFinished | (() -> Unit)? | 值变化结束时调用 | null | 否 | +| reverseDirection | Boolean | 控制滑块的方向。false 时从左到右增加,true 时从右到左增加(适用于 RTL 布局) | false | 否 | +| height | Dp | 滑块的高度 | SliderDefaults.MinHeight | 否 | +| colors | SliderColors | 滑块的颜色配置 | SliderDefaults.sliderColors() | 否 | +| effect | Boolean | 是否显示特殊效果 | false | 否 | +| hapticEffect | SliderDefaults.SliderHapticEffect | 滑块的触感反馈类型 | SliderDefaults.DefaultHapticEffect | 否 | +| showKeyPoints | Boolean | 是否显示关键点指示器。仅当 keyPoints 不为 null 时有效 | false | 否 | +| keyPoints | List\? | 要在滑块上显示的自定义关键点值。如果为 null,则使用 steps 参数的步长位置。值应在 valueRange 范围内 | null | 否 | +| magnetThreshold | Float | 磁吸对齐阈值,以分数表示 (0.0 到 1.0)。当滑块值与关键点的距离在此阈值内时,将对齐到该点。仅在设置 keyPoints 时生效 | 0.02f | 否 | + +### VerticalSlider 属性 + +| 属性名 | 类型 | 说明 | 默认值 | 是否必须 | +| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------- | -------- | +| value | Float | 当前滑块的值 | - | 是 | +| onValueChange | (Float) -> Unit | 值变化时的回调函数 | - | 是 | +| modifier | Modifier | 应用于滑块的修饰符 | Modifier | 否 | +| enabled | Boolean | 是否启用滑块 | true | 否 | +| valueRange | ClosedFloatingPointRange\ | 滑块可以采用的值范围 | 0f..1f | 否 | +| steps | Int | 离散值的数量 | 0 | 否 | +| onValueChangeFinished | (() -> Unit)? | 值变化结束时调用 | null | 否 | +| reverseDirection | Boolean | 控制滑块的方向。false 时从下到上增加,true 时从上到下增加 | false | 否 | +| width | Dp | 垂直滑块的宽度 | SliderDefaults.MinHeight | 否 | +| colors | SliderColors | 滑块的颜色配置 | SliderDefaults.sliderColors() | 否 | +| effect | Boolean | 是否显示特殊效果 | false | 否 | +| hapticEffect | SliderDefaults.SliderHapticEffect | 滑块的触感反馈类型 | SliderDefaults.DefaultHapticEffect | 否 | +| showKeyPoints | Boolean | 是否显示关键点指示器。仅当 keyPoints 不为 null 时有效 | false | 否 | +| keyPoints | List\? | 要在滑块上显示的自定义关键点值。如果为 null,则使用 steps 参数的步长位置。值应在 valueRange 范围内 | null | 否 | +| magnetThreshold | Float | 磁吸对齐阈值,以分数表示 (0.0 到 1.0)。当滑块值与关键点的距离在此阈值内时,将对齐到该点。仅在设置 keyPoints 时生效 | 0.02f | 否 | + +### RangeSlider 属性 + +| 属性名 | 类型 | 说明 | 默认值 | 是否必须 | +| --------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------- | -------- | +| value | ClosedFloatingPointRange\ | RangeSlider 的当前值。如果任一值超出 valueRange,将被强制限制在范围内 | - | 是 | +| onValueChange | (ClosedFloatingPointRange\) -> Unit | 值变化时的回调函数 | - | 是 | +| modifier | Modifier | 应用于滑块的修饰符 | Modifier | 否 | +| enabled | Boolean | 是否启用滑块 | true | 否 | +| valueRange | ClosedFloatingPointRange\ | Range Slider 值可以采用的范围 | 0f..1f | 否 | +| steps | Int | 离散值的数量 | 0 | 否 | +| onValueChangeFinished | (() -> Unit)? | 值变化结束时调用 | null | 否 | +| height | Dp | 滑块的高度 | SliderDefaults.MinHeight | 否 | +| colors | SliderColors | 滑块的颜色配置 | SliderDefaults.sliderColors() | 否 | +| effect | Boolean | 是否显示特殊效果 | false | 否 | +| hapticEffect | SliderDefaults.SliderHapticEffect | 滑块的触感反馈类型 | SliderDefaults.DefaultHapticEffect | 否 | +| showKeyPoints | Boolean | 是否显示关键点指示器。仅当 keyPoints 不为 null 时有效 | false | 否 | +| keyPoints | List\? | 要在滑块上显示的自定义关键点值。如果为 null,则使用 steps 参数的步长位置。值应在 valueRange 范围内 | null | 否 | +| magnetThreshold | Float | 磁吸对齐阈值,以分数表示 (0.0 到 1.0)。当滑块值与关键点的距离在此阈值内时,将对齐到该点。仅在设置 keyPoints 时生效 | 0.02f | 否 | ### SliderDefaults 对象 @@ -82,11 +154,11 @@ SliderDefaults 对象提供了 Slider 组件的默认配置。 ### SliderHapticEffect -| 值 | 说明 | -| ---- | -------------------------- | -| None | 无触感反馈 | -| Edge | 在 0% 和 100% 处有触感反馈 | -| Step | 在每个步长处有触感反馈 | +| 值 | 说明 | +| ---- | ---------------------- | +| None | 无触感反馈 | +| Edge | 在边缘处有触感反馈 | +| Step | 在每个步长处有触感反馈 | #### 方法 @@ -101,21 +173,37 @@ SliderDefaults 对象提供了 Slider 组件的默认配置。 | foregroundColor | Color | 滑块的前景颜色 | | disabledForegroundColor | Color | 禁用状态时滑块的前景颜色 | | backgroundColor | Color | 滑块的背景颜色 | +| keyPointColor | Color | 关键点指示器的颜色 | ## 进阶用法 ### 自定义数值范围 ```kotlin -var temperature by remember { mutableStateOf(25f) } +var temperature by remember { mutableFloatStateOf(25f) } + +Column { + Text("温度: ${temperature.roundToInt()}°C") + Slider( + value = temperature, + onValueChange = { temperature = it }, + valueRange = 16f..32f + ) +} +``` + +### 离散步长 + +```kotlin +var rating by remember { mutableFloatStateOf(3f) } Column { - Text("温度: $temperature°C") + Text("评分: ${rating.roundToInt()}/5") Slider( - progress = temperature, - onProgressChange = { temperature = it }, - minValue = 16f, - maxValue = 32f + value = rating, + onValueChange = { rating = it }, + valueRange = 1f..5f, + steps = 3 // 创建 5 个离散值: 1, 2, 3, 4, 5 ) } ``` @@ -123,11 +211,11 @@ Column { ### 自定义颜色 ```kotlin -var volume by remember { mutableStateOf(0.7f) } +var volume by remember { mutableFloatStateOf(0.7f) } Slider( - progress = volume, - onProgressChange = { volume = it }, + value = volume, + onValueChange = { volume = it }, colors = SliderDefaults.sliderColors( foregroundColor = Color.Red, backgroundColor = Color.LightGray @@ -138,11 +226,11 @@ Slider( ### 自定义高度和效果 ```kotlin -var brightness by remember { mutableStateOf(0.8f) } +var brightness by remember { mutableFloatStateOf(0.8f) } Slider( - progress = brightness, - onProgressChange = { brightness = it }, + value = brightness, + onValueChange = { brightness = it }, height = 40.dp, effect = true ) @@ -151,11 +239,96 @@ Slider( ### 带触感反馈的滑块 ```kotlin -var progress by remember { mutableStateOf(0.5f) } +var value by remember { mutableFloatStateOf(0.5f) } Slider( - progress = progress, - onProgressChange = { progress = it }, + value = value, + onValueChange = { value = it }, hapticEffect = SliderDefaults.SliderHapticEffect.Step ) ``` + +### 反向方向的 VerticalSlider + +```kotlin +var volume by remember { mutableFloatStateOf(0.5f) } + +VerticalSlider( + value = volume, + onValueChange = { volume = it }, + modifier = Modifier.height(200.dp), + reverseDirection = true // 从上到下 +) +``` + +### 用于价格筛选的 RangeSlider + +```kotlin +var priceRange by remember { mutableStateOf(100f..500f) } + +Column { + Text("价格: ¥${priceRange.start.roundToInt()} - ¥${priceRange.endInclusive.roundToInt()}") + RangeSlider( + value = priceRange, + onValueChange = { priceRange = it }, + valueRange = 0f..1000f, + steps = 99 // 从 0 到 1000 的 101 个离散值 + ) +} +``` + +### 完整示例(含值变化回调) + +```kotlin +var value by remember { mutableFloatStateOf(0.5f) } +var finalValue by remember { mutableFloatStateOf(0.5f) } + +Column { + Text("当前值: $value") + Text("最终值: $finalValue") + Slider( + value = value, + onValueChange = { value = it }, + onValueChangeFinished = { finalValue = value }, + valueRange = 0f..100f, + steps = 99 + ) +} +``` + +### 自定义关键点的滑块 + +```kotlin +var value by remember { mutableFloatStateOf(50f) } + +Column { + Text("值: ${value.roundToInt()}") + Slider( + value = value, + onValueChange = { value = it }, + valueRange = 0f..100f, + showKeyPoints = true, + keyPoints = listOf(0f, 25f, 50f, 75f, 100f), + magnetThreshold = 0.05f, // 5% 磁吸阈值 + hapticEffect = SliderDefaults.SliderHapticEffect.Step + ) +} +``` + +### 带关键点的 RangeSlider + +```kotlin +var range by remember { mutableStateOf(20f..80f) } + +Column { + Text("范围: ${range.start.roundToInt()} - ${range.endInclusive.roundToInt()}") + RangeSlider( + value = range, + onValueChange = { range = it }, + valueRange = 0f..100f, + showKeyPoints = true, + keyPoints = listOf(0f, 20f, 40f, 60f, 80f, 100f), + magnetThreshold = 0.03f + ) +} +``` diff --git a/example/src/commonMain/kotlin/component/OtherComponent.kt b/example/src/commonMain/kotlin/component/OtherComponent.kt index 29a458a5..6e55b97f 100644 --- a/example/src/commonMain/kotlin/component/OtherComponent.kt +++ b/example/src/commonMain/kotlin/component/OtherComponent.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -35,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import top.yukonga.miuix.kmp.basic.ButtonDefaults @@ -47,6 +49,7 @@ import top.yukonga.miuix.kmp.basic.ColorSpace import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator import top.yukonga.miuix.kmp.basic.LinearProgressIndicator +import top.yukonga.miuix.kmp.basic.RangeSlider import top.yukonga.miuix.kmp.basic.Slider import top.yukonga.miuix.kmp.basic.SliderDefaults import top.yukonga.miuix.kmp.basic.SmallTitle @@ -55,12 +58,13 @@ import top.yukonga.miuix.kmp.basic.TabRowWithContour import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.VerticalSlider import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.icons.useful.Like import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.PressFeedbackType import top.yukonga.miuix.kmp.utils.toHsv import top.yukonga.miuix.kmp.utils.toOkLab -import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.round fun LazyListScope.otherComponent( @@ -222,33 +226,292 @@ fun LazyListScope.otherComponent( item(key = "slider") { SmallTitle(text = "Slider") - var progress by remember { mutableStateOf(0.5f) } - Slider( - progress = progress, - onProgressChange = { newProgress -> progress = newProgress }, - decimalPlaces = 3, + Card( modifier = Modifier .padding(horizontal = 12.dp) - .padding(bottom = 12.dp) - ) - var progressHaptic by remember { mutableStateOf(0.5f) } - Slider( - progress = progressHaptic, - onProgressChange = { newProgress -> progressHaptic = newProgress }, - hapticEffect = SliderDefaults.SliderHapticEffect.Step, + .padding(bottom = 6.dp) + ) { + var sliderValue by remember { mutableStateOf(0.3f) } + Text( + text = "Normal: ${(sliderValue * 100).toInt()}%", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + var stepsValue by remember { mutableStateOf(5f) } + Text( + text = "Steps: ${stepsValue.toInt()}/8", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + Slider( + value = stepsValue, + onValueChange = { stepsValue = it }, + valueRange = 0f..8f, + steps = 7, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + var stepsWithKeyPointsValue by remember { mutableStateOf(5f) } + Text( + text = "Steps with Key Points: ${stepsWithKeyPointsValue.toInt()}/8", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + Slider( + value = stepsWithKeyPointsValue, + onValueChange = { stepsWithKeyPointsValue = it }, + valueRange = 0f..8f, + steps = 7, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + showKeyPoints = true, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + var customKeyPointsValue by remember { mutableStateOf(25f) } + Text( + text = "Custom Key Points: ${customKeyPointsValue.toInt()}%", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + Slider( + value = customKeyPointsValue, + onValueChange = { customKeyPointsValue = it }, + valueRange = 0f..100f, + showKeyPoints = true, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + keyPoints = listOf(0f, 25f, 50f, 75f, 100f), + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + val disabledValue by remember { mutableStateOf(0.7f) } + Text( + text = "Disabled: ${(disabledValue * 100).toInt()}%", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + Slider( + value = disabledValue, + onValueChange = {}, + enabled = false, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + } + + // RangeSlider + SmallTitle(text = "RangeSlider") + Card( modifier = Modifier .padding(horizontal = 12.dp) - .padding(bottom = 12.dp) - ) - val progressDisable by remember { mutableStateOf(0.5f) } - Slider( - progress = progressDisable, - onProgressChange = {}, - enabled = false, + .padding(bottom = 6.dp) + ) { + var rangeValue by remember { mutableStateOf(0.2f..0.8f) } + Text( + text = "Range: ${(rangeValue.start * 100).toInt()}% - ${(rangeValue.endInclusive * 100).toInt()}%", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + RangeSlider( + value = rangeValue, + onValueChange = { rangeValue = it }, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + var rangeStepsValue by remember { mutableStateOf(2f..8f) } + Text( + text = "Range with Key Points: ${rangeStepsValue.start.toInt()} - ${rangeStepsValue.endInclusive.toInt()}", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + RangeSlider( + value = rangeStepsValue, + onValueChange = { rangeStepsValue = it }, + valueRange = 0f..8f, + steps = 7, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + showKeyPoints = true, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + var customRangeValue by remember { mutableStateOf(20f..80f) } + Text( + text = "Custom Range Points: ${customRangeValue.start.toInt()}% - ${customRangeValue.endInclusive.toInt()}%", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + RangeSlider( + value = customRangeValue, + onValueChange = { customRangeValue = it }, + valueRange = 0f..100f, + showKeyPoints = true, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + keyPoints = listOf(0f, 20f, 40f, 60f, 80f, 100f), + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + var disabledRangeValue by remember { mutableStateOf(0.3f..0.7f) } + Text( + text = "Disabled: ${(disabledRangeValue.start * 100).toInt()}% - ${(disabledRangeValue.endInclusive * 100).toInt()}%", + fontSize = 14.sp, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 12.dp, bottom = 4.dp) + ) + RangeSlider( + value = disabledRangeValue, + onValueChange = {}, + enabled = false, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 12.dp) + ) + } + + // VerticalSlider + SmallTitle(text = "VerticalSlider") + Card( modifier = Modifier .padding(horizontal = 12.dp) .padding(bottom = 6.dp) - ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + var verticalValue1 by remember { mutableStateOf(0.3f) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + VerticalSlider( + value = verticalValue1, + onValueChange = { verticalValue1 = it }, + modifier = Modifier.size(25.dp, 160.dp) + ) + Text( + text = "Normal\n${(verticalValue1 * 100).toInt()}%", + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + var verticalValue2 by remember { mutableStateOf(5f) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + VerticalSlider( + value = verticalValue2, + onValueChange = { verticalValue2 = it }, + valueRange = 0f..8f, + steps = 7, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + modifier = Modifier.size(25.dp, 160.dp) + ) + Text( + text = "Steps\n${verticalValue2.toInt()}/8", + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + var verticalValue3 by remember { mutableStateOf(5f) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + VerticalSlider( + value = verticalValue3, + onValueChange = { verticalValue3 = it }, + valueRange = 0f..8f, + steps = 7, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + showKeyPoints = true, + modifier = Modifier.size(25.dp, 160.dp) + ) + Text( + text = "Points\n${verticalValue3.toInt()}/8", + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + var verticalValue4 by remember { mutableStateOf(50f) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + VerticalSlider( + value = verticalValue4, + onValueChange = { verticalValue4 = it }, + valueRange = 0f..100f, + showKeyPoints = true, + hapticEffect = SliderDefaults.SliderHapticEffect.Step, + keyPoints = listOf(0f, 25f, 50f, 75f, 100f), + modifier = Modifier.size(25.dp, 160.dp) + ) + Text( + text = "Custom\n${verticalValue4.toInt()}%", + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + val disabledVerticalValue by remember { mutableStateOf(0.7f) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + VerticalSlider( + value = disabledVerticalValue, + onValueChange = {}, + enabled = false, + modifier = Modifier.size(25.dp, 160.dp) + ) + Text( + text = "Disabled\n${(disabledVerticalValue * 100).toInt()}%", + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } } item(key = "tabRow") { diff --git a/example/src/commonMain/kotlin/component/TextComponent.kt b/example/src/commonMain/kotlin/component/TextComponent.kt index 4e77d488..92c52ff5 100644 --- a/example/src/commonMain/kotlin/component/TextComponent.kt +++ b/example/src/commonMain/kotlin/component/TextComponent.kt @@ -510,11 +510,10 @@ fun BottomSheet( ) { LazyColumn { item { - var progress by remember { mutableStateOf(0.5f) } + var sliderValue by remember { mutableStateOf(0.5f) } Slider( - progress = progress, - onProgressChange = { newProgress -> progress = newProgress }, - decimalPlaces = 3, + value = sliderValue, + onValueChange = { sliderValue = it }, modifier = Modifier.padding(bottom = 12.dp) ) Card( diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 7a97f58a..d9db8a03 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 1.0.5 CFBundleVersion - 569 + 574 LSRequiresIPhoneOS CADisableMinimumFrameDurationOnPhone diff --git a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/ColorPicker.kt b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/ColorPicker.kt index 82f03d88..ca5ccf16 100644 --- a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/ColorPicker.kt +++ b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/ColorPicker.kt @@ -868,7 +868,7 @@ private fun ColorSlider( size.width.toFloat(), with(density) { sliderHeightDp.toPx() }).coerceIn(0f, 1f) onValueChangedState.value(newValue) - hapticState.handleHapticFeedback(newValue, hapticEffect, hapticFeedback) + hapticState.handleHapticFeedback(newValue, 0f..1f, hapticEffect, hapticFeedback) } ) } diff --git a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Slider.kt b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Slider.kt index 6a388f64..06596ff8 100644 --- a/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Slider.kt +++ b/miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/Slider.kt @@ -3,21 +3,26 @@ package top.yukonga.miuix.kmp.basic +import androidx.annotation.IntRange import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -35,54 +40,203 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.utils.G2RoundedCornerShape -import kotlin.math.pow -import kotlin.math.round +import kotlin.math.abs /** * A [Slider] component with Miuix style. * - * @param progress The current progress of the [Slider]. - * @param onProgressChange The callback to be called when the progress changes. + * @param value The current value of the [Slider]. If outside of [valueRange] provided, value will be coerced to this range. + * @param onValueChange The callback to be called when the value changes. * @param modifier The modifier to be applied to the [Slider]. * @param enabled Whether the [Slider] is enabled. - * @param minValue The minimum value of the [Slider]. It is required - * that [minValue] < [maxValue]. - * @param maxValue The maximum value of the [Slider]. + * @param valueRange Range of values that this slider can take. The passed [value] will be coerced to this range. + * @param steps If positive, specifies the amount of discrete allowable values between the endpoints of [valueRange]. + * For example, a range from 0 to 10 with 4 [steps] allows 4 values evenly distributed between 0 and 10 (i.e., 2, 4, 6, 8). + * If [steps] is 0, the slider will behave continuously and allow any value from the range. 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 reverseDirection Controls the direction of this slider. When false (default), slider increases from left to right. + * When true, slider increases from right to left (useful for RTL layouts or custom direction requirements). * @param height The height of the [Slider]. * @param colors The [SliderColors] of the [Slider]. * @param effect Whether to show the effect of the [Slider]. - * @param decimalPlaces The number of decimal places to be displayed in the drag indicator. * @param hapticEffect The haptic effect of the [Slider]. + * @param showKeyPoints Whether to show the key points (step indicators) on the slider. Only works when [keyPoints] is not null. + * @param keyPoints Custom key point values to display on the slider. If null, uses step positions from [steps] parameter. + * Values should be within [valueRange]. For example, for a range of 0f..100f, you might specify listOf(0f, 25f, 50f, 75f, 100f). + * @param magnetThreshold The magnetic snap threshold as a fraction (0.0 to 1.0). When the slider value is within this + * distance from a key point, it will snap to that point. Default is 0.02 (2%). Only applies when [keyPoints] is set. */ @Composable fun Slider( - progress: Float, - onProgressChange: (Float) -> Unit, + value: Float, + onValueChange: (Float) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - minValue: Float = 0f, - maxValue: Float = 1f, + valueRange: ClosedFloatingPointRange = 0f..1f, + @IntRange(from = 0) steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + reverseDirection: Boolean = false, height: Dp = SliderDefaults.MinHeight, colors: SliderColors = SliderDefaults.sliderColors(), effect: Boolean = false, - decimalPlaces: Int = 2, - hapticEffect: SliderDefaults.SliderHapticEffect = SliderDefaults.DefaultHapticEffect + hapticEffect: SliderDefaults.SliderHapticEffect = SliderDefaults.DefaultHapticEffect, + showKeyPoints: Boolean = false, + keyPoints: List? = null, + magnetThreshold: Float = 0.02f ) { + require(steps >= 0) { "steps should be >= 0" } + require(valueRange.start < valueRange.endInclusive) { "valueRange start should be less than end" } + val hapticFeedback = LocalHapticFeedback.current - var dragOffset by remember { mutableStateOf(0f) } + var dragOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } - val factor = remember(decimalPlaces) { 10f.pow(decimalPlaces) } val hapticState = remember { SliderHapticState() } val interactionSource = remember { MutableInteractionSource() } val shape = remember(height) { G2RoundedCornerShape(height) } - val calculateProgress = remember(minValue, maxValue, factor) { - { offset: Float, width: Int -> - val newValue = (offset / width) * (maxValue - minValue) + minValue - (round(newValue * factor) / factor).coerceIn(minValue, maxValue) - } + val coercedValue = value.coerceIn(valueRange.start, valueRange.endInclusive) + val stepFractions = remember(steps) { stepsToTickFractions(steps) } + + val keyPointFractions = remember(keyPoints, stepFractions, valueRange, showKeyPoints) { + computeKeyPointFractions(keyPoints, stepFractions, valueRange, showKeyPoints) + } + + val allKeyPointFractions = remember(keyPoints, stepFractions, valueRange) { + computeAllKeyPointFractions(keyPoints, stepFractions, valueRange) + } + + val calculateValue = remember(valueRange, steps, stepFractions, allKeyPointFractions, magnetThreshold, reverseDirection) { + createValueCalculator(valueRange, steps, stepFractions, allKeyPointFractions, magnetThreshold, reverseDirection) + } + + Box( + modifier = modifier + .then( + if (enabled) { + Modifier + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + isDragging = true + dragOffset = offset.x + val calculatedValue = calculateValue(dragOffset, size.width) + onValueChange(calculatedValue) + hapticState.reset(calculatedValue) + }, + onHorizontalDrag = { _, dragAmount -> + dragOffset = (dragOffset + dragAmount).coerceIn(0f, size.width.toFloat()) + val calculatedValue = calculateValue(dragOffset, size.width) + onValueChange(calculatedValue) + hapticState.handleHapticFeedback( + calculatedValue, + valueRange, + hapticEffect, + hapticFeedback, + allKeyPointFractions, + hasCustomKeyPoints = keyPoints != null + ) + }, + onDragEnd = { + isDragging = false + onValueChangeFinished?.invoke() + } + ) + } + .indication(interactionSource, LocalIndication.current) + } else Modifier + ), + contentAlignment = Alignment.CenterStart + ) { + SliderTrack( + modifier = Modifier.fillMaxWidth().height(height), + shape = shape, + backgroundColor = colors.backgroundColor(), + foregroundColor = colors.foregroundColor(enabled), + effect = effect, + value = coercedValue, + valueRange = valueRange, + isDragging = isDragging, + isVertical = false, + showKeyPoints = showKeyPoints, + stepFractions = keyPointFractions, + keyPointColor = colors.keyPointColor() + ) + } +} + +/** + * A vertical [Slider] component with Miuix style. + * + * @param value The current value of the [Slider]. If outside of [valueRange] provided, value will be coerced to this range. + * @param onValueChange The callback to be called when the value changes. + * @param modifier The modifier to be applied to the [Slider]. + * @param enabled Whether the [Slider] is enabled. + * @param valueRange Range of values that this slider can take. The passed [value] will be coerced to this range. + * @param steps If positive, specifies the amount of discrete allowable values between the endpoints of [valueRange]. + * @param onValueChangeFinished Called when value change has ended. + * @param reverseDirection Controls the direction of this slider. When false (default), slider increases from bottom to top. + * When true, slider increases from top to bottom. + * @param width The width of the vertical [Slider]. + * @param colors The [SliderColors] of the [Slider]. + * @param effect Whether to show the effect of the [Slider]. + * @param hapticEffect The haptic effect of the [Slider]. + * @param showKeyPoints Whether to show the key points (step indicators) on the slider. Only works when [keyPoints] is not null. + * @param keyPoints Custom key point values to display on the slider. If null, uses step positions from [steps] parameter. + * Values should be within [valueRange]. + */ +@Composable +fun VerticalSlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + @IntRange(from = 0) steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + reverseDirection: Boolean = false, + width: Dp = SliderDefaults.MinHeight, + colors: SliderColors = SliderDefaults.sliderColors(), + effect: Boolean = false, + hapticEffect: SliderDefaults.SliderHapticEffect = SliderDefaults.DefaultHapticEffect, + showKeyPoints: Boolean = false, + keyPoints: List? = null, + magnetThreshold: Float = 0.02f +) { + require(steps >= 0) { "steps should be >= 0" } + require(valueRange.start < valueRange.endInclusive) { "valueRange start should be less than end" } + + val hapticFeedback = LocalHapticFeedback.current + var dragOffset by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + val hapticState = remember { SliderHapticState() } + val interactionSource = remember { MutableInteractionSource() } + val shape = remember(width) { G2RoundedCornerShape(width) } + + val coercedValue = value.coerceIn(valueRange.start, valueRange.endInclusive) + val stepFractions = remember(steps) { stepsToTickFractions(steps) } + + val keyPointFractions = remember(keyPoints, stepFractions, valueRange, showKeyPoints) { + computeKeyPointFractions(keyPoints, stepFractions, valueRange, showKeyPoints) + } + + val allKeyPointFractions = remember(keyPoints, stepFractions, valueRange) { + computeAllKeyPointFractions(keyPoints, stepFractions, valueRange) + } + + val calculateValue = remember(valueRange, steps, stepFractions, allKeyPointFractions, magnetThreshold, reverseDirection) { + createValueCalculator( + valueRange, + steps, + stepFractions, + allKeyPointFractions, + magnetThreshold, + reverseDirection, + isVertical = true + ) } Box( @@ -90,39 +244,210 @@ fun Slider( .then( if (enabled) { Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( + detectVerticalDragGestures( onDragStart = { offset -> isDragging = true - dragOffset = offset.x - val calculatedValue = calculateProgress(dragOffset, size.width) - onProgressChange(calculatedValue) + dragOffset = offset.y + val calculatedValue = calculateValue(dragOffset, size.height) + onValueChange(calculatedValue) hapticState.reset(calculatedValue) }, - onHorizontalDrag = { _, dragAmount -> - dragOffset = (dragOffset + dragAmount).coerceIn(0f, size.width.toFloat()) - val calculatedValue = calculateProgress(dragOffset, size.width) - onProgressChange(calculatedValue) - hapticState.handleHapticFeedback(calculatedValue, hapticEffect, hapticFeedback) + onVerticalDrag = { _, dragAmount -> + dragOffset = (dragOffset + dragAmount).coerceIn(0f, size.height.toFloat()) + val calculatedValue = calculateValue(dragOffset, size.height) + onValueChange(calculatedValue) + hapticState.handleHapticFeedback( + calculatedValue, + valueRange, + hapticEffect, + hapticFeedback, + allKeyPointFractions, + hasCustomKeyPoints = keyPoints != null + ) }, onDragEnd = { isDragging = false + onValueChangeFinished?.invoke() } ) }.indication(interactionSource, LocalIndication.current) } else Modifier ), - contentAlignment = Alignment.CenterStart + contentAlignment = Alignment.BottomCenter ) { SliderTrack( + modifier = Modifier.width(width).fillMaxHeight(), + shape = shape, + backgroundColor = colors.backgroundColor(), + foregroundColor = colors.foregroundColor(enabled), + effect = effect, + value = coercedValue, + valueRange = valueRange, + isDragging = isDragging, + isVertical = true, + showKeyPoints = showKeyPoints, + stepFractions = keyPointFractions, + keyPointColor = colors.keyPointColor() + ) + } +} + +/** + * A [RangeSlider] component with Miuix style. + * + * 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. + * + * @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 The modifier to be applied to the [RangeSlider]. + * @param enabled Whether the [RangeSlider] is enabled. + * @param valueRange Range of values that Range Slider values can take. Passed [value] will be coerced to this range. + * @param steps If positive, specifies the amount of discrete allowable values between the endpoints of [valueRange]. + * @param onValueChangeFinished Lambda to be invoked when value change has ended. + * @param height The height of the [RangeSlider]. + * @param colors The [SliderColors] of the [RangeSlider]. + * @param effect Whether to show the effect of the [RangeSlider]. + * @param hapticEffect The haptic effect of the [RangeSlider]. + * @param showKeyPoints Whether to show the key points (step indicators) on the slider. Only works when [keyPoints] is not null. + * @param keyPoints Custom key point values to display on the slider. If null, uses step positions from [steps] parameter. + * Values should be within [valueRange]. + * @param magnetThreshold The magnetic snap threshold as a fraction (0.0 to 1.0). When the slider value is within this + * distance from a key point, it will snap to that point. Default is 0.02 (2%). Only applies when [keyPoints] is set. + */ +@Composable +fun RangeSlider( + value: ClosedFloatingPointRange, + onValueChange: (ClosedFloatingPointRange) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + @IntRange(from = 0) steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + height: Dp = SliderDefaults.MinHeight, + colors: SliderColors = SliderDefaults.sliderColors(), + effect: Boolean = false, + hapticEffect: SliderDefaults.SliderHapticEffect = SliderDefaults.DefaultHapticEffect, + showKeyPoints: Boolean = false, + keyPoints: List? = null, + magnetThreshold: Float = 0.02f +) { + require(steps >= 0) { "steps should be >= 0" } + require(valueRange.start < valueRange.endInclusive) { "valueRange start should be less than end" } + + val hapticFeedback = LocalHapticFeedback.current + var startDragOffset by remember { mutableFloatStateOf(0f) } + var endDragOffset by remember { mutableFloatStateOf(0f) } + var isDraggingStart by remember { mutableStateOf(false) } + var isDraggingEnd by remember { mutableStateOf(false) } + val isDragging = isDraggingStart || isDraggingEnd + val hapticState = remember { RangeSliderHapticState() } + val interactionSource = remember { MutableInteractionSource() } + val shape = remember(height) { G2RoundedCornerShape(height) } + + var currentStartValue by remember { mutableFloatStateOf(value.start) } + var currentEndValue by remember { mutableFloatStateOf(value.endInclusive) } + + if (!isDragging) { + currentStartValue = value.start + currentEndValue = value.endInclusive + } + + val coercedStart = currentStartValue.coerceIn(valueRange.start, valueRange.endInclusive) + val coercedEnd = currentEndValue.coerceIn(valueRange.start, valueRange.endInclusive) + val stepFractions = remember(steps) { stepsToTickFractions(steps) } + + val keyPointFractions = remember(keyPoints, stepFractions, valueRange, showKeyPoints) { + computeKeyPointFractions(keyPoints, stepFractions, valueRange, showKeyPoints) + } + + val allKeyPointFractions = remember(keyPoints, stepFractions, valueRange) { + computeAllKeyPointFractions(keyPoints, stepFractions, valueRange) + } + + val calculateValue = remember(valueRange, steps, stepFractions, allKeyPointFractions, magnetThreshold) { + createValueCalculator(valueRange, steps, stepFractions, allKeyPointFractions, magnetThreshold) + } + + Box( + modifier = modifier + .then( + if (enabled) { + Modifier + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + val startPos = + (coercedStart - valueRange.start) / (valueRange.endInclusive - valueRange.start) * size.width + val endPos = + (coercedEnd - valueRange.start) / (valueRange.endInclusive - valueRange.start) * size.width + val diffStart = abs(offset.x - startPos) + val diffEnd = abs(offset.x - endPos) + + if (diffStart < diffEnd) { + isDraggingStart = true + startDragOffset = offset.x + hapticState.resetStart(coercedStart) + } else { + isDraggingEnd = true + endDragOffset = offset.x + hapticState.resetEnd(coercedEnd) + } + }, + onHorizontalDrag = { _, dragAmount -> + if (isDraggingStart) { + startDragOffset = (startDragOffset + dragAmount).coerceIn(0f, size.width.toFloat()) + val newStart = calculateValue(startDragOffset, size.width).coerceAtMost(currentEndValue) + currentStartValue = newStart + onValueChange(newStart..currentEndValue) + hapticState.handleStartHapticFeedback( + newStart, + valueRange, + hapticEffect, + hapticFeedback, + allKeyPointFractions, + hasCustomKeyPoints = keyPoints != null + ) + } else if (isDraggingEnd) { + endDragOffset = (endDragOffset + dragAmount).coerceIn(0f, size.width.toFloat()) + val newEnd = calculateValue(endDragOffset, size.width).coerceAtLeast(currentStartValue) + currentEndValue = newEnd + onValueChange(currentStartValue..newEnd) + hapticState.handleEndHapticFeedback( + newEnd, + valueRange, + hapticEffect, + hapticFeedback, + allKeyPointFractions, + hasCustomKeyPoints = keyPoints != null + ) + } + }, + onDragEnd = { + isDraggingStart = false + isDraggingEnd = false + onValueChangeFinished?.invoke() + } + ) + } + .indication(interactionSource, LocalIndication.current) + } else Modifier + ), + contentAlignment = Alignment.CenterStart + ) { + RangeSliderTrack( modifier = Modifier.fillMaxWidth().height(height), shape = shape, backgroundColor = colors.backgroundColor(), foregroundColor = colors.foregroundColor(enabled), effect = effect, - progress = progress, - minValue = minValue, - maxValue = maxValue, + valueStart = coercedStart, + valueEnd = coercedEnd, + valueRange = valueRange, isDragging = isDragging, + showKeyPoints = showKeyPoints, + stepFractions = keyPointFractions, + keyPointColor = colors.keyPointColor() ) } } @@ -137,10 +462,95 @@ private fun SliderTrack( backgroundColor: Color, foregroundColor: Color, effect: Boolean, - progress: Float, - minValue: Float, - maxValue: Float, + value: Float, + valueRange: ClosedFloatingPointRange, + isDragging: Boolean, + isVertical: Boolean, + showKeyPoints: Boolean, + stepFractions: FloatArray, + keyPointColor: Color +) { + val backgroundAlpha by animateFloatAsState( + targetValue = if (isDragging) 0.044f else 0f, + animationSpec = tween(150) + ) + + Canvas( + modifier = modifier + .clip(shape) + .background(backgroundColor) + .drawBehind { drawRect(Color.Black.copy(alpha = backgroundAlpha)) } + ) { + val barHeight = size.height + val barWidth = size.width + val fraction = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val cornerRadius = if (effect) { + if (isVertical) CornerRadius(barWidth / 2) else CornerRadius(barHeight / 2) + } else { + CornerRadius.Zero + } + + if (isVertical) { + val progressHeight = barHeight * fraction + drawRoundRect( + color = foregroundColor, + size = Size(barWidth, progressHeight), + topLeft = Offset(0f, barHeight - progressHeight), + cornerRadius = cornerRadius + ) + + if (showKeyPoints && stepFractions.isNotEmpty()) { + val keyPointRadius = barWidth / 6f + stepFractions.forEach { stepFraction -> + val y = barHeight * (1f - stepFraction) + drawCircle( + color = keyPointColor, + radius = keyPointRadius, + center = Offset(barWidth / 2f, y) + ) + } + } + } else { + val progressWidth = barWidth * fraction + drawRoundRect( + color = foregroundColor, + size = Size(progressWidth, barHeight), + topLeft = Offset(0f, 0f), + cornerRadius = cornerRadius + ) + + if (showKeyPoints && stepFractions.isNotEmpty()) { + val keyPointRadius = barHeight / 6f + stepFractions.forEach { stepFraction -> + val x = barWidth * stepFraction + drawCircle( + color = keyPointColor, + radius = keyPointRadius, + center = Offset(x, barHeight / 2f) + ) + } + } + } + } +} + +/** + * Internal range slider track renderer + */ +@Composable +private fun RangeSliderTrack( + modifier: Modifier, + shape: G2RoundedCornerShape, + backgroundColor: Color, + foregroundColor: Color, + effect: Boolean, + valueStart: Float, + valueEnd: Float, + valueRange: ClosedFloatingPointRange, isDragging: Boolean, + showKeyPoints: Boolean, + stepFractions: FloatArray, + keyPointColor: Color ) { val backgroundAlpha by animateFloatAsState( targetValue = if (isDragging) 0.044f else 0f, @@ -155,15 +565,30 @@ private fun SliderTrack( ) { val barHeight = size.height val barWidth = size.width - val progressWidth = barWidth * ((progress - minValue) / (maxValue - minValue)) + val startFraction = (valueStart - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val endFraction = (valueEnd - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val startX = barWidth * startFraction + val endX = barWidth * endFraction val cornerRadius = if (effect) CornerRadius(barHeight / 2) else CornerRadius.Zero drawRoundRect( color = foregroundColor, - size = Size(progressWidth, barHeight), - topLeft = Offset(0f, center.y - barHeight / 2), + size = Size(endX - startX, barHeight), + topLeft = Offset(startX, 0f), cornerRadius = cornerRadius ) + + if (showKeyPoints && stepFractions.isNotEmpty()) { + val keyPointRadius = barHeight / 6f + stepFractions.forEach { stepFraction -> + val x = barWidth * stepFraction + drawCircle( + color = keyPointColor, + radius = keyPointRadius, + center = Offset(x, barHeight / 2f) + ) + } + } } } @@ -173,38 +598,313 @@ private fun SliderTrack( internal class SliderHapticState { private var edgeFeedbackTriggered: Boolean = false private var lastStep: Float = 0f + private var isAtKeyPoint: Boolean = false fun reset(currentValue: Float) { edgeFeedbackTriggered = false lastStep = currentValue + isAtKeyPoint = false } fun handleHapticFeedback( currentValue: Float, + valueRange: ClosedFloatingPointRange, hapticEffect: SliderDefaults.SliderHapticEffect, - hapticFeedback: HapticFeedback + hapticFeedback: HapticFeedback, + keyPointFractions: FloatArray = floatArrayOf(), + hasCustomKeyPoints: Boolean = false ) { if (hapticEffect == SliderDefaults.SliderHapticEffect.None) return - val isAtEdge = currentValue == 0f || currentValue == 1f + handleEdgeHaptic(currentValue, valueRange, hapticFeedback) + + if (hapticEffect == SliderDefaults.SliderHapticEffect.Step) { + handleStepHaptic(currentValue, valueRange, hapticFeedback, keyPointFractions, hasCustomKeyPoints) + } + } + + private fun handleEdgeHaptic( + currentValue: Float, + valueRange: ClosedFloatingPointRange, + hapticFeedback: HapticFeedback + ) { + val isAtEdge = currentValue == valueRange.start || currentValue == valueRange.endInclusive if (isAtEdge && !edgeFeedbackTriggered) { hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) edgeFeedbackTriggered = true } else if (!isAtEdge) { edgeFeedbackTriggered = false } + } + + private fun handleStepHaptic( + currentValue: Float, + valueRange: ClosedFloatingPointRange, + hapticFeedback: HapticFeedback, + keyPointFractions: FloatArray, + hasCustomKeyPoints: Boolean + ) { + val isNotAtEdge = currentValue != valueRange.start && currentValue != valueRange.endInclusive + + if (hasCustomKeyPoints && keyPointFractions.isNotEmpty()) { + handleKeyPointHaptic(currentValue, valueRange, hapticFeedback, keyPointFractions, isNotAtEdge) + } else if (currentValue != lastStep && isNotAtEdge) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + lastStep = currentValue + } + } + + private fun handleKeyPointHaptic( + currentValue: Float, + valueRange: ClosedFloatingPointRange, + hapticFeedback: HapticFeedback, + keyPointFractions: FloatArray, + isNotAtEdge: Boolean + ) { + val fraction = (currentValue - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val threshold = 0.005f + + val nearestKeyPoint = keyPointFractions.minByOrNull { abs(it - fraction) } + val currentlyAtKeyPoint = nearestKeyPoint != null && abs(fraction - nearestKeyPoint) < threshold + + if (currentlyAtKeyPoint && !isAtKeyPoint && isNotAtEdge) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + + isAtKeyPoint = currentlyAtKeyPoint + } +} + +/** + * Manages haptic feedback state for the range slider. + */ +internal class RangeSliderHapticState { + private var startEdgeFeedbackTriggered: Boolean = false + private var endEdgeFeedbackTriggered: Boolean = false + private var startLastStep: Float = 0f + private var endLastStep: Float = 0f + private var startIsAtKeyPoint: Boolean = false + private var endIsAtKeyPoint: Boolean = false + + fun resetStart(currentValue: Float) { + startEdgeFeedbackTriggered = false + startLastStep = currentValue + startIsAtKeyPoint = false + } + + fun resetEnd(currentValue: Float) { + endEdgeFeedbackTriggered = false + endLastStep = currentValue + endIsAtKeyPoint = false + } + + fun handleStartHapticFeedback( + currentValue: Float, + valueRange: ClosedFloatingPointRange, + hapticEffect: SliderDefaults.SliderHapticEffect, + hapticFeedback: HapticFeedback, + keyPointFractions: FloatArray = floatArrayOf(), + hasCustomKeyPoints: Boolean = false + ) { + handleHapticFeedbackInternal( + currentValue = currentValue, + valueRange = valueRange, + hapticEffect = hapticEffect, + hapticFeedback = hapticFeedback, + keyPointFractions = keyPointFractions, + edgeFeedbackTriggered = startEdgeFeedbackTriggered, + lastStep = startLastStep, + isAtKeyPoint = startIsAtKeyPoint, + isStartEdge = true, + hasCustomKeyPoints = hasCustomKeyPoints, + onEdgeFeedbackUpdate = { startEdgeFeedbackTriggered = it }, + onLastStepUpdate = { startLastStep = it }, + onKeyPointUpdate = { startIsAtKeyPoint = it } + ) + } + + fun handleEndHapticFeedback( + currentValue: Float, + valueRange: ClosedFloatingPointRange, + hapticEffect: SliderDefaults.SliderHapticEffect, + hapticFeedback: HapticFeedback, + keyPointFractions: FloatArray = floatArrayOf(), + hasCustomKeyPoints: Boolean = false + ) { + handleHapticFeedbackInternal( + currentValue = currentValue, + valueRange = valueRange, + hapticEffect = hapticEffect, + hapticFeedback = hapticFeedback, + keyPointFractions = keyPointFractions, + edgeFeedbackTriggered = endEdgeFeedbackTriggered, + lastStep = endLastStep, + isAtKeyPoint = endIsAtKeyPoint, + isStartEdge = false, + hasCustomKeyPoints = hasCustomKeyPoints, + onEdgeFeedbackUpdate = { endEdgeFeedbackTriggered = it }, + onLastStepUpdate = { endLastStep = it }, + onKeyPointUpdate = { endIsAtKeyPoint = it } + ) + } + + private fun handleHapticFeedbackInternal( + currentValue: Float, + valueRange: ClosedFloatingPointRange, + hapticEffect: SliderDefaults.SliderHapticEffect, + hapticFeedback: HapticFeedback, + keyPointFractions: FloatArray, + edgeFeedbackTriggered: Boolean, + lastStep: Float, + isAtKeyPoint: Boolean, + isStartEdge: Boolean, + hasCustomKeyPoints: Boolean, + onEdgeFeedbackUpdate: (Boolean) -> Unit, + onLastStepUpdate: (Float) -> Unit, + onKeyPointUpdate: (Boolean) -> Unit + ) { + if (hapticEffect == SliderDefaults.SliderHapticEffect.None) return + + val targetEdge = if (isStartEdge) valueRange.start else valueRange.endInclusive + val isAtEdge = currentValue == targetEdge + + if (isAtEdge && !edgeFeedbackTriggered) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + onEdgeFeedbackUpdate(true) + } else if (!isAtEdge) { + onEdgeFeedbackUpdate(false) + } if (hapticEffect == SliderDefaults.SliderHapticEffect.Step) { - val isNotAtEdge = currentValue != 0f && currentValue != 1f - if (currentValue != lastStep && isNotAtEdge) { + val isNotAtEdge = currentValue != targetEdge + + if (hasCustomKeyPoints && keyPointFractions.isNotEmpty()) { + val fraction = (currentValue - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val threshold = 0.005f + + val nearestKeyPoint = keyPointFractions.minByOrNull { abs(it - fraction) } + val currentlyAtKeyPoint = nearestKeyPoint != null && abs(fraction - nearestKeyPoint) < threshold + + if (currentlyAtKeyPoint && !isAtKeyPoint && isNotAtEdge) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + + onKeyPointUpdate(currentlyAtKeyPoint) + } else if (currentValue != lastStep && isNotAtEdge) { hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - lastStep = currentValue + onLastStepUpdate(currentValue) } } } } +private fun stepsToTickFractions(steps: Int): FloatArray { + return if (steps == 0) floatArrayOf() else FloatArray(steps + 2) { it.toFloat() / (steps + 1) } +} + + +private fun snapValueToTick( + current: Float, + tickFractions: FloatArray, + minPx: Float, + maxPx: Float +): Float { + return tickFractions + .minByOrNull { abs(lerp(minPx, maxPx, it) - current) } + ?.run { lerp(minPx, maxPx, this) } + ?: current +} + +/** + * Converts point values to normalized fractions within the value range. + */ +private fun pointsToFractions( + points: List, + valueRange: ClosedFloatingPointRange +): FloatArray { + return points.map { point -> + ((point - valueRange.start) / (valueRange.endInclusive - valueRange.start)) + .coerceIn(0f, 1f) + }.toFloatArray() +} + +/** + * Computes key point fractions for slider display. + * Filters out points too close to edges (within 2% margin). + */ +private fun computeKeyPointFractions( + keyPoints: List?, + stepFractions: FloatArray, + valueRange: ClosedFloatingPointRange, + showKeyPoints: Boolean +): FloatArray { + val baseFractions = when { + keyPoints != null -> pointsToFractions(keyPoints, valueRange) + showKeyPoints -> stepFractions + else -> floatArrayOf() + } + + return baseFractions.filter { fraction -> fraction > 0.02f && fraction < 0.98f }.toFloatArray() +} + +/** + * Computes all key point fractions including edge points. + * Used for haptic feedback and magnetic snapping. + */ +private fun computeAllKeyPointFractions( + keyPoints: List?, + stepFractions: FloatArray, + valueRange: ClosedFloatingPointRange +): FloatArray { + return when { + keyPoints != null -> pointsToFractions(keyPoints, valueRange) + stepFractions.isNotEmpty() -> stepFractions + else -> floatArrayOf() + } +} + +/** + * Creates a value calculator for slider position to value conversion. + */ +private fun createValueCalculator( + valueRange: ClosedFloatingPointRange, + steps: Int, + stepFractions: FloatArray, + allKeyPointFractions: FloatArray, + magnetThreshold: Float, + reverseDirection: Boolean = false, + isVertical: Boolean = false +): (offset: Float, size: Int) -> Float { + return { offset: Float, size: Int -> + val adjustedOffset = when { + isVertical -> if (reverseDirection) offset else size - offset + else -> if (reverseDirection) size - offset else offset + } + val fraction = (adjustedOffset / size).coerceIn(0f, 1f) + val newValue = lerp(valueRange.start, valueRange.endInclusive, fraction) + + when { + steps > 0 -> snapValueToTick(newValue, stepFractions, valueRange.start, valueRange.endInclusive) + allKeyPointFractions.isNotEmpty() -> { + val closestKeyPoint = allKeyPointFractions.minByOrNull { keyPointFraction -> + abs(fraction - keyPointFraction) + } + if (closestKeyPoint != null && abs(fraction - closestKeyPoint) < magnetThreshold) { + lerp(valueRange.start, valueRange.endInclusive, closestKeyPoint) + } else { + newValue + } + } + + else -> newValue + } + } +} + object SliderDefaults { + /** + * The minimum height of the [Slider] and [RangeSlider]. + */ val MinHeight = 30.dp /** @@ -221,17 +921,22 @@ object SliderDefaults { Step } + /** + * The default haptic effect of the [Slider] and [RangeSlider]. + */ val DefaultHapticEffect = SliderHapticEffect.Edge @Composable fun sliderColors( foregroundColor: Color = MiuixTheme.colorScheme.primary, disabledForegroundColor: Color = MiuixTheme.colorScheme.disabledPrimarySlider, - backgroundColor: Color = MiuixTheme.colorScheme.tertiaryContainerVariant + backgroundColor: Color = MiuixTheme.colorScheme.tertiaryContainerVariant, + keyPointColor: Color = Color(0x4DA3B3CD) ): SliderColors = SliderColors( foregroundColor = foregroundColor, disabledForegroundColor = disabledForegroundColor, - backgroundColor = backgroundColor + backgroundColor = backgroundColor, + keyPointColor = keyPointColor ) } @@ -239,7 +944,8 @@ object SliderDefaults { class SliderColors( private val foregroundColor: Color, private val disabledForegroundColor: Color, - private val backgroundColor: Color + private val backgroundColor: Color, + private val keyPointColor: Color ) { @Stable internal fun foregroundColor(enabled: Boolean): Color = @@ -247,4 +953,7 @@ class SliderColors( @Stable internal fun backgroundColor(): Color = backgroundColor + + @Stable + internal fun keyPointColor(): Color = keyPointColor } \ No newline at end of file