From e3be9988ad6bb450223fa16b46cdab01bb26b36a Mon Sep 17 00:00:00 2001 From: Roman Levinzon Date: Thu, 2 May 2024 14:02:54 +0200 Subject: [PATCH 1/4] feat: Revert normalized behavior from the distribution --- .../monstarlab/mosaic/slider/SliderState.kt | 47 +++++++++++++------ .../CheckPointsValuesDistribution.kt | 14 +++--- .../mosaic/slider/distribution/Extensions.kt | 15 ++++++ .../distribution/SliderValuesDistribution.kt | 13 +++-- 4 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt index 58c03b2..170dc99 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt @@ -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 @@ -70,8 +72,10 @@ 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 @@ -119,20 +123,25 @@ 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) + val invertedRange = valueDistribution.inverse(range) + 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 { @@ -157,10 +166,18 @@ public class SliderState( private fun coerceRangeIntoFractions( subrange: ClosedFloatingPointRange, ): ClosedFloatingPointRange { - 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, + ) } } diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt index 372b7ae..f19e8bc 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt @@ -27,18 +27,14 @@ public class CheckPointsValuesDistribution( require(valuesMap.isNotEmpty()) { "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 x1Fraction = it.first.first + val x2Fraction = it.second.first + val y1Fraction = it.first.second + val y2Fraction = it.second.second val equation = LinearEquation.fromTwoPoints( x1 = x1Fraction, x2 = x2Fraction, @@ -50,6 +46,8 @@ public class CheckPointsValuesDistribution( offsetRange = x1Fraction..x2Fraction, valueRange = y1Fraction..y2Fraction, ) + }.also { + println(it) } } diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt new file mode 100644 index 0000000..f13f1d5 --- /dev/null +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt @@ -0,0 +1,15 @@ +package io.monstarlab.mosaic.slider.distribution + +internal fun SliderValuesDistribution.inverse( + range: ClosedFloatingPointRange +): ClosedFloatingPointRange { + if (range.isEmpty()) return range + return inverse(range.start)..inverse(range.endInclusive) +} + +internal fun SliderValuesDistribution.interpolate( + range: ClosedFloatingPointRange +): ClosedFloatingPointRange { + if (range.isEmpty()) return range + return interpolate(range.start)..interpolate(range.endInclusive) +} \ No newline at end of file diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt index dc4cd24..3022dd2 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt @@ -1,6 +1,5 @@ package io.monstarlab.mosaic.slider.distribution -import androidx.annotation.FloatRange /** * Determines how the values will be distributed across the slider @@ -12,18 +11,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 { From 7f47744b470de5dd2b176603675cae1879abe81e Mon Sep 17 00:00:00 2001 From: Roman Levinzon Date: Thu, 2 May 2024 14:08:41 +0200 Subject: [PATCH 2/4] style: apply spotles --- .../io/monstarlab/mosaic/features/SliderDemo.kt | 13 +++++-------- .../java/io/monstarlab/mosaic/slider/SliderState.kt | 1 - .../distribution/CheckPointsValuesDistribution.kt | 1 - .../mosaic/slider/distribution/Extensions.kt | 6 +++--- .../slider/distribution/SliderValuesDistribution.kt | 3 +-- 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/demo/src/main/java/io/monstarlab/mosaic/features/SliderDemo.kt b/demo/src/main/java/io/monstarlab/mosaic/features/SliderDemo.kt index baecfa4..f9b666f 100644 --- a/demo/src/main/java/io/monstarlab/mosaic/features/SliderDemo.kt +++ b/demo/src/main/java/io/monstarlab/mosaic/features/SliderDemo.kt @@ -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 @@ -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, ) } diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt index 170dc99..89470f3 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt @@ -75,7 +75,6 @@ public class SliderState( val inverted = valueDistribution.inverse(value) val invertedRange = valueDistribution.inverse(range) inverted.valueToFraction(invertedRange) - } internal val disabledRangeAsFractions: ClosedFloatingPointRange diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt index f19e8bc..df854cd 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt @@ -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. diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt index f13f1d5..5d95780 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt @@ -1,15 +1,15 @@ package io.monstarlab.mosaic.slider.distribution internal fun SliderValuesDistribution.inverse( - range: ClosedFloatingPointRange + range: ClosedFloatingPointRange, ): ClosedFloatingPointRange { if (range.isEmpty()) return range return inverse(range.start)..inverse(range.endInclusive) } internal fun SliderValuesDistribution.interpolate( - range: ClosedFloatingPointRange + range: ClosedFloatingPointRange, ): ClosedFloatingPointRange { if (range.isEmpty()) return range return interpolate(range.start)..interpolate(range.endInclusive) -} \ No newline at end of file +} diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt index 3022dd2..145b563 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/SliderValuesDistribution.kt @@ -1,6 +1,5 @@ package io.monstarlab.mosaic.slider.distribution - /** * Determines how the values will be distributed across the slider * Usually the values are distributed in a linear fashion, this interfaces allows @@ -19,7 +18,7 @@ public interface SliderValuesDistribution { /** * Inversely interpolates a value from the output range to the input range based on the distribution strategy. * - * @param value to inverse interpolate + * @param value to inverse interpolate * @return inverse interpolated value based on the distribution strategy */ public fun inverse(value: Float): Float From 4b71aa291428375c1554045b02018afc821e5099 Mon Sep 17 00:00:00 2001 From: Roman Levinzon Date: Thu, 2 May 2024 14:10:18 +0200 Subject: [PATCH 3/4] docs: update slider.md --- docs/slider.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/slider.md b/docs/slider.md index 0eaed51..c241589 100644 --- a/docs/slider.md +++ b/docs/slider.md @@ -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 @@ -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. @@ -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 From 311c600a3d7b46ba6a6861a5735ebca1bc70cdbd Mon Sep 17 00:00:00 2001 From: Roman Levinzon Date: Thu, 2 May 2024 15:51:38 +0200 Subject: [PATCH 4/4] fix(Slider): Use double when calculating Linear Equation for better precision --- .../monstarlab/mosaic/slider/SliderState.kt | 4 ++- .../CheckPointsValuesDistribution.kt | 25 ++++++++-------- .../mosaic/slider/distribution/Extensions.kt | 1 + .../mosaic/slider/math/LinearEquation.kt | 14 +++++---- .../mosaic/slider/math/MathUtils.kt | 8 +++-- .../CheckPointsValueDistributionTest.kt | 30 +++++++++---------- 6 files changed, 46 insertions(+), 36 deletions(-) diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt index 89470f3..2e8a92f 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/SliderState.kt @@ -85,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) } @@ -122,7 +122,9 @@ public class SliderState( * Scales offset in to the value that user should see */ private fun scaleToUserValue(offset: Float): Float { + 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)) } diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt index df854cd..1978979 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/CheckPointsValuesDistribution.kt @@ -23,30 +23,29 @@ public class CheckPointsValuesDistribution( private var equations: List init { - require(valuesMap.isNotEmpty()) { + val max = requireNotNull(valuesMap.maxByOrNull { it.first }?.second) { "Values map can't be empty" } + equations = valuesMap.sortedBy { it.first } .zipWithNext() .checkIncreasingValues() // check if values are always increasing .map { - val x1Fraction = it.first.first - val x2Fraction = it.second.first - val y1Fraction = it.first.second - val y2Fraction = it.second.second + 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, ) - }.also { - println(it) } } diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt index 5d95780..2af202f 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/distribution/Extensions.kt @@ -4,6 +4,7 @@ internal fun SliderValuesDistribution.inverse( range: ClosedFloatingPointRange, ): ClosedFloatingPointRange { if (range.isEmpty()) return range + println("inverse ${range.start } ${range.endInclusive}") return inverse(range.start)..inverse(range.endInclusive) } diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/math/LinearEquation.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/math/LinearEquation.kt index e08e702..96dc4f4 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/math/LinearEquation.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/math/LinearEquation.kt @@ -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) } diff --git a/slider/src/main/java/io/monstarlab/mosaic/slider/math/MathUtils.kt b/slider/src/main/java/io/monstarlab/mosaic/slider/math/MathUtils.kt index 8c1acf3..9e2749b 100644 --- a/slider/src/main/java/io/monstarlab/mosaic/slider/math/MathUtils.kt +++ b/slider/src/main/java/io/monstarlab/mosaic/slider/math/MathUtils.kt @@ -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)) @@ -17,5 +19,7 @@ internal fun Float.valueToFraction(range: ClosedFloatingPointRange) = internal fun Float.fractionToValue(rangeStart: Float, rangeEnd: Float): Float = scale(0f, 1f, coerceIn(0f, 1f), rangeStart, rangeEnd) -internal fun Float.fractionToValue(range: ClosedFloatingPointRange): 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() +} diff --git a/slider/src/test/java/io/monstarlab/mosaic/slider/CheckPointsValueDistributionTest.kt b/slider/src/test/java/io/monstarlab/mosaic/slider/CheckPointsValueDistributionTest.kt index 787594c..05910c7 100644 --- a/slider/src/test/java/io/monstarlab/mosaic/slider/CheckPointsValueDistributionTest.kt +++ b/slider/src/test/java/io/monstarlab/mosaic/slider/CheckPointsValueDistributionTest.kt @@ -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, ), ) } @@ -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) @@ -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)