From 9007cd84501b8581d281d368f3518f2b88d9de32 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Sun, 19 Apr 2026 15:06:24 +0200 Subject: [PATCH 1/9] Replace Material3 Slider with custom implementation for horizontal slider --- .../widgets/range/HorizontalRangeSlider.kt | 76 ++++++++++++------- .../collect/android/widgets/range/Thumb.kt | 27 ++++--- .../collect/android/widgets/range/Track.kt | 29 +++---- 3 files changed, 77 insertions(+), 55 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index 6d96819f3d3..bd34fc3ced5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -1,30 +1,31 @@ package org.odk.collect.android.widgets.range -import android.view.MotionEvent +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.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInteropFilter +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.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) @Composable fun HorizontalRangeSlider( value: Float?, @@ -40,40 +41,51 @@ 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( + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.horizontal_slider) + val layoutDirection = LocalLayoutDirection.current + + Box( modifier = Modifier .fillMaxWidth() + .height(48.dp) .semantics { contentDescription = sliderContentDescription } - .pointerInteropFilter { event -> - if (enabled && event.action == MotionEvent.ACTION_DOWN) { - onValueChanging(true) - if (value == null) { - onValueChange(0f) + .pointerInput(enabled, steps) { + if (enabled) { + awaitEachGesture { + val trackWidth = size.width.toFloat() + 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() } } - false - }, - value = value ?: 0f, - steps = steps, - onValueChange = onValueChange, - onValueChangeFinished = { - onValueChanging(false) - onValueChangeFinished() - }, - thumb = {}, - track = { Track(it, ticks) }, - enabled = enabled + } + .align(Alignment.Center) + ) + + Track( + modifier = Modifier.align(Alignment.Center), + value = value, + ticks = ticks ) val thumbValue = value ?: placeholder if (thumbValue != null) { - Box( + Thumb( modifier = Modifier .offset { calculateOffset( @@ -83,9 +95,8 @@ fun HorizontalRangeSlider( isVertical = false ) } - .pointerInteropFilter { false } .align(Alignment.CenterStart) - ) { Thumb(value = thumbValue) } + ) } } @@ -149,3 +160,10 @@ private fun HorizontalStepLabels(labels: List) { } } } + +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 +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt index 4f797f64ab3..eb8af29f875 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt @@ -1,11 +1,14 @@ package org.odk.collect.android.widgets.range -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width -import androidx.compose.material3.SliderDefaults +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember 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 @@ -14,15 +17,15 @@ import androidx.compose.ui.unit.dp const val THUMB_WIDTH = 6 @Composable -fun Thumb(value: Float?) { +fun Thumb(modifier: Modifier) { val sliderThumbContentDescription = stringResource(org.odk.collect.strings.R.string.slider_thumb) - if (value != null) { - SliderDefaults.Thumb( - modifier = Modifier - .width(THUMB_WIDTH.dp) - .semantics { contentDescription = sliderThumbContentDescription }, - interactionSource = remember { MutableInteractionSource() } - ) - } + Box( + modifier = modifier + .width(THUMB_WIDTH.dp) + .height(20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .semantics { contentDescription = sliderThumbContentDescription } + ) } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt index daedba1e563..b764dc73cd9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt @@ -8,9 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SliderState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -20,25 +18,28 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun Track(sliderState: SliderState, ticks: Int) { +fun Track( + modifier: Modifier = Modifier, + value: Float?, + ticks: Int +) { Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(20.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer) ) { - Box( - modifier = Modifier - .fillMaxWidth( - fraction = (sliderState.value - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start) - ) - .height(20.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) + if (value != null) { + Box( + modifier = Modifier + .fillMaxWidth(fraction = value) + .height(20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) + } Row( modifier = Modifier From 31b9a4af8cdd4c11667ad624223c254e480c089a Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Sun, 19 Apr 2026 15:41:31 +0200 Subject: [PATCH 2/9] Replace Material3 Slider with custom implementation for vertical slider --- .../odk/collect/android/widgets/range/Tick.kt | 30 +++ .../collect/android/widgets/range/Track.kt | 20 -- .../widgets/range/VerticalRangeSlider.kt | 253 +++++++++++------- 3 files changed, 180 insertions(+), 123 deletions(-) create mode 100644 collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt new file mode 100644 index 00000000000..3a42470344b --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt @@ -0,0 +1,30 @@ +package org.odk.collect.android.widgets.range + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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() { + val sliderTickContentDescription = stringResource(org.odk.collect.strings.R.string.slider_tick) + val tickWidth = 4.dp + + Box( + modifier = Modifier + .size(tickWidth) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onPrimary) + .semantics { + contentDescription = sliderTickContentDescription + } + ) +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt index b764dc73cd9..74bf71e3674 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt @@ -6,16 +6,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -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.Alignment 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 @@ -52,19 +48,3 @@ fun Track( } } } - -@Composable -private fun Tick() { - val sliderTickContentDescription = stringResource(org.odk.collect.strings.R.string.slider_tick) - val tickWidth = 4.dp - - Box( - modifier = Modifier - .size(tickWidth) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onPrimary) - .semantics { - contentDescription = sliderTickContentDescription - } - ) -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt index d0a48a85b3b..aeb552da9f5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt @@ -1,31 +1,29 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.layout.layout -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension @@ -35,7 +33,6 @@ import kotlin.math.roundToInt private const val SLIDER_HEIGHT = 330 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun VerticalRangeSlider( value: Float?, @@ -53,95 +50,158 @@ fun VerticalRangeSlider( ) { val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.vertical_slider) - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - ConstraintLayout(Modifier.fillMaxWidth()) { - val (valueLabelRef, sliderRef, edgeLabelsRef, stepLabelsRef) = createRefs() + ConstraintLayout(Modifier.fillMaxWidth()) { + val (valueLabelRef, sliderRef, edgeLabelsRef, stepLabelsRef) = createRefs() + val margin = dimensionResource(id = dimen.margin_standard) - BoxWithConstraints( - modifier = Modifier - .height(SLIDER_HEIGHT.dp) - .constrainAs(sliderRef) { centerHorizontallyTo(parent) } - ) { - Slider( - modifier = Modifier - .semantics { contentDescription = sliderContentDescription } - .rotateVertically() - .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 - ) - - val thumbValue = value ?: placeholder - if (thumbValue != null) { - Box( - modifier = Modifier - .offset { - calculateOffset( - trackSize = constraints.maxHeight, - itemWidth = THUMB_WIDTH.dp.toPx(), - value = thumbValue, - isVertical = true - ) - } - .rotateVertically() - .pointerInteropFilter { false } - .align(Alignment.TopCenter) - ) { Thumb(value = thumbValue) } - } + ValueLabel( + valueLabel, + modifier = Modifier.constrainAs(valueLabelRef) { + end.linkTo(sliderRef.start, margin = margin) + centerVerticallyTo(sliderRef) } + ) - val margin = dimensionResource(id = dimen.margin_standard) + VerticalTrack( + modifier = Modifier + .height(SLIDER_HEIGHT.dp) + .semantics { contentDescription = sliderContentDescription } + .constrainAs(sliderRef) { centerHorizontallyTo(parent) }, + value = value, + placeholder = placeholder, + ticks = ticks, + steps = steps, + enabled = enabled, + onValueChanging = onValueChanging, + onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished + ) - ValueLabel( - valueLabel, - modifier = Modifier.constrainAs(valueLabelRef) { - end.linkTo(sliderRef.start, margin = margin) + VerticalEdgeLabels( + startLabel, + endLabel, + modifier = Modifier + .height(SLIDER_HEIGHT.dp) + .constrainAs(edgeLabelsRef) { + start.linkTo(sliderRef.end, margin = margin) centerVerticallyTo(sliderRef) } - ) + ) - VerticalEdgeLabels( - startLabel, - endLabel, - modifier = Modifier - .height(SLIDER_HEIGHT.dp) - .constrainAs(edgeLabelsRef) { - start.linkTo(sliderRef.end, margin = margin) - centerVerticallyTo(sliderRef) + VerticalStepLabels( + labels, + modifier = Modifier + .height(SLIDER_HEIGHT.dp) + .constrainAs(stepLabelsRef) { + start.linkTo(edgeLabelsRef.end, margin = margin) + end.linkTo(parent.end, margin = margin) + width = Dimension.fillToConstraints + centerVerticallyTo(sliderRef) + } + ) + } +} + +@Composable +private fun VerticalTrack( + modifier: Modifier = Modifier, + value: Float?, + placeholder: Float?, + ticks: Int, + steps: Int = 0, + enabled: Boolean, + onValueChanging: (Boolean) -> Unit, + onValueChange: (Float) -> Unit, + onValueChangeFinished: () -> Unit +) { + BoxWithConstraints( + modifier = modifier + .width(48.dp) + .fillMaxHeight() + .pointerInput(steps) { + if (enabled) { + val trackHeight = size.height.toFloat() + awaitEachGesture { + val down = awaitFirstDown() + onValueChanging(true) + onValueChange(positionToValue(down.position.y, steps, trackHeight)) + + do { + val event = awaitPointerEvent() + val pointer = event.changes.firstOrNull() ?: break + if (!pointer.pressed) break + pointer.consume() + onValueChange(positionToValue(pointer.position.y, steps, trackHeight)) + } while (true) + + onValueChanging(false) + onValueChangeFinished() } - ) + } + } + ) { + Box( + modifier = Modifier + .width(20.dp) + .fillMaxHeight() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .align(Alignment.Center) + ) { + if (value != null) { + Box( + modifier = Modifier + .fillMaxHeight(fraction = value) + .width(20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .align(Alignment.BottomCenter) + ) + } + + Column( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.Center), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally + ) { + repeat(ticks) { Tick() } + } + } - VerticalStepLabels( - labels, + val thumbValue = value ?: placeholder + if (thumbValue != null) { + VerticalThumb( modifier = Modifier - .height(SLIDER_HEIGHT.dp) - .constrainAs(stepLabelsRef) { - start.linkTo(edgeLabelsRef.end, margin = margin) - end.linkTo(parent.end, margin = margin) - width = Dimension.fillToConstraints - centerVerticallyTo(sliderRef) + .offset { + calculateOffset( + trackSize = constraints.maxHeight, + itemWidth = THUMB_WIDTH.dp.toPx(), + value = thumbValue, + isVertical = true + ) } + .align(Alignment.TopCenter) ) } } } +@Composable +private fun VerticalThumb(modifier: Modifier = Modifier) { + val sliderThumbContentDescription = stringResource(org.odk.collect.strings.R.string.slider_thumb) + + Box( + modifier = modifier + .width(40.dp) + .height(6.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .semantics { contentDescription = sliderThumbContentDescription } + ) +} + @Composable private fun VerticalEdgeLabels( labelStart: String, @@ -204,21 +264,8 @@ private fun VerticalStepLabels(labels: List, modifier: Modifier = Modifi } } -private fun Modifier.rotateVertically() = this - .graphicsLayer { - rotationZ = 270f - transformOrigin = TransformOrigin(0f, 0f) - } - .layout { measurable, constraints -> - val placeable = measurable.measure( - Constraints( - minWidth = constraints.minHeight, - maxWidth = constraints.maxHeight, - minHeight = constraints.minWidth, - maxHeight = constraints.maxHeight, - ) - ) - layout(placeable.height, placeable.width) { - placeable.place(-placeable.width, 0) - } - } +private fun positionToValue(y: Float, steps: Int, trackHeight: Float): Float { + val fraction = 1f - y.coerceIn(0f, trackHeight) / trackHeight + val divisions = steps + 1 + return (fraction * divisions).roundToInt().toFloat() / divisions +} From f1625c31b808481e6fc060078034996f19afc5a6 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Sun, 19 Apr 2026 16:01:34 +0200 Subject: [PATCH 3/9] Align HorizontalRangeSlider implementation with VerticalRangeSlider --- .../collect/androidshared/ui/OffsetUtils.kt | 4 +- .../widgets/range/HorizontalRangeSlider.kt | 163 ++++++++++++------ .../collect/android/widgets/range/Thumb.kt | 31 ---- .../collect/android/widgets/range/Track.kt | 50 ------ .../widgets/range/VerticalRangeSlider.kt | 5 +- 5 files changed, 117 insertions(+), 136 deletions(-) delete mode 100644 collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt delete mode 100644 collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/OffsetUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/OffsetUtils.kt index a1d92d5e278..5d05d8595b1 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/OffsetUtils.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/OffsetUtils.kt @@ -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) } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index bd34fc3ced5..b7b44c49a2c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -1,5 +1,6 @@ package org.odk.collect.android.widgets.range +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement @@ -11,9 +12,12 @@ 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.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 @@ -26,6 +30,8 @@ import androidx.compose.ui.unit.dp import org.odk.collect.androidshared.ui.OffsetUtils.calculateOffset import kotlin.math.roundToInt +private const val THUMB_WIDTH = 6 + @Composable fun HorizontalRangeSlider( value: Float?, @@ -44,67 +50,122 @@ fun HorizontalRangeSlider( Column(horizontalAlignment = Alignment.CenterHorizontally) { ValueLabel(valueLabel) - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.horizontal_slider) - val layoutDirection = LocalLayoutDirection.current - - Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .semantics { contentDescription = sliderContentDescription } - .pointerInput(enabled, steps) { - if (enabled) { - awaitEachGesture { - val trackWidth = size.width.toFloat() - 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() - } - } - } - .align(Alignment.Center) - ) + HorizontalTrack( + value = value, + placeholder = placeholder, + ticks = ticks, + steps = steps, + enabled = enabled, + onValueChanging = onValueChanging, + onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished + ) - Track( - modifier = Modifier.align(Alignment.Center), - value = value, - ticks = ticks - ) + HorizontalEdgeLabels(startLabel, endLabel) + HorizontalStepLabels(labels) + } +} - val thumbValue = value ?: placeholder - if (thumbValue != null) { - Thumb( +@Composable +private fun HorizontalTrack( + value: Float?, + placeholder: Float?, + ticks: Int, + steps: Int = 0, + enabled: Boolean, + onValueChanging: ((Boolean) -> Unit), + onValueChange: ((Float) -> Unit), + onValueChangeFinished: (() -> Unit) +) { + val layoutDirection = LocalLayoutDirection.current + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .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() + } + } + } + ) { + 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 - ) - } - .align(Alignment.CenterStart) + .fillMaxWidth(fraction = value) + .height(20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) ) } + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(ticks) { Tick() } + } } - 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) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt deleted file mode 100644 index eb8af29f875..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.odk.collect.android.widgets.range - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -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 - -const val THUMB_WIDTH = 6 - -@Composable -fun Thumb(modifier: Modifier) { - val sliderThumbContentDescription = stringResource(org.odk.collect.strings.R.string.slider_thumb) - - Box( - modifier = modifier - .width(THUMB_WIDTH.dp) - .height(20.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .semantics { contentDescription = sliderThumbContentDescription } - ) -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt deleted file mode 100644 index 74bf71e3674..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.odk.collect.android.widgets.range - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp - -@Composable -fun Track( - modifier: Modifier = Modifier, - value: Float?, - ticks: Int -) { - Box( - modifier = modifier - .fillMaxWidth() - .height(20.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer) - ) { - if (value != null) { - Box( - modifier = Modifier - .fillMaxWidth(fraction = value) - .height(20.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - repeat(ticks) { Tick() } - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt index aeb552da9f5..111a15be766 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt @@ -32,6 +32,7 @@ import org.odk.collect.androidshared.ui.OffsetUtils.calculateOffset import kotlin.math.roundToInt private const val SLIDER_HEIGHT = 330 +private const val THUMB_HEIGHT = 6 @Composable fun VerticalRangeSlider( @@ -177,7 +178,7 @@ private fun VerticalTrack( .offset { calculateOffset( trackSize = constraints.maxHeight, - itemWidth = THUMB_WIDTH.dp.toPx(), + itemSize = THUMB_HEIGHT.dp.toPx(), value = thumbValue, isVertical = true ) @@ -195,7 +196,7 @@ private fun VerticalThumb(modifier: Modifier = Modifier) { Box( modifier = modifier .width(40.dp) - .height(6.dp) + .height(THUMB_HEIGHT.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) .semantics { contentDescription = sliderThumbContentDescription } From f5325a770fc17752a0822fe758c88cb3835a350d Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Sun, 19 Apr 2026 16:37:17 +0200 Subject: [PATCH 4/9] Fix tests --- .../collect/androidshared/ui/OffsetUtilsTest.kt | 14 +++++++------- .../android/widgets/range/HorizontalRangeSlider.kt | 2 ++ .../android/widgets/range/VerticalRangeSlider.kt | 6 +++--- .../widgets/range/RangeDecimalWidgetTest.kt | 12 +++++++++--- .../widgets/range/RangeIntegerWidgetTest.kt | 14 ++++++++++---- .../android/widgets/range/RangeSliderTest.kt | 13 ++----------- 6 files changed, 33 insertions(+), 28 deletions(-) diff --git a/androidshared/src/test/java/org/odk/collect/androidshared/ui/OffsetUtilsTest.kt b/androidshared/src/test/java/org/odk/collect/androidshared/ui/OffsetUtilsTest.kt index 1ca56f9d1cd..16ff9b9d11b 100644 --- a/androidshared/src/test/java/org/odk/collect/androidshared/ui/OffsetUtilsTest.kt +++ b/androidshared/src/test/java/org/odk/collect/androidshared/ui/OffsetUtilsTest.kt @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) @@ -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 ) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index b7b44c49a2c..cf2c964116c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -77,6 +77,7 @@ private fun HorizontalTrack( onValueChange: ((Float) -> Unit), onValueChangeFinished: (() -> Unit) ) { + val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.horizontal_slider) val layoutDirection = LocalLayoutDirection.current BoxWithConstraints( @@ -104,6 +105,7 @@ private fun HorizontalTrack( } } } + .semantics { contentDescription = sliderContentDescription } ) { Box( modifier = Modifier diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt index 111a15be766..b396f67daeb 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt @@ -49,8 +49,6 @@ fun VerticalRangeSlider( onValueChangeFinished: () -> Unit, onValueChange: (Float) -> Unit ) { - val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.vertical_slider) - ConstraintLayout(Modifier.fillMaxWidth()) { val (valueLabelRef, sliderRef, edgeLabelsRef, stepLabelsRef) = createRefs() val margin = dimensionResource(id = dimen.margin_standard) @@ -66,7 +64,6 @@ fun VerticalRangeSlider( VerticalTrack( modifier = Modifier .height(SLIDER_HEIGHT.dp) - .semantics { contentDescription = sliderContentDescription } .constrainAs(sliderRef) { centerHorizontallyTo(parent) }, value = value, placeholder = placeholder, @@ -115,6 +112,8 @@ private fun VerticalTrack( onValueChange: (Float) -> Unit, onValueChangeFinished: () -> Unit ) { + val sliderContentDescription = stringResource(org.odk.collect.strings.R.string.vertical_slider) + BoxWithConstraints( modifier = modifier .width(48.dp) @@ -140,6 +139,7 @@ private fun VerticalTrack( } } } + .semantics { contentDescription = sliderContentDescription } ) { Box( modifier = Modifier diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.kt index d0e262be6a5..a28ba040d9f 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.kt @@ -2,7 +2,6 @@ package org.odk.collect.android.widgets.range import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.click import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -134,13 +133,20 @@ class RangeDecimalWidgetTest : QuestionWidgetTest Date: Sat, 9 May 2026 16:15:40 +0200 Subject: [PATCH 5/9] Fix range slider progress bar clipping --- .../odk/collect/android/widgets/range/HorizontalRangeSlider.kt | 1 - .../org/odk/collect/android/widgets/range/VerticalRangeSlider.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index cf2c964116c..3d3a7e0bc88 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -120,7 +120,6 @@ private fun HorizontalTrack( modifier = Modifier .fillMaxWidth(fraction = value) .height(20.dp) - .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) ) } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt index b396f67daeb..d33d89ffaff 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt @@ -154,7 +154,6 @@ private fun VerticalTrack( modifier = Modifier .fillMaxHeight(fraction = value) .width(20.dp) - .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) .align(Alignment.BottomCenter) ) From 314f9bfa6328014ef36a3e45ce72cea60555fc56 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Mon, 11 May 2026 09:14:53 +0200 Subject: [PATCH 6/9] Add Compose Previews for range sliders --- .../widgets/range/HorizontalRangeSlider.kt | 23 +++++++++++++++++++ .../widgets/range/VerticalRangeSlider.kt | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index 3d3a7e0bc88..e4ffdc53ad4 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape 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 @@ -24,6 +25,7 @@ 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 @@ -229,3 +231,24 @@ private fun positionToValue(position: Float, steps: Int, trackWidth: Float, layo 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 = {} + ) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt index d33d89ffaff..df8a9d3a107 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape 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 @@ -24,6 +25,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension @@ -269,3 +271,24 @@ private fun positionToValue(y: Float, steps: Int, trackHeight: Float): Float { val divisions = steps + 1 return (fraction * divisions).roundToInt().toFloat() / divisions } + +@Preview +@Composable +private fun VerticalRangeSliderPreview() { + Surface { + VerticalRangeSlider( + 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 = {} + ) + } +} From d07110560f752721346613d0976eaf70c4921e29 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Thu, 14 May 2026 22:05:18 +0200 Subject: [PATCH 7/9] Hide first and last ticks in RangeSlider --- .../widgets/range/HorizontalRangeSlider.kt | 4 ++- .../odk/collect/android/widgets/range/Tick.kt | 28 +++++++++++-------- .../widgets/range/VerticalRangeSlider.kt | 4 ++- .../android/widgets/range/RangeSliderTest.kt | 6 ++-- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index e4ffdc53ad4..ffb31f65ed2 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -133,7 +133,9 @@ private fun HorizontalTrack( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - repeat(ticks) { Tick() } + repeat(ticks) { index -> + Tick(isEdgeTick = index == 0 || index == ticks - 1) + } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt index 3a42470344b..e4d8b1baeaf 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt @@ -2,6 +2,7 @@ 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 @@ -14,17 +15,22 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @Composable -fun Tick() { - val sliderTickContentDescription = stringResource(org.odk.collect.strings.R.string.slider_tick) +fun Tick(isEdgeTick: Boolean = true) { val tickWidth = 4.dp - Box( - modifier = Modifier - .size(tickWidth) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onPrimary) - .semantics { - contentDescription = sliderTickContentDescription - } - ) + 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 + } + ) + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt index df8a9d3a107..091189ff57e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt @@ -168,7 +168,9 @@ private fun VerticalTrack( verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.CenterHorizontally ) { - repeat(ticks) { Tick() } + repeat(ticks) { index -> + Tick(isEdgeTick = index == 0 || index == ticks - 1) + } } } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeSliderTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeSliderTest.kt index 16102793bf1..ac51ac9af6c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeSliderTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeSliderTest.kt @@ -149,7 +149,7 @@ class RangeSliderTest { } @Test - fun `displays ticks when numOfTicks is greater than 0`() { + fun `displays ticks except for the first and last when ticks parameter is greater than 0`() { setContent(ticks = 3) composeTestRule @@ -157,11 +157,11 @@ class RangeSliderTest { org.odk.collect.strings.R.string.slider_tick, useUnmergedTree = true ) - .assertCountEquals(3) + .assertCountEquals(1) } @Test - fun `does not display ticks when numOfTicks is 0`() { + fun `does not display ticks when ticks parameter is 0`() { setContent(ticks = 0) composeTestRule From f8255ac5b81b2a2ac4d3b354e840c2fd03535c02 Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Mon, 18 May 2026 15:41:38 +0200 Subject: [PATCH 8/9] Exclude horizontal slider from system gestures --- .../odk/collect/android/widgets/range/HorizontalRangeSlider.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index ffb31f65ed2..5847000a7fc 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width 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 @@ -86,6 +87,7 @@ private fun HorizontalTrack( modifier = Modifier .fillMaxWidth() .height(48.dp) + .systemGestureExclusion() .pointerInput(steps, layoutDirection) { if (enabled) { val trackWidth = size.width.toFloat() From d08a0f55b91ad46502c26c4e258bd2ecc53323ac Mon Sep 17 00:00:00 2001 From: Grzegorz Orczykowski Date: Sun, 31 May 2026 00:51:05 +0200 Subject: [PATCH 9/9] Reduce duplication in range slider components --- .../widgets/range/HorizontalRangeSlider.kt | 206 +---------- .../widgets/range/RangeSliderComponents.kt | 336 ++++++++++++++++++ .../widgets/range/VerticalRangeSlider.kt | 220 +----------- 3 files changed, 357 insertions(+), 405 deletions(-) create mode 100644 collect_app/src/main/java/org/odk/collect/android/widgets/range/RangeSliderComponents.kt diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt index 5847000a7fc..c03fa0c8b86 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/HorizontalRangeSlider.kt @@ -1,39 +1,11 @@ package org.odk.collect.android.widgets.range -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.gestures.Orientation +import androidx.compose.ui.Alignment 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.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.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 - -private const val THUMB_WIDTH = 6 @Composable fun HorizontalRangeSlider( @@ -53,7 +25,8 @@ fun HorizontalRangeSlider( Column(horizontalAlignment = Alignment.CenterHorizontally) { ValueLabel(valueLabel) - HorizontalTrack( + RangeSliderTrack( + orientation = Orientation.Horizontal, value = value, placeholder = placeholder, ticks = ticks, @@ -64,178 +37,11 @@ fun HorizontalRangeSlider( onValueChangeFinished = onValueChangeFinished ) - HorizontalEdgeLabels(startLabel, endLabel) - HorizontalStepLabels(labels) + RangeSliderEdgeLabels(Orientation.Horizontal, startLabel, endLabel) + RangeSliderStepLabels(Orientation.Horizontal, labels) } } -@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 - .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) - } - } - } - - 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) - val sliderEndLabelContentDescription = stringResource(org.odk.collect.strings.R.string.slider_end_label) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Label( - modifier = Modifier.semantics { - contentDescription = sliderStartLabelContentDescription - }, - text = labelStart, - ) - Label( - modifier = Modifier.semantics { - contentDescription = sliderEndLabelContentDescription - }, - text = labelEnd, - ) - } -} - -@Composable -private fun HorizontalStepLabels(labels: List) { - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val totalSteps = labels.size - 1 - val labelWidth = maxWidth / 5 // Each label takes up a fifth of the track width. Confirmed to look good in most cases. - - labels.forEachIndexed { index, label -> - if (label.isBlank()) return@forEachIndexed - - val modifier = when (index) { - 0 -> Modifier.align(Alignment.TopStart) - totalSteps -> Modifier.align(Alignment.TopEnd) - else -> Modifier.offset { - val fraction = index.toFloat() / totalSteps - val centerX = (constraints.maxWidth * fraction).roundToInt() - IntOffset(centerX - labelWidth.roundToPx() / 2, 0) - } - } - - Label( - modifier = modifier.width(labelWidth), - text = label, - textAlign = when (index) { - 0 -> TextAlign.Start - totalSteps -> TextAlign.End - else -> TextAlign.Center - } - ) - } - } -} - -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() { diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/RangeSliderComponents.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/RangeSliderComponents.kt new file mode 100644 index 00000000000..849b9f6377f --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/RangeSliderComponents.kt @@ -0,0 +1,336 @@ +package org.odk.collect.android.widgets.range + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +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.fillMaxHeight +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.foundation.shape.CircleShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.SubcomposeLayout +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.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 + +@Composable +fun RangeSliderTrack( + modifier: Modifier = Modifier, + orientation: Orientation, + value: Float?, + placeholder: Float?, + ticks: Int, + steps: Int = 0, + enabled: Boolean, + onValueChanging: (Boolean) -> Unit, + onValueChange: (Float) -> Unit, + onValueChangeFinished: () -> Unit +) { + val layoutDirection = LocalLayoutDirection.current + val sliderContentDescription = stringResource( + if (orientation == Orientation.Horizontal) org.odk.collect.strings.R.string.horizontal_slider + else org.odk.collect.strings.R.string.vertical_slider + ) + + BoxWithConstraints( + modifier = modifier + .then( + if (orientation == Orientation.Horizontal) { + Modifier.fillMaxWidth().height(INTERACTIVE_SIZE).systemGestureExclusion() + } else { + Modifier.fillMaxHeight().width(INTERACTIVE_SIZE) + } + ) + .pointerInput(steps, layoutDirection) { + if (enabled) { + val trackSize = if (orientation == Orientation.Horizontal) size.width.toFloat() else size.height.toFloat() + awaitEachGesture { + val down = awaitFirstDown() + onValueChanging(true) + onValueChange( + positionToValue( + if (orientation == Orientation.Horizontal) down.position.x else down.position.y, + steps, + trackSize, + orientation, + layoutDirection + ) + ) + + do { + val event = awaitPointerEvent() + val pointer = event.changes.firstOrNull() ?: break + if (!pointer.pressed) break + pointer.consume() + onValueChange( + positionToValue( + if (orientation == Orientation.Horizontal) pointer.position.x else pointer.position.y, + steps, + trackSize, + orientation, + layoutDirection + ) + ) + } while (true) + + onValueChanging(false) + onValueChangeFinished() + } + } + } + .semantics { contentDescription = sliderContentDescription } + ) { + Box( + modifier = Modifier + .then( + if (orientation == Orientation.Horizontal) { + Modifier.fillMaxWidth().height(TRACK_THICKNESS) + } else { + Modifier.fillMaxHeight().width(TRACK_THICKNESS) + } + ) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .align(Alignment.Center) + ) { + if (value != null) { + Box( + modifier = Modifier + .then( + if (orientation == Orientation.Horizontal) { + Modifier.fillMaxWidth(value).height(TRACK_THICKNESS) + } else { + Modifier.fillMaxHeight(value).width(TRACK_THICKNESS) + } + ) + .background(MaterialTheme.colorScheme.primary) + .then( + if (orientation == Orientation.Vertical) Modifier.align(Alignment.BottomCenter) + else Modifier + ) + ) + } + + if (orientation == Orientation.Horizontal) { + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.Center), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(ticks) { index -> + Tick(isEdgeTick = index == 0 || index == ticks - 1) + } + } + } else { + Column( + modifier = Modifier.fillMaxHeight().align(Alignment.Center), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally + ) { + repeat(ticks) { index -> + Tick(isEdgeTick = index == 0 || index == ticks - 1) + } + } + } + } + + val thumbValue = value ?: placeholder + if (thumbValue != null) { + RangeSliderThumb( + orientation = orientation, + modifier = Modifier + .offset { + calculateOffset( + trackSize = if (orientation == Orientation.Horizontal) constraints.maxWidth else constraints.maxHeight, + itemSize = THUMB_THICKNESS.toPx(), + value = thumbValue, + isVertical = orientation == Orientation.Vertical + ) + } + .align(if (orientation == Orientation.Horizontal) Alignment.CenterStart else Alignment.TopCenter) + ) + } + } +} + +@Composable +fun RangeSliderThumb( + orientation: Orientation, + modifier: Modifier = Modifier +) { + val sliderThumbContentDescription = stringResource(org.odk.collect.strings.R.string.slider_thumb) + + Box( + modifier = modifier + .then( + if (orientation == Orientation.Horizontal) { + Modifier.width(THUMB_THICKNESS).height(THUMB_LENGTH) + } else { + Modifier.width(THUMB_LENGTH).height(THUMB_THICKNESS) + } + ) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .semantics { contentDescription = sliderThumbContentDescription } + ) +} + +@Composable +fun RangeSliderEdgeLabels( + orientation: Orientation, + labelStart: String, + labelEnd: String, + modifier: Modifier = Modifier +) { + val sliderStartLabelContentDescription = stringResource(org.odk.collect.strings.R.string.slider_start_label) + val sliderEndLabelContentDescription = stringResource(org.odk.collect.strings.R.string.slider_end_label) + + if (orientation == Orientation.Horizontal) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Label( + modifier = Modifier.semantics { + contentDescription = sliderStartLabelContentDescription + }, + text = labelStart, + ) + Label( + modifier = Modifier.semantics { + contentDescription = sliderEndLabelContentDescription + }, + text = labelEnd, + ) + } + } else { + Column( + modifier = modifier, + verticalArrangement = Arrangement.SpaceBetween + ) { + Label( + modifier = Modifier.semantics { + contentDescription = sliderEndLabelContentDescription + }, + text = labelEnd, + ) + Label( + modifier = Modifier.semantics { + contentDescription = sliderStartLabelContentDescription + }, + text = labelStart, + ) + } + } +} + +@Composable +fun RangeSliderStepLabels( + orientation: Orientation, + labels: List, + modifier: Modifier = Modifier +) { + val totalSteps = labels.size - 1 + + if (orientation == Orientation.Horizontal) { + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val labelWidth = maxWidth / 5 // Each label takes up a fifth of the track width. Confirmed to look good in most cases. + + labels.forEachIndexed { index, label -> + if (label.isBlank()) return@forEachIndexed + + val labelModifier = when (index) { + 0 -> Modifier.align(Alignment.TopStart) + totalSteps -> Modifier.align(Alignment.TopEnd) + else -> Modifier.offset { + val fraction = index.toFloat() / totalSteps + val centerX = (constraints.maxWidth * fraction).roundToInt() + IntOffset(centerX - labelWidth.roundToPx() / 2, 0) + } + } + + Label( + modifier = labelModifier.width(labelWidth), + text = label, + textAlign = when (index) { + 0 -> TextAlign.Start + totalSteps -> TextAlign.End + else -> TextAlign.Center + } + ) + } + } + } else { + Box(modifier = modifier) { + SubcomposeLayout { constraints -> + val placeable = labels.mapIndexed { index, label -> + if (label.isBlank()) return@mapIndexed null + + val measurables = subcompose(index) { + Label(modifier = Modifier, text = label) + } + val placeable = measurables.first().measure(constraints) + val fraction = if (totalSteps > 0) index.toFloat() / totalSteps else 0f + + val y = when (index) { + 0 -> constraints.maxHeight - placeable.height + totalSteps -> 0 + else -> (constraints.maxHeight * (1 - fraction) - placeable.height / 2).roundToInt() + } + + index to Triple(placeable, 0, y) + }.filterNotNull() + + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.forEach { (_, triple) -> + val (placeable, x, y) = triple + placeable.placeRelative(x, y) + } + } + } + } + } +} + +private fun positionToValue( + position: Float, + steps: Int, + trackSize: Float, + orientation: Orientation, + layoutDirection: LayoutDirection +): Float { + val fraction = if (orientation == Orientation.Horizontal) { + val adjustedPosition = if (layoutDirection == LayoutDirection.Rtl) trackSize - position else position + adjustedPosition.coerceIn(0f, trackSize) / trackSize + } else { + 1f - position.coerceIn(0f, trackSize) / trackSize + } + val divisions = steps + 1 + return (fraction * divisions).roundToInt().toFloat() / divisions +} + +private val TRACK_THICKNESS = 20.dp +private val THUMB_LENGTH = 40.dp +private val THUMB_THICKNESS = 6.dp +private val INTERACTIVE_SIZE = 48.dp diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt index 091189ff57e..165665c2ee5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/VerticalRangeSlider.kt @@ -1,40 +1,17 @@ package org.odk.collect.android.widgets.range -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.fillMaxHeight +import androidx.compose.foundation.gestures.Orientation 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.foundation.shape.CircleShape -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.draw.clip -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import org.odk.collect.androidshared.R.dimen -import org.odk.collect.androidshared.ui.OffsetUtils.calculateOffset -import kotlin.math.roundToInt - -private const val SLIDER_HEIGHT = 330 -private const val THUMB_HEIGHT = 6 @Composable fun VerticalRangeSlider( @@ -63,10 +40,11 @@ fun VerticalRangeSlider( } ) - VerticalTrack( + RangeSliderTrack( modifier = Modifier - .height(SLIDER_HEIGHT.dp) + .height(SLIDER_HEIGHT) .constrainAs(sliderRef) { centerHorizontallyTo(parent) }, + orientation = Orientation.Vertical, value = value, placeholder = placeholder, ticks = ticks, @@ -77,21 +55,23 @@ fun VerticalRangeSlider( onValueChangeFinished = onValueChangeFinished ) - VerticalEdgeLabels( - startLabel, - endLabel, + RangeSliderEdgeLabels( + orientation = Orientation.Vertical, + labelStart = startLabel, + labelEnd = endLabel, modifier = Modifier - .height(SLIDER_HEIGHT.dp) + .height(SLIDER_HEIGHT) .constrainAs(edgeLabelsRef) { start.linkTo(sliderRef.end, margin = margin) centerVerticallyTo(sliderRef) } ) - VerticalStepLabels( - labels, + RangeSliderStepLabels( + orientation = Orientation.Vertical, + labels = labels, modifier = Modifier - .height(SLIDER_HEIGHT.dp) + .height(SLIDER_HEIGHT) .constrainAs(stepLabelsRef) { start.linkTo(edgeLabelsRef.end, margin = margin) end.linkTo(parent.end, margin = margin) @@ -102,178 +82,6 @@ fun VerticalRangeSlider( } } -@Composable -private fun VerticalTrack( - modifier: Modifier = Modifier, - 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.vertical_slider) - - BoxWithConstraints( - modifier = modifier - .width(48.dp) - .fillMaxHeight() - .pointerInput(steps) { - if (enabled) { - val trackHeight = size.height.toFloat() - awaitEachGesture { - val down = awaitFirstDown() - onValueChanging(true) - onValueChange(positionToValue(down.position.y, steps, trackHeight)) - - do { - val event = awaitPointerEvent() - val pointer = event.changes.firstOrNull() ?: break - if (!pointer.pressed) break - pointer.consume() - onValueChange(positionToValue(pointer.position.y, steps, trackHeight)) - } while (true) - - onValueChanging(false) - onValueChangeFinished() - } - } - } - .semantics { contentDescription = sliderContentDescription } - ) { - Box( - modifier = Modifier - .width(20.dp) - .fillMaxHeight() - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer) - .align(Alignment.Center) - ) { - if (value != null) { - Box( - modifier = Modifier - .fillMaxHeight(fraction = value) - .width(20.dp) - .background(MaterialTheme.colorScheme.primary) - .align(Alignment.BottomCenter) - ) - } - - Column( - modifier = Modifier - .fillMaxHeight() - .align(Alignment.Center), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally - ) { - repeat(ticks) { index -> - Tick(isEdgeTick = index == 0 || index == ticks - 1) - } - } - } - - val thumbValue = value ?: placeholder - if (thumbValue != null) { - VerticalThumb( - modifier = Modifier - .offset { - calculateOffset( - trackSize = constraints.maxHeight, - itemSize = THUMB_HEIGHT.dp.toPx(), - value = thumbValue, - isVertical = true - ) - } - .align(Alignment.TopCenter) - ) - } - } -} - -@Composable -private fun VerticalThumb(modifier: Modifier = Modifier) { - val sliderThumbContentDescription = stringResource(org.odk.collect.strings.R.string.slider_thumb) - - Box( - modifier = modifier - .width(40.dp) - .height(THUMB_HEIGHT.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .semantics { contentDescription = sliderThumbContentDescription } - ) -} - -@Composable -private fun VerticalEdgeLabels( - labelStart: String, - labelEnd: String, - modifier: Modifier = Modifier -) { - val sliderStartLabelContentDescription = stringResource(org.odk.collect.strings.R.string.slider_start_label) - val sliderEndLabelContentDescription = stringResource(org.odk.collect.strings.R.string.slider_end_label) - - Column( - modifier = modifier, - verticalArrangement = Arrangement.SpaceBetween - ) { - Label( - modifier = Modifier.semantics { - contentDescription = sliderEndLabelContentDescription - }, - text = labelEnd, - ) - Label( - modifier = Modifier.semantics { - contentDescription = sliderStartLabelContentDescription - }, - text = labelStart, - ) - } -} - -@Composable -private fun VerticalStepLabels(labels: List, modifier: Modifier = Modifier) { - Box(modifier = modifier) { - val totalSteps = labels.size - 1 - - SubcomposeLayout { constraints -> - val placeable = labels.mapIndexed { index, label -> - if (label.isBlank()) return@mapIndexed null - - val measurables = subcompose(index) { - Label(modifier = Modifier, text = label) - } - val placeable = measurables.first().measure(constraints) - val fraction = if (totalSteps > 0) index.toFloat() / totalSteps else 0f - - val y = when (index) { - 0 -> constraints.maxHeight - placeable.height - totalSteps -> 0 - else -> (constraints.maxHeight * (1 - fraction) - placeable.height / 2).roundToInt() - } - - index to Triple(placeable, 0, y) - }.filterNotNull() - - layout(constraints.maxWidth, constraints.maxHeight) { - placeable.forEach { (_, triple) -> - val (placeable, x, y) = triple - placeable.placeRelative(x, y) - } - } - } - } -} - -private fun positionToValue(y: Float, steps: Int, trackHeight: Float): Float { - val fraction = 1f - y.coerceIn(0f, trackHeight) / trackHeight - val divisions = steps + 1 - return (fraction * divisions).roundToInt().toFloat() / divisions -} - @Preview @Composable private fun VerticalRangeSliderPreview() { @@ -294,3 +102,5 @@ private fun VerticalRangeSliderPreview() { ) } } + +private val SLIDER_HEIGHT = 330.dp