Slider.kt

/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.wear.compose.material3

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material3.tokens.MotionTokens
import androidx.wear.compose.materialcore.InlineSliderButton
import androidx.wear.compose.materialcore.RangeDefaults.calculateCurrentStepValue
import androidx.wear.compose.materialcore.RangeDefaults.snapValueToStep
import androidx.wear.compose.materialcore.RangeIcons
import androidx.wear.compose.materialcore.directedValue
import androidx.wear.compose.materialcore.drawProgressBar
import kotlin.math.roundToInt

/**
 * [InlineSlider] allows users to make a selection from a range of values. The range of selections
 * is shown as a bar between the minimum and maximum values of the range, from which users may
 * select a single value. InlineSlider is ideal for adjusting settings such as volume or brightness.
 *
 * Value can be increased and decreased by clicking on the increase and decrease buttons, located
 * accordingly to the start and end of the control. Buttons can have custom icons - [decreaseIcon]
 * and [increaseIcon].
 *
 * The bar in the middle of control can have separators if [segmented] flag is set to true. A single
 * step value is calculated as the difference between min and max values of [valueRange] divided by
 * [steps] + 1 value.
 *
 * A continuous non-segmented slider sample:
 * @sample androidx.wear.compose.material3.samples.InlineSliderSample
 *
 * A segmented slider sample:
 * @sample androidx.wear.compose.material3.samples.InlineSliderSegmentedSample
 *
 * @param value Current value of the Slider. If outside of [valueRange] provided, value will be
 * coerced to this range.
 * @param onValueChange Lambda in which value should be updated.
 * @param steps Specifies the number of discrete values, excluding min and max values, evenly
 * distributed across the whole value range. Must not be negative. If 0, slider will have only min
 * and max values and no steps in between.
 * @param decreaseIcon A slot for an icon which is placed on the decrease (start) button such as
 * [InlineSliderDefaults.Decrease].
 * @param increaseIcon A slot for an icon which is placed on the increase (end) button such as
 * [InlineSliderDefaults.Increase].
 * @param modifier Modifiers for the Slider layout.
 * @param enabled Controls the enabled state of the slider. When `false`, this slider will not be
 * clickable.
 * @param valueRange Range of values that Slider value can take. Passed [value] will be coerced to
 * this range.
 * @param segmented A boolean value which specifies whether a bar will be split into segments or
 * not. Recommendation is while using this flag do not have more than 8 [steps] as it might affect
 * user experience. By default true if number of [steps] is <=8.
 * @param colors [InlineSliderColors] that will be used to resolve the background and content color
 * for this slider in different states.
 */
@ExperimentalWearMaterial3Api
@Composable
fun InlineSlider(
    value: Float,
    onValueChange: (Float) -> Unit,
    steps: Int,
    decreaseIcon: @Composable () -> Unit,
    increaseIcon: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    valueRange: ClosedFloatingPointRange<Float> = 0f..(steps + 1).toFloat(),
    segmented: Boolean = steps <= 8,
    colors: InlineSliderColors = InlineSliderDefaults.colors(),
) {
    require(steps >= 0) { "steps should be >= 0" }
    val currentStep =
        remember(value, valueRange, steps) { snapValueToStep(value, valueRange, steps) }
    BoxWithConstraints(
        modifier = modifier
            .fillMaxWidth()
            .rangeSemantics(
                value, enabled, onValueChange, valueRange, steps
            )
            .height(InlineSliderDefaults.SliderHeight)
            .clip(CircleShape) // TODO(b/290625297) Replace with tokens
    ) {
        val visibleSegments = if (segmented) steps + 1 else 1

        val updateValue: (Int) -> Unit = { stepDiff ->
            val newValue = calculateCurrentStepValue(currentStep + stepDiff, steps, valueRange)
            if (newValue != value) onValueChange(newValue)
        }
        val selectedBarColor = colors.barColor(enabled, true)
        val unselectedBarColor = colors.barColor(enabled, false)
        val containerColor = colors.containerColor(enabled)
        val barSeparatorColor = colors.barSeparatorColor(enabled)
        CompositionLocalProvider(
            LocalIndication provides rippleOrFallbackImplementation(
                bounded = false,
                radius = this.maxWidth / 2
            )
        ) {
            Row(
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.Start,
                modifier = Modifier
                    .fillMaxWidth()
                    .background(containerColor.value)
            ) {
                val increaseButtonEnabled = enabled && currentStep < steps + 1
                val decreaseButtonEnabled = enabled && currentStep > 0

                InlineSliderButton(
                    enabled = decreaseButtonEnabled,
                    onClick = { updateValue(-1) },
                    contentAlignment = Alignment.CenterStart,
                    buttonControlSize = InlineSliderDefaults.ControlSize,
                    modifier = Modifier.padding(start = InlineSliderDefaults.OuterHorizontalMargin),
                    content = {
                        InlineSliderButtonContent(
                            decreaseButtonEnabled,
                            { enabled -> colors.buttonIconColor(enabled) },
                            decreaseIcon
                        )
                    }
                )

                val valueRatio by animateFloatAsState(
                    targetValue = currentStep.toFloat() / (steps + 1).toFloat(),
                    animationSpec = tween(
                        durationMillis = MotionTokens.DurationShort3,
                        delayMillis = 0,
                        easing = MotionTokens.EasingStandardDecelerate
                    )
                )

                Box(
                    modifier = Modifier
                        .height(InlineSliderDefaults.BarHeight)
                        .weight(1f)
                        .clip(CircleShape) // TODO(b/290625297) Replace with token
                        .drawProgressBar(
                            selectedBarColor = selectedBarColor,
                            unselectedBarColor = unselectedBarColor,
                            barSeparatorColor = barSeparatorColor,
                            visibleSegments = visibleSegments,
                            valueRatio = valueRatio,
                            direction = LocalLayoutDirection.current,
                            drawSelectedProgressBar = { color, ratio, direction, drawScope ->
                                drawScope.drawSelectedProgressBar(color, ratio, direction)
                            },
                            drawUnselectedProgressBar = { color, ratio, direction, drawScope ->
                                drawScope.drawUnselectedProgressBar(color, ratio, direction)
                            },
                            drawProgressBarSeparator = { color, position, drawScope ->
                                drawScope.drawProgressBarSeparator(color, position)
                            }
                        )
                )

                InlineSliderButton(
                    enabled = increaseButtonEnabled,
                    onClick = { updateValue(1) },
                    contentAlignment = Alignment.CenterEnd,
                    buttonControlSize = InlineSliderDefaults.ControlSize,
                    modifier = Modifier.padding(end = InlineSliderDefaults.OuterHorizontalMargin),
                    content = {
                        InlineSliderButtonContent(
                            increaseButtonEnabled,
                            { enabled -> colors.buttonIconColor(enabled) },
                            increaseIcon
                        )
                    }
                )
            }
        }
    }
}

/**
 * [InlineSlider] allows users to make a selection from a range of values. The range of selections
 * is shown as a bar between the minimum and maximum values of the range, from which users may
 * select a single value. InlineSlider is ideal for adjusting settings such as volume or brightness.
 *
 * Value can be increased and decreased by clicking on the increase and decrease buttons, located
 * accordingly to the start and end of the control. Buttons can have custom icons - [decreaseIcon]
 * and [increaseIcon].
 *
 * The bar in the middle of control can have separators if [segmented] flag is set to true. A number
 * of steps is calculated as the difference between max and min values of [valueProgression] divided
 * by [valueProgression].step - 1. For example, with a range of 100..120 and a step 5, number of
 * steps will be (120-100)/ 5 - 1 = 3. Steps are 100(first), 105, 110, 115, 120(last)
 *
 * If [valueProgression] range is not equally divisible by [valueProgression].step, then
 * [valueProgression].last will be adjusted to the closest divisible value in the range. For
 * example, 1..13 range and a step = 5, steps will be 1(first) , 6 , 11(last)
 *
 * A continuous non-segmented slider sample:
 * @sample androidx.wear.compose.material3.samples.InlineSliderWithIntegerSample
 *
 * A segmented slider sample:
 * @sample androidx.wear.compose.material3.samples.InlineSliderSegmentedSample
 *
 * @param value Current value of the Slider. If outside of [valueProgression] provided, value will
 * be coerced to this range.
 * @param onValueChange Lambda in which value should be updated.
 * @param valueProgression Progression of values that Slider value can take. Consists of rangeStart,
 * rangeEnd and step. Range will be equally divided by step size.
 * @param decreaseIcon A slot for an icon which is placed on the decrease (start) button such as
 * [InlineSliderDefaults.Decrease].
 * @param increaseIcon A slot for an icon which is placed on the increase (end) button such as
 * [InlineSliderDefaults.Increase].
 * @param modifier Modifiers for the Slider layout.
 * @param enabled Controls the enabled state of the slider. When `false`, this slider will not be
 * clickable.
 * @param segmented A boolean value which specifies whether a bar will be split into segments or
 * not. Recommendation is while using this flag do not have more than 8 steps as it might affect
 * user experience. By default true if number of steps is <=8.
 * @param colors [InlineSliderColors] that will be used to resolve the background and content color
 * for this slider in different states.
 */
@ExperimentalWearMaterial3Api
@Composable
fun InlineSlider(
    value: Int,
    onValueChange: (Int) -> Unit,
    valueProgression: IntProgression,
    decreaseIcon: @Composable () -> Unit,
    increaseIcon: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    segmented: Boolean = valueProgression.stepsNumber() <= 8,
    colors: InlineSliderColors = InlineSliderDefaults.colors(),
) {
    InlineSlider(
        value = value.toFloat(),
        onValueChange = { onValueChange(it.roundToInt()) },
        steps = valueProgression.stepsNumber(),
        modifier = modifier,
        enabled = enabled,
        valueRange = valueProgression.first.toFloat()..valueProgression.last.toFloat(),
        segmented = segmented,
        decreaseIcon = decreaseIcon,
        increaseIcon = increaseIcon,
        colors = colors
    )
}

/** Defaults used by slider. */
@ExperimentalWearMaterial3Api
object InlineSliderDefaults {
    /**
     * Default slider measurements.
     */
    internal val SliderHeight = 52.dp

    internal val ControlSize = 36.dp

    internal val OuterHorizontalMargin = 6.dp

    internal val BarHeight = 10.dp

    internal val SelectedBarHeight = 10.dp

    internal val UnselectedBarHeight = 4.dp

    internal val BarSeparatorRadius = 2.dp

    /**
     * The recommended size for Slider [Decrease] and [Increase] button icons.
     */
    val IconSize = 24.dp

    /**
     * Creates a [InlineSliderColors] that represents the default background and content colors used
     * in an [InlineSlider].
     *
     * @param containerColor The background color of this [InlineSlider] when enabled
     * @param buttonIconColor The color of the icon of buttons when enabled
     * @param selectedBarColor The color of the progress bar when enabled
     * @param unselectedBarColor The background color of the progress bar when enabled
     * @param barSeparatorColor The color of separator between visible segments when enabled
     * @param disabledContainerColor The background color of this [InlineSlider] when disabled
     * @param disabledButtonIconColor The color of the icon of buttons when disabled
     * @param disabledSelectedBarColor The color of the progress bar when disabled
     * @param disabledUnselectedBarColor The background color of the progress bar when disabled
     * @param disabledBarSeparatorColor The color of separator between visible segments when disabled
     */
    @Composable
    fun colors(
        containerColor: Color = MaterialTheme.colorScheme.surface,
        buttonIconColor: Color = MaterialTheme.colorScheme.secondary,
        selectedBarColor: Color = MaterialTheme.colorScheme.primary,
        unselectedBarColor: Color = MaterialTheme.colorScheme.background.copy(alpha = 0.3f),
        barSeparatorColor: Color = MaterialTheme.colorScheme.primaryDim,
        disabledContainerColor: Color = containerColor.toDisabledColor(
            disabledAlpha = DisabledContainerAlpha
        ),
        disabledButtonIconColor: Color = buttonIconColor.toDisabledColor(),
        disabledSelectedBarColor: Color = selectedBarColor.toDisabledColor(),
        disabledUnselectedBarColor: Color = unselectedBarColor.toDisabledColor(),
        disabledBarSeparatorColor: Color = barSeparatorColor.toDisabledColor(
            disabledAlpha = DisabledContainerAlpha
        )
    ): InlineSliderColors = InlineSliderColors(
        containerColor = containerColor,
        buttonIconColor = buttonIconColor,
        selectedBarColor = selectedBarColor,
        unselectedBarColor = unselectedBarColor,
        barSeparatorColor = barSeparatorColor,
        disabledContainerColor = disabledContainerColor,
        disabledButtonIconColor = disabledButtonIconColor,
        disabledSelectedBarColor = disabledSelectedBarColor,
        disabledUnselectedBarColor = disabledUnselectedBarColor,
        disabledBarSeparatorColor = disabledBarSeparatorColor
    )

    /** Decrease [ImageVector]. */
    val Decrease = RangeIcons.Minus

    /** Increase [ImageVector]. */
    val Increase = Icons.Filled.Add
}

/**
 * Represents the background and content colors used in [InlineSlider] in different states.
 *
 * @constructor create an instance with arbitrary colors.
 * See [InlineSliderDefaults.colors] for the default implementation that follows Material
 * specifications.
 *
 * @param containerColor The background color of this [InlineSlider] when enabled.
 * @param buttonIconColor The color of the icon of buttons when enabled.
 * @param selectedBarColor The color of the progress bar when enabled.
 * @param unselectedBarColor The background color of the progress bar when enabled.
 * @param barSeparatorColor The color of separator between visible segments when enabled.
 * @param disabledContainerColor The background color of this [InlineSlider] when disabled.
 * @param disabledButtonIconColor The color of the icon of buttons when disabled.
 * @param disabledSelectedBarColor The color of the progress bar when disabled.
 * @param disabledUnselectedBarColor The background color of the progress bar when disabled.
 * @param disabledBarSeparatorColor The color of separator between visible segments when disabled.
 */
@ExperimentalWearMaterial3Api
@Immutable
class InlineSliderColors constructor(
    val containerColor: Color,
    val buttonIconColor: Color,
    val selectedBarColor: Color,
    val unselectedBarColor: Color,
    val barSeparatorColor: Color,
    val disabledContainerColor: Color,
    val disabledButtonIconColor: Color,
    val disabledSelectedBarColor: Color,
    val disabledUnselectedBarColor: Color,
    val disabledBarSeparatorColor: Color
) {
    @Composable
    internal fun containerColor(enabled: Boolean): State<Color> =
        animateColorAsState(
            if (enabled) containerColor else disabledContainerColor,
            label = "sliderContainerColorAnimation"
        )

    @Composable
    internal fun buttonIconColor(enabled: Boolean): State<Color> =
        animateColorAsState(
            if (enabled) buttonIconColor else disabledButtonIconColor,
            label = "sliderButtonIconColorAnimation"
        )

    @Composable
    internal fun barSeparatorColor(enabled: Boolean): State<Color> =
        animateColorAsState(
            if (enabled) barSeparatorColor else disabledBarSeparatorColor,
            label = "sliderBarSeparatorColorAnimation"
        )

    @Composable
    internal fun barColor(enabled: Boolean, selected: Boolean): State<Color> = animateColorAsState(
        if (enabled) {
            if (selected) selectedBarColor else unselectedBarColor
        } else {
            if (selected) disabledSelectedBarColor else disabledUnselectedBarColor
        }, label = "sliderBarColorAnimation"
    )

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null) return false
        if (this::class != other::class) return false

        other as InlineSliderColors

        if (containerColor != other.containerColor) return false
        if (buttonIconColor != other.buttonIconColor) return false
        if (selectedBarColor != other.selectedBarColor) return false
        if (unselectedBarColor != other.unselectedBarColor) return false
        if (barSeparatorColor != other.barSeparatorColor) return false
        if (disabledContainerColor != other.disabledContainerColor) return false
        if (disabledButtonIconColor != other.disabledButtonIconColor) return false
        if (disabledSelectedBarColor != other.disabledSelectedBarColor) return false
        if (disabledUnselectedBarColor != other.disabledUnselectedBarColor) return false
        if (disabledBarSeparatorColor != other.disabledBarSeparatorColor) return false

        return true
    }

    override fun hashCode(): Int {
        var result = containerColor.hashCode()
        result = 31 * result + buttonIconColor.hashCode()
        result = 31 * result + selectedBarColor.hashCode()
        result = 31 * result + unselectedBarColor.hashCode()
        result = 31 * result + barSeparatorColor.hashCode()
        result = 31 * result + disabledContainerColor.hashCode()
        result = 31 * result + disabledButtonIconColor.hashCode()
        result = 31 * result + disabledSelectedBarColor.hashCode()
        result = 31 * result + disabledUnselectedBarColor.hashCode()
        result = 31 * result + disabledBarSeparatorColor.hashCode()
        return result
    }
}

@OptIn(ExperimentalWearMaterial3Api::class)
internal fun DrawScope.drawSelectedProgressBar(
    color: Color,
    valueRatio: Float,
    direction: LayoutDirection
) {
    val barHeightInPx = InlineSliderDefaults.SelectedBarHeight.toPx()
    drawRoundRect(
        color = color,
        topLeft = Offset(
            directedValue(direction, 0f, size.width * (1 - valueRatio)),
            (size.height - barHeightInPx) / 2
        ),
        size = Size(size.width * valueRatio, barHeightInPx),
        cornerRadius = CornerRadius(barHeightInPx / 2)
    )
}

@OptIn(ExperimentalWearMaterial3Api::class)
internal fun DrawScope.drawUnselectedProgressBar(
    color: Color,
    valueRatio: Float,
    direction: LayoutDirection,
) {
    val barHeightInPx = InlineSliderDefaults.UnselectedBarHeight.toPx()
    drawRoundRect(
        color = color,
        topLeft = Offset(
            directedValue(direction, size.width * valueRatio, 0f), (size.height - barHeightInPx) / 2
        ),
        size = Size(size.width * (1 - valueRatio), barHeightInPx),
        cornerRadius = CornerRadius(barHeightInPx / 2)
    )
}

@OptIn(ExperimentalWearMaterial3Api::class)
internal fun DrawScope.drawProgressBarSeparator(color: Color, position: Float) {
    drawCircle(
        color = color,
        radius = InlineSliderDefaults.BarSeparatorRadius.toPx(),
        center = Offset(position, size.height / 2),
        blendMode = BlendMode.Src
    )
}

@Composable
private fun InlineSliderButtonContent(
    enabled: Boolean,
    buttonIconColor: @Composable (enabled: Boolean) -> State<Color>,
    content: @Composable () -> Unit
) = CompositionLocalProvider(
    LocalContentColor provides buttonIconColor(enabled).value,
    content = content
)