From 940000002b850d27e108f5a9315fc8f56a499442 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 9 Oct 2023 14:33:57 +0100 Subject: [PATCH] Support for Steppers with RSB built in (#1749) --- compose-material/api/current.api | 5 + compose-material/build.gradle.kts | 1 + .../horologist/compose/material/Stepper.kt | 178 ++++++++++++++++++ .../src/main/res/values/strings.xml | 2 + .../compose/material/StepperA11yTest.kt | 70 +++++++ .../compose/material/StepperTest.kt | 67 +++++++ ...compose.material_StepperA11yTest_float.png | 3 + ...t.compose.material_StepperA11yTest_int.png | 3 + ...ist.compose.material_StepperTest_float.png | 3 + ...ogist.compose.material_StepperTest_int.png | 3 + 10 files changed, 335 insertions(+) create mode 100644 compose-material/src/main/java/com/google/android/horologist/compose/material/Stepper.kt create mode 100644 compose-material/src/test/java/com/google/android/horologist/compose/material/StepperA11yTest.kt create mode 100644 compose-material/src/test/java/com/google/android/horologist/compose/material/StepperTest.kt create mode 100644 compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_float.png create mode 100644 compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_int.png create mode 100644 compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_float.png create mode 100644 compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_int.png diff --git a/compose-material/api/current.api b/compose-material/api/current.api index f520b38ee9..05e62bdbfb 100644 --- a/compose-material/api/current.api +++ b/compose-material/api/current.api @@ -89,6 +89,11 @@ package com.google.android.horologist.compose.material { method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static void SplitToggleChip(boolean checked, kotlin.jvm.functions.Function1 onCheckedChanged, String label, kotlin.jvm.functions.Function0 onClick, com.google.android.horologist.compose.material.ToggleChipToggleControl toggleControl, optional androidx.compose.ui.Modifier modifier, optional String? secondaryLabel, optional androidx.wear.compose.material.SplitToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource checkedInteractionSource, optional androidx.compose.foundation.interaction.MutableInteractionSource clickInteractionSource); } + public final class StepperKt { + method @androidx.compose.runtime.Composable public static void Stepper(float value, kotlin.jvm.functions.Function1 onValueChange, int steps, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0 decreaseIcon, optional kotlin.jvm.functions.Function0 increaseIcon, optional kotlin.ranges.ClosedFloatingPointRange valueRange, optional long backgroundColor, optional long contentColor, optional long iconColor, optional boolean enableRangeSemantics, kotlin.jvm.functions.Function1 content); + method @androidx.compose.runtime.Composable public static void Stepper(int value, kotlin.jvm.functions.Function1 onValueChange, kotlin.ranges.IntProgression valueProgression, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0 decreaseIcon, optional kotlin.jvm.functions.Function0 increaseIcon, optional long backgroundColor, optional long contentColor, optional long iconColor, optional boolean enableRangeSemantics, kotlin.jvm.functions.Function1 content); + } + public final class TitleKt { method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static void SecondaryTitle(@StringRes int textId, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.vector.ImageVector? icon, optional float iconSize, optional com.google.android.horologist.compose.material.IconRtlMode iconRtlMode); method @androidx.compose.runtime.Composable @com.google.android.horologist.annotations.ExperimentalHorologistApi public static void SecondaryTitle(String text, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.vector.ImageVector? icon, optional long iconTint, optional float iconSize, optional com.google.android.horologist.compose.material.IconRtlMode iconRtlMode); diff --git a/compose-material/build.gradle.kts b/compose-material/build.gradle.kts index 7975ba2b2b..c158edf0ce 100644 --- a/compose-material/build.gradle.kts +++ b/compose-material/build.gradle.kts @@ -97,6 +97,7 @@ metalava { dependencies { api(projects.annotations) + api(projects.composeLayout) api(libs.compose.foundation.foundation) api(libs.compose.foundation.foundation.layout) diff --git a/compose-material/src/main/java/com/google/android/horologist/compose/material/Stepper.kt b/compose-material/src/main/java/com/google/android/horologist/compose/material/Stepper.kt new file mode 100644 index 0000000000..f8ccffe3ba --- /dev/null +++ b/compose-material/src/main/java/com/google/android/horologist/compose/material/Stepper.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.compose.material + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.util.lerp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.StepperDefaults +import androidx.wear.compose.material.contentColorFor +import com.google.android.horologist.compose.rotaryinput.RotaryDefaults +import com.google.android.horologist.compose.rotaryinput.onRotaryInputAccumulatedWithFocus +import kotlin.math.roundToInt + +/** + * Wrapper for androidx.wear.compose.material.Stepper with default RSB scroll support. + * + * @see androidx.wear.compose.material.Stepper + */ +@Composable +public fun Stepper( + value: Float, + onValueChange: (Float) -> Unit, + steps: Int, + modifier: Modifier = Modifier, + decreaseIcon: @Composable () -> Unit = { + Icon( + StepperDefaults.Decrease, + stringResource(R.string.horologist_stepper_decrease_content_description), + ) + }, + increaseIcon: @Composable () -> Unit = { + Icon( + StepperDefaults.Increase, + stringResource(R.string.horologist_stepper_increase_content_description), + ) + }, + valueRange: ClosedFloatingPointRange = 0f..(steps + 1).toFloat(), + backgroundColor: Color = MaterialTheme.colors.background, + contentColor: Color = contentColorFor(backgroundColor), + iconColor: Color = contentColor, + enableRangeSemantics: Boolean = true, + content: @Composable BoxScope.() -> Unit, +) { + val isLowRes = RotaryDefaults.isLowResInput() + + val currentStep = remember(value, valueRange, steps) { + snapValueToStep( + value, + valueRange, + steps, + ) + } + + val updateValue: (Int) -> Unit = { stepDiff -> + val newValue = calculateCurrentStepValue(currentStep + stepDiff, steps, valueRange) + if (newValue != value) onValueChange(newValue) + } + + androidx.wear.compose.material.Stepper( + value, + onValueChange, + steps, + decreaseIcon, + increaseIcon, + modifier.onRotaryInputAccumulatedWithFocus(isLowRes = isLowRes, onValueChange = { + if (it < 0f) { + updateValue(1) + } else if (it > 0f) { + updateValue(-1) + } + }), + valueRange, + backgroundColor, + contentColor, + iconColor, + enableRangeSemantics, + content, + ) +} + +/** + * Wrapper for androidx.wear.compose.material.Stepper with default RSB scroll support. + * + * @see androidx.wear.compose.material.Stepper + */ +@Composable +public fun Stepper( + value: Int, + onValueChange: (Int) -> Unit, + valueProgression: IntProgression, + modifier: Modifier = Modifier, + decreaseIcon: @Composable () -> Unit = { + Icon( + StepperDefaults.Decrease, + stringResource(R.string.horologist_stepper_decrease_content_description), + ) + }, + increaseIcon: @Composable () -> Unit = { + Icon( + StepperDefaults.Increase, + stringResource(R.string.horologist_stepper_increase_content_description), + ) + }, + backgroundColor: Color = MaterialTheme.colors.background, + contentColor: Color = contentColorFor(backgroundColor), + iconColor: Color = contentColor, + enableRangeSemantics: Boolean = true, + content: @Composable BoxScope.() -> Unit, +) { + val isLowRes = RotaryDefaults.isLowResInput() + androidx.wear.compose.material.Stepper( + value, + onValueChange, + valueProgression, + decreaseIcon, + increaseIcon, + modifier.onRotaryInputAccumulatedWithFocus(isLowRes = isLowRes, onValueChange = { + if (it < 0f) { + val newValue = (value + valueProgression.step) + if (newValue <= valueProgression.last) { + onValueChange(newValue) + } + } else if (it > 0f) { + val newValue = (value - valueProgression.step) + if (newValue >= valueProgression.first) { + onValueChange(newValue) + } + } + }), + backgroundColor, + contentColor, + iconColor, + enableRangeSemantics, + content, + ) +} + +/** + * Calculates value of [currentStep] in [valueRange] depending on number of [steps] + */ +internal fun calculateCurrentStepValue( + currentStep: Int, + steps: Int, + valueRange: ClosedFloatingPointRange, +): Float = lerp( + valueRange.start, + valueRange.endInclusive, + currentStep.toFloat() / (steps + 1).toFloat(), +).coerceIn(valueRange) + +/** + * Snaps [value] to the closest [step] in the [valueRange] + */ +internal fun snapValueToStep( + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int, +): Int = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start) * (steps + 1)).roundToInt() + .coerceIn(0, steps + 1) diff --git a/compose-material/src/main/res/values/strings.xml b/compose-material/src/main/res/values/strings.xml index d044b546a2..00590b4f91 100644 --- a/compose-material/src/main/res/values/strings.xml +++ b/compose-material/src/main/res/values/strings.xml @@ -21,4 +21,6 @@ Off On Off + Decrease + Increase diff --git a/compose-material/src/test/java/com/google/android/horologist/compose/material/StepperA11yTest.kt b/compose-material/src/test/java/com/google/android/horologist/compose/material/StepperA11yTest.kt new file mode 100644 index 0000000000..695e4a06e1 --- /dev/null +++ b/compose-material/src/test/java/com/google/android/horologist/compose/material/StepperA11yTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.compose.material + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.wear.compose.material.Text +import com.google.android.horologist.screenshots.ScreenshotBaseTest +import com.google.android.horologist.screenshots.ScreenshotTestRule +import org.junit.Test + +class StepperA11yTest : + ScreenshotBaseTest( + ScreenshotTestRule.screenshotTestRuleParams { + enableA11y = true + screenTimeText = {} + record = ScreenshotTestRule.RecordMode.Repair + }, + ) { + + @Test + fun float() { + screenshotTestRule.setContent(takeScreenshot = true) { + var value by remember { + mutableFloatStateOf(0f) + } + Stepper( + value = value, + onValueChange = { value = it }, + valueRange = 0f..100f, + steps = 9, + ) { + Text("Value: $value") + } + } + } + + @Test + fun int() { + screenshotTestRule.setContent(takeScreenshot = true) { + var value by remember { + mutableIntStateOf(0) + } + Stepper( + value = value, + onValueChange = { value = it }, + valueProgression = IntProgression.fromClosedRange(0, 100, 10), + ) { + Text("Value: $value") + } + } + } +} diff --git a/compose-material/src/test/java/com/google/android/horologist/compose/material/StepperTest.kt b/compose-material/src/test/java/com/google/android/horologist/compose/material/StepperTest.kt new file mode 100644 index 0000000000..b405b40a1d --- /dev/null +++ b/compose-material/src/test/java/com/google/android/horologist/compose/material/StepperTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.compose.material + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.wear.compose.material.Text +import com.google.android.horologist.screenshots.ScreenshotBaseTest +import com.google.android.horologist.screenshots.ScreenshotTestRule +import org.junit.Test + +class StepperTest : ScreenshotBaseTest( + params = ScreenshotTestRule.screenshotTestRuleParams { + screenTimeText = {} + }, +) { + + @Test + fun float() { + screenshotTestRule.setContent(takeScreenshot = true, roundScreen = true) { + var value by remember { + mutableFloatStateOf(0f) + } + Stepper( + value = value, + onValueChange = { value = it }, + valueRange = 0f..100f, + steps = 9, + ) { + Text("Value: $value") + } + } + } + + @Test + fun int() { + screenshotTestRule.setContent(takeScreenshot = true, roundScreen = true) { + var value by remember { + mutableIntStateOf(0) + } + Stepper( + value = value, + onValueChange = { value = it }, + valueProgression = IntProgression.fromClosedRange(0, 100, 10), + ) { + Text("Value: $value") + } + } + } +} diff --git a/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_float.png b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_float.png new file mode 100644 index 0000000000..5e056bec8e --- /dev/null +++ b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_float.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ec2db66a14204c4e3ebcff60c6c356520f5f09c72557ca649560893511eb941 +size 18001 diff --git a/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_int.png b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_int.png new file mode 100644 index 0000000000..dd0739cbc5 --- /dev/null +++ b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperA11yTest_int.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a46d9cd8eea5579b480052c48173d003969b31d8c48ab5240a0d06edc73c6e65 +size 17950 diff --git a/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_float.png b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_float.png new file mode 100644 index 0000000000..bcec641d7a --- /dev/null +++ b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_float.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb959373213f0e3ec79c3f25c156026269829447182ffad0fb3a9bd41ba3a600 +size 10857 diff --git a/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_int.png b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_int.png new file mode 100644 index 0000000000..0802066078 --- /dev/null +++ b/compose-material/src/test/snapshots/images/com.google.android.horologist.compose.material_StepperTest_int.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86df0aee4d9bf048d0e2c122659df932aef0bb22f3f9d5ffae29f6601c0ff473 +size 10832