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/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 6d96819f3d3..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,30 +1,12 @@ package org.odk.collect.android.widgets.range -import android.view.MotionEvent -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.offset -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider +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.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.dp -import org.odk.collect.androidshared.ui.OffsetUtils.calculateOffset -import kotlin.math.roundToInt +import androidx.compose.ui.tooling.preview.Preview -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HorizontalRangeSlider( value: Float?, @@ -40,112 +22,43 @@ 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 - ) - - val thumbValue = value ?: placeholder - if (thumbValue != 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) } - } - } + RangeSliderTrack( + orientation = Orientation.Horizontal, + value = value, + placeholder = placeholder, + ticks = ticks, + steps = steps, + enabled = enabled, + onValueChanging = onValueChanging, + onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished + ) - HorizontalEdgeLabels(startLabel, endLabel) - HorizontalStepLabels(labels) + RangeSliderEdgeLabels(Orientation.Horizontal, startLabel, endLabel) + RangeSliderStepLabels(Orientation.Horizontal, labels) } } +@Preview @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, +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 = {} ) } } - -@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 - } - ) - } - } -} 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/Thumb.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt deleted file mode 100644 index 4f797f64ab3..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Thumb.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.odk.collect.android.widgets.range - -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.width -import androidx.compose.material3.SliderDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -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(value: Float?) { - 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() } - ) - } -} 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..e4d8b1baeaf --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/range/Tick.kt @@ -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 + } + ) + } +} 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 daedba1e563..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/range/Track.kt +++ /dev/null @@ -1,69 +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.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 -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 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun Track(sliderState: SliderState, ticks: Int) { - Box( - 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) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - repeat(ticks) { Tick() } - } - } -} - -@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..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,41 +1,18 @@ package org.odk.collect.android.widgets.range -import android.view.MotionEvent -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.gestures.Orientation 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.material3.Surface 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.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.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 - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun VerticalRangeSlider( value: Float?, @@ -51,174 +28,79 @@ fun VerticalRangeSlider( onValueChangeFinished: () -> Unit, onValueChange: (Float) -> Unit ) { - 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() - - 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) } - } + ConstraintLayout(Modifier.fillMaxWidth()) { + val (valueLabelRef, sliderRef, edgeLabelsRef, stepLabelsRef) = createRefs() + val margin = dimensionResource(id = dimen.margin_standard) + + ValueLabel( + valueLabel, + modifier = Modifier.constrainAs(valueLabelRef) { + end.linkTo(sliderRef.start, margin = margin) + centerVerticallyTo(sliderRef) } + ) - val margin = dimensionResource(id = dimen.margin_standard) + RangeSliderTrack( + modifier = Modifier + .height(SLIDER_HEIGHT) + .constrainAs(sliderRef) { centerHorizontallyTo(parent) }, + orientation = Orientation.Vertical, + 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) + RangeSliderEdgeLabels( + orientation = Orientation.Vertical, + labelStart = startLabel, + labelEnd = endLabel, + modifier = Modifier + .height(SLIDER_HEIGHT) + .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 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, + + RangeSliderStepLabels( + orientation = Orientation.Vertical, + labels = labels, + modifier = Modifier + .height(SLIDER_HEIGHT) + .constrainAs(stepLabelsRef) { + start.linkTo(edgeLabelsRef.end, margin = margin) + end.linkTo(parent.end, margin = margin) + width = Dimension.fillToConstraints + centerVerticallyTo(sliderRef) + } ) } } +@Preview @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 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 = {} + ) } } -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 val SLIDER_HEIGHT = 330.dp 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