Skip to content

Commit

Permalink
feat: slider distribution adjustments (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
levinzonr authored May 3, 2024
1 parent a5bb1ca commit ec914e2
Show file tree
Hide file tree
Showing 9 changed files with 106 additions and 73 deletions.
13 changes: 5 additions & 8 deletions demo/src/main/java/io/monstarlab/mosaic/features/SliderDemo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.monstarlab.mosaic.slider.Slider
import io.monstarlab.mosaic.slider.SliderColors
import io.monstarlab.mosaic.slider.distribution.CheckPointsValuesDistribution
import io.monstarlab.mosaic.slider.distribution.SliderValuesDistribution
import kotlin.math.roundToInt
import androidx.compose.material3.Slider as MaterialSlider
Expand Down Expand Up @@ -84,13 +83,11 @@ fun MosaicSliderDemo() {
}

val fragmentedDistribution: SliderValuesDistribution = remember {
CheckPointsValuesDistribution(
listOf(
0f to 0f,
0.2f to 500f,
0.4f to 800f,
1f to 1000f,
),
SliderValuesDistribution.checkpoints(
0f to 0f,
0.2f to 500f,
0.4f to 800f,
1f to 1000f,
)
}

Expand Down
8 changes: 4 additions & 4 deletions docs/slider.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,11 @@ This allows you to control how the user interacts with the slider, the specific

![Distributions](./assets/example_distribution.gif)

### Linear Values Distribution
#### Linear Values Distribution
By default, Mosaic Slider will use `SliderValuesDistribution.Linear` which would arrange values in a linear fashion just like any other Slider


### Parabolic Values Distribution
#### Parabolic Values Distribution
Parabolic Values Distribution allows you to arrange your values in parabolic fashion. For this, you would have to provide your `a`,`b` and `c` values for the `axˆ2 + bx+ c` equation.

!!! note
Expand All @@ -156,7 +156,7 @@ Parabolic Values Distribution allows you to arrange your values in parabolic fas
val myDistribution = SliderValuesDistribution.parbolic(a, b, c)
```

### Checkpoints Values Distribution
#### Checkpoints Values Distribution

`CheckpointValuesDistribution` provides a more convinient way to customize distribution. It is based on the list of "checkoints" where each one of them is placed along the SliderTrack and comes with specific values.

Expand All @@ -172,7 +172,7 @@ val distribution = SliderValuesDistribution.checkpoints(
)
```

### Make your own distribution
#### Make your own distribution
`SliderValuesDistribution` is a simple interface you can extend and build your own distribution.

```kotlin
Expand Down
50 changes: 34 additions & 16 deletions slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import io.monstarlab.mosaic.slider.distribution.SliderValuesDistribution
import io.monstarlab.mosaic.slider.math.fractionToValue
import io.monstarlab.mosaic.slider.distribution.inverse
import io.monstarlab.mosaic.slider.math.calcFraction
import io.monstarlab.mosaic.slider.math.scale
import io.monstarlab.mosaic.slider.math.valueToFraction
import kotlinx.coroutines.coroutineScope

Expand Down Expand Up @@ -70,8 +72,9 @@ public class SliderState(
get() = if (totalWidth == 0f) {
0f
} else {
val valueFraction = value.valueToFraction(range)
valueDistribution.inverse(valueFraction).coerceIn(0f, 1f)
val inverted = valueDistribution.inverse(value)
val invertedRange = valueDistribution.inverse(range)
inverted.valueToFraction(invertedRange)
}

internal val disabledRangeAsFractions: ClosedFloatingPointRange<Float>
Expand All @@ -82,7 +85,7 @@ public class SliderState(
}

override fun dispatchRawDelta(delta: Float) {
val newRawOffset = rawOffset + delta
val newRawOffset = (rawOffset + delta).coerceIn(0f, totalWidth)
val userValue = scaleToUserValue(newRawOffset)
handleValueUpdate(userValue, newRawOffset)
}
Expand Down Expand Up @@ -119,20 +122,27 @@ public class SliderState(
* Scales offset in to the value that user should see
*/
private fun scaleToUserValue(offset: Float): Float {
val coercedValue = (offset / totalWidth).coerceIn(0f..1f)
val value = valueDistribution.interpolate(coercedValue)
.fractionToValue(range)
return coerceUserValue(value)
println("Range: $range")
val invertedRange = valueDistribution.inverse(range)
println("Inverted range: $invertedRange")
val value = scale(0f, totalWidth, offset, invertedRange.start, invertedRange.endInclusive)
return coerceUserValue(valueDistribution.interpolate(value))
}

/**
* Converts value of the user into the raw offset on the track
*/
private fun scaleToOffset(value: Float): Float {
val valueAsFraction = coerceUserValue(value).valueToFraction(range)
return valueDistribution
.inverse(valueAsFraction)
.fractionToValue(0f, totalWidth)
val coerced = coerceUserValue(value)
val invertedRange = valueDistribution.inverse(range)
val invertedValue = valueDistribution.inverse(coerced)
return scale(
invertedRange.start,
invertedRange.endInclusive,
invertedValue,
0f,
totalWidth,
)
}

internal fun coerceUserValue(value: Float): Float {
Expand All @@ -157,10 +167,18 @@ public class SliderState(
private fun coerceRangeIntoFractions(
subrange: ClosedFloatingPointRange<Float>,
): ClosedFloatingPointRange<Float> {
if (subrange.isEmpty()) return subrange
val start = valueDistribution.inverse(subrange.start.valueToFraction(range))
val end = valueDistribution.inverse(subrange.endInclusive.valueToFraction(range))
return start..end
val inverseRange = valueDistribution.inverse(range)
val inverseSubrange = valueDistribution.inverse(subrange)

return calcFraction(
inverseRange.start,
inverseRange.endInclusive,
inverseSubrange.start,
)..calcFraction(
inverseRange.start,
inverseRange.endInclusive,
inverseSubrange.endInclusive,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package io.monstarlab.mosaic.slider.distribution
import io.monstarlab.mosaic.slider.math.LinearEquation
import io.monstarlab.mosaic.slider.math.Point
import io.monstarlab.mosaic.slider.math.RangedLinearEquation
import io.monstarlab.mosaic.slider.math.valueToFraction

/**
* Represents a distribution strategy for slider values based on a list of check points.
Expand All @@ -24,31 +23,28 @@ public class CheckPointsValuesDistribution(
private var equations: List<RangedLinearEquation>

init {
require(valuesMap.isNotEmpty()) {
val max = requireNotNull(valuesMap.maxByOrNull { it.first }?.second) {
"Values map can't be empty"
}

val offsetRange = valuesMap.minOf { it.first }..valuesMap.maxOf { it.first }
val valueRange = valuesMap.minOf { it.second }..valuesMap.maxOf { it.second }

equations = valuesMap.sortedBy { it.first }
.zipWithNext()
.checkIncreasingValues() // check if values are always increasing
.map {
val x1Fraction = it.first.first.valueToFraction(offsetRange)
val x2Fraction = it.second.first.valueToFraction(offsetRange)
val y1Fraction = it.first.second.valueToFraction(valueRange)
val y2Fraction = it.second.second.valueToFraction(valueRange)
val x1 = it.first.first * max
val x2 = it.second.first * max
val y1 = it.first.second
val y2 = it.second.second
val equation = LinearEquation.fromTwoPoints(
x1 = x1Fraction,
x2 = x2Fraction,
y1 = y1Fraction,
y2 = y2Fraction,
x1 = x1,
x2 = x2,
y1 = y1,
y2 = y2,
)
RangedLinearEquation(
equation = equation,
offsetRange = x1Fraction..x2Fraction,
valueRange = y1Fraction..y2Fraction,
offsetRange = x1..x2,
valueRange = y1..y2,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.monstarlab.mosaic.slider.distribution

internal fun SliderValuesDistribution.inverse(
range: ClosedFloatingPointRange<Float>,
): ClosedFloatingPointRange<Float> {
if (range.isEmpty()) return range
println("inverse ${range.start } ${range.endInclusive}")
return inverse(range.start)..inverse(range.endInclusive)
}

internal fun SliderValuesDistribution.interpolate(
range: ClosedFloatingPointRange<Float>,
): ClosedFloatingPointRange<Float> {
if (range.isEmpty()) return range
return interpolate(range.start)..interpolate(range.endInclusive)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package io.monstarlab.mosaic.slider.distribution

import androidx.annotation.FloatRange

/**
* Determines how the values will be distributed across the slider
* Usually the values are distributed in a linear fashion, this interfaces allows
Expand All @@ -12,18 +10,18 @@ public interface SliderValuesDistribution {

/**
* Interpolates a value based on the distribution strategy.
* @param value the normalized input value to interpolate, it must be between 0 and 1
* @return the normalized interpolated value based on the distribution strategy, between 0 and 1
* @param value the normalized input value to interpolate
* @return the interpolated value based on the distribution strategy
*/
public fun interpolate(@FloatRange(0.0, 1.0) value: Float): Float
public fun interpolate(value: Float): Float

/**
* Inversely interpolates a value from the output range to the input range based on the distribution strategy.
*
* @param value the normalized value to inverse interpolate, must be between 0 and 1
* @return the normalized inverse interpolated value based on the distribution strategy, between 0 and 1
* @param value to inverse interpolate
* @return inverse interpolated value based on the distribution strategy
*/
public fun inverse(@FloatRange(0.0, 1.0) value: Float): Float
public fun inverse(value: Float): Float

public companion object {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package io.monstarlab.mosaic.slider.math

internal data class LinearEquation(
private val m: Float,
private val c: Float,
private val m: Double,
private val c: Double,
) {
internal fun valueFromOffset(offset: Float): Float = m * offset + c
internal fun valueFromOffset(offset: Float): Float {
return (m * offset + c).toFloat()
}

internal fun offsetFromValue(value: Float): Float = (value - c) / m
internal fun offsetFromValue(value: Float): Float {
return ((value - c) / m).toFloat()
}

internal companion object {
internal fun fromTwoPoints(x1: Float, y1: Float, x2: Float, y2: Float): LinearEquation {
require(x2 != x1) { "can't calc equation from points with similar x value" }
val slope = (y2 - y1) / (x2 - x1)
val slope = (y2.toDouble() - y1) / (x2 - x1)
val c = y2 - slope * x2
return LinearEquation(slope, c)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.monstarlab.mosaic.slider.math

import androidx.compose.ui.util.lerp
import kotlin.math.pow
import kotlin.math.roundToInt

internal fun scale(a1: Float, b1: Float, x1: Float, a2: Float, b2: Float) =
lerp(a2, b2, calcFraction(a1, b1, x1))
Expand All @@ -17,5 +19,7 @@ internal fun Float.valueToFraction(range: ClosedFloatingPointRange<Float>) =
internal fun Float.fractionToValue(rangeStart: Float, rangeEnd: Float): Float =
scale(0f, 1f, coerceIn(0f, 1f), rangeStart, rangeEnd)

internal fun Float.fractionToValue(range: ClosedFloatingPointRange<Float>): Float =
fractionToValue(range.start, range.endInclusive)
internal fun Float.roundFractionToDigits(digits: Int): Float {
val factor = 10.0.pow(digits)
return (this * factor).roundToInt() / factor.toFloat()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ class CheckPointsValueDistributionTest {
checkPointsValueDistribution = CheckPointsValuesDistribution(
listOf(
0f to 0f,
25f to 25f,
50f to 75f,
100f to 100f,
0.25f to 25f,
0.50f to 75f,
1f to 100f,
),
)
}
Expand All @@ -45,12 +45,12 @@ class CheckPointsValueDistributionTest {
@Test
fun `create from pairs and interpolate`() {
val points = listOf(
0.1f to 0.1f,
0.2f to 0.2f,
0.25f to 0.25f,
0.4f to 0.55f,
0.6f to 0.8f,
0.75f to 0.875f,
10f to 10f,
20f to 20f,
25f to 25f,
40f to 55f,
60f to 80f,
70.5f to 85.25f,
)
points.forEach {
assertEquals(it.second, checkPointsValueDistribution.interpolate(it.first), accuracy)
Expand All @@ -60,12 +60,12 @@ class CheckPointsValueDistributionTest {
@Test
fun `create from pairs and inverse`() {
val points = listOf(
0.1f to 0.1f,
0.2f to 0.2f,
0.25f to 0.25f,
0.4f to 0.55f,
0.6f to 0.8f,
0.75f to 0.875f,
10f to 10f,
20f to 20f,
25f to 25f,
40f to 55f,
60f to 80f,
70.5f to 85.25f,
)
points.forEach {
assertEquals(it.first, checkPointsValueDistribution.inverse(it.second), accuracy)
Expand Down

0 comments on commit ec914e2

Please sign in to comment.