Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import kotlin.math.roundToInt
object OffsetUtils {
fun calculateOffset(
trackSize: Int,
itemWidth: Float,
itemSize: Float,
value: Float,
isVertical: Boolean
): IntOffset {
val fraction = if (isVertical) 1 - value else value
val offset = (trackSize * fraction - itemWidth * fraction).roundToInt()
val offset = (trackSize * fraction - itemSize * fraction).roundToInt()
return if (isVertical) IntOffset(0, offset) else IntOffset(offset, 0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class OffsetUtilsTest {
fun `calculateOffset returns zero offset when horizontal and value is 0`() {
val result = OffsetUtils.calculateOffset(
trackSize = 1000,
itemWidth = 100f,
itemSize = 100f,
value = 0f,
isVertical = false
)
Expand All @@ -21,7 +21,7 @@ class OffsetUtilsTest {
fun `calculateOffset returns max offset when horizontal and value is 1`() {
val result = OffsetUtils.calculateOffset(
trackSize = 1000,
itemWidth = 100f,
itemSize = 100f,
value = 1f,
isVertical = false
)
Expand All @@ -32,7 +32,7 @@ class OffsetUtilsTest {
fun `calculateOffset returns middle offset when horizontal and value is 0,5`() {
val result = OffsetUtils.calculateOffset(
trackSize = 1000,
itemWidth = 100f,
itemSize = 100f,
value = 0.5f,
isVertical = false
)
Expand All @@ -43,7 +43,7 @@ class OffsetUtilsTest {
fun `calculateOffset returns max offset when vertical and value is 0`() {
val result = OffsetUtils.calculateOffset(
trackSize = 1000,
itemWidth = 100f,
itemSize = 100f,
value = 0f,
isVertical = true
)
Expand All @@ -54,7 +54,7 @@ class OffsetUtilsTest {
fun `calculateOffset returns zero offset when vertical and value is 1`() {
val result = OffsetUtils.calculateOffset(
trackSize = 1000,
itemWidth = 100f,
itemSize = 100f,
value = 1f,
isVertical = true
)
Expand All @@ -65,7 +65,7 @@ class OffsetUtilsTest {
fun `calculateOffset returns middle offset when vertical and value is 0,5`() {
val result = OffsetUtils.calculateOffset(
trackSize = 1000,
itemWidth = 100f,
itemSize = 100f,
value = 0.5f,
isVertical = true
)
Expand All @@ -76,7 +76,7 @@ class OffsetUtilsTest {
fun `calculateOffset returns zero offset when itemWidth equals trackSize`() {
val result = OffsetUtils.calculateOffset(
trackSize = 100,
itemWidth = 100f,
itemSize = 100f,
value = 0.5f,
isVertical = false
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
package org.odk.collect.android.widgets.range

import android.view.MotionEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.odk.collect.androidshared.ui.OffsetUtils.calculateOffset
import kotlin.math.roundToInt

@OptIn(ExperimentalMaterial3Api::class)
private const val THUMB_WIDTH = 6

@Composable
fun HorizontalRangeSlider(
value: Float?,
Expand All @@ -40,60 +50,129 @@ fun HorizontalRangeSlider(
onValueChange: (Float) -> Unit,
onValueChangeFinished: () -> Unit
) {
val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.horizontal_slider)

Column(horizontalAlignment = Alignment.CenterHorizontally) {
ValueLabel(valueLabel)

BoxWithConstraints {
Slider(
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = sliderContentDescription }
.pointerInteropFilter { event ->
if (enabled && event.action == MotionEvent.ACTION_DOWN) {
onValueChanging(true)
if (value == null) {
onValueChange(0f)
}
}
false
},
value = value ?: 0f,
steps = steps,
onValueChange = onValueChange,
onValueChangeFinished = {
onValueChanging(false)
onValueChangeFinished()
},
thumb = {},
track = { Track(it, ticks) },
enabled = enabled
)
HorizontalTrack(
value = value,
placeholder = placeholder,
ticks = ticks,
steps = steps,
enabled = enabled,
onValueChanging = onValueChanging,
onValueChange = onValueChange,
onValueChangeFinished = onValueChangeFinished
)

HorizontalEdgeLabels(startLabel, endLabel)
HorizontalStepLabels(labels)
}
}

val thumbValue = value ?: placeholder
if (thumbValue != null) {
@Composable
private fun HorizontalTrack(
value: Float?,
placeholder: Float?,
ticks: Int,
steps: Int = 0,
enabled: Boolean,
onValueChanging: ((Boolean) -> Unit),
onValueChange: ((Float) -> Unit),
onValueChangeFinished: (() -> Unit)
) {
val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.horizontal_slider)
val layoutDirection = LocalLayoutDirection.current

BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.systemGestureExclusion()
.pointerInput(steps, layoutDirection) {
if (enabled) {
val trackWidth = size.width.toFloat()
awaitEachGesture {
val down = awaitFirstDown()
onValueChanging(true)
onValueChange(positionToValue(down.position.x, steps, trackWidth, layoutDirection))

do {
val event = awaitPointerEvent()
val pointer = event.changes.firstOrNull() ?: break
if (!pointer.pressed) break
pointer.consume()
onValueChange(positionToValue(pointer.position.x, steps, trackWidth, layoutDirection))
} while (true)

onValueChanging(false)
onValueChangeFinished()
}
}
}
.semantics { contentDescription = sliderContentDescription }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.align(Alignment.Center)
) {
if (value != null) {
Box(
modifier = Modifier
.offset {
calculateOffset(
trackSize = constraints.maxWidth,
itemWidth = THUMB_WIDTH.dp.toPx(),
value = thumbValue,
isVertical = false
)
}
.pointerInteropFilter { false }
.align(Alignment.CenterStart)
) { Thumb(value = thumbValue) }
.fillMaxWidth(fraction = value)
.height(20.dp)
.background(MaterialTheme.colorScheme.primary)
)
}

Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
repeat(ticks) { index ->
Tick(isEdgeTick = index == 0 || index == ticks - 1)
}
}
}

HorizontalEdgeLabels(startLabel, endLabel)
HorizontalStepLabels(labels)
val thumbValue = value ?: placeholder
if (thumbValue != null) {
HorizontalThumb(
modifier = Modifier
.offset {
calculateOffset(
trackSize = constraints.maxWidth,
itemSize = THUMB_WIDTH.dp.toPx(),
value = thumbValue,
isVertical = false
)
}
.align(Alignment.CenterStart)
)
}
}
}

@Composable
private fun HorizontalThumb(modifier: Modifier = Modifier) {
val sliderThumbContentDescription = stringResource(org.odk.collect.strings.R.string.slider_thumb)

Box(
modifier = modifier
.width(THUMB_WIDTH.dp)
.height(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.semantics { contentDescription = sliderThumbContentDescription }
)
}

@Composable
private fun HorizontalEdgeLabels(labelStart: String, labelEnd: String) {
val sliderStartLabelContentDescription = stringResource(org.odk.collect.strings.R.string.slider_start_label)
Expand Down Expand Up @@ -149,3 +228,31 @@ private fun HorizontalStepLabels(labels: List<String>) {
}
}
}

private fun positionToValue(position: Float, steps: Int, trackWidth: Float, layoutDirection: LayoutDirection): Float {
val adjustedPosition = if (layoutDirection == LayoutDirection.Rtl) trackWidth - position else position
val fraction = adjustedPosition.coerceIn(0f, trackWidth) / trackWidth
val divisions = steps + 1
return (fraction * divisions).roundToInt().toFloat() / divisions
}

@Preview
@Composable
private fun HorizontalRangeSliderPreview() {
Surface {
HorizontalRangeSlider(
value = 0.5f,
valueLabel = "5",
placeholder = null,
steps = 9,
ticks = 11,
enabled = true,
startLabel = "0",
endLabel = "10",
labels = listOf("very bad", "very good"),
onValueChanging = {},
onValueChange = {},
onValueChangeFinished = {}
)
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.odk.collect.android.widgets.range

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp

@Composable
fun Tick(isEdgeTick: Boolean = true) {
val tickWidth = 4.dp

if (isEdgeTick) {
Spacer(modifier = Modifier.size(tickWidth))
} else {
val sliderTickContentDescription = stringResource(org.odk.collect.strings.R.string.slider_tick)

Box(
modifier = Modifier
.size(tickWidth)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onPrimary)
.semantics {
contentDescription = sliderTickContentDescription
}
)
}
}
Loading