Loading mechanics/src/com/android/mechanics/GestureContext.kt 0 → 100644 +171 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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 * * http://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.android.mechanics import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.mechanics.spec.InputDirection import kotlin.math.max import kotlin.math.min /** * Gesture-specific context to augment [MotionValue.currentInput]. * * This context helps to capture the user's intent, and should be provided to [MotionValue]s that * respond to a user gesture. */ @Stable interface GestureContext { /** * The intrinsic direction of the [MotionValue.currentInput]. * * This property determines which of the [DirectionalMotionSpec] from the [MotionSpec] is used, * and also prevents flip-flopping of the output value on tiny input-changes around a * breakpoint. * * If the [MotionValue.currentInput] is driven - directly or indirectly - by a user gesture, * this property should only change direction after the gesture travelled a significant distance * in the opposite direction. * * @see DistanceGestureContext for a default implementation. */ val direction: InputDirection /** * The gesture distance of the current gesture, in pixels. * * Used solely for the [GestureDistance] [Guarantee]. Can be hard-coded to a static value if * this type of [Guarantee] is not used. */ val distance: Float } /** [GestureContext] implementation for manually set values. */ class ProvidedGestureContext(direction: InputDirection, distance: Float) : GestureContext { override var direction by mutableStateOf(direction) override var distance by mutableFloatStateOf(distance) } /** * [GestureContext] driven by a gesture distance. * * The direction is determined from the gesture input, where going further than * [directionChangeSlop] in the opposite direction toggles the direction. * * @param initialDistance The initial [distance] of the [GestureContext] * @param initialDirection The initial [direction] of the [GestureContext] * @param directionChangeSlop the amount [distance] must be moved in the opposite direction for the * [direction] to flip. */ class DistanceGestureContext( initialDistance: Float, initialDirection: InputDirection, directionChangeSlop: Float, ) : GestureContext { init { require(directionChangeSlop > 0) { "directionChangeSlop must be greater than 0, was $directionChangeSlop" } } override var direction by mutableStateOf(initialDirection) private set private var furthestDistance by mutableFloatStateOf(initialDistance) private var _distance by mutableFloatStateOf(initialDistance) override var distance: Float get() = _distance /** * Updates the [distance]. * * This flips the [direction], if the [value] is further than [directionChangeSlop] away * from the furthest recorded value regarding to the current [direction]. */ set(value) { _distance = value this.direction = when (direction) { InputDirection.Max -> { if (furthestDistance - value > directionChangeSlop) { furthestDistance = value InputDirection.Min } else { furthestDistance = max(value, furthestDistance) InputDirection.Max } } InputDirection.Min -> { if (value - furthestDistance > directionChangeSlop) { furthestDistance = value InputDirection.Max } else { furthestDistance = min(value, furthestDistance) InputDirection.Min } } } } private var _directionChangeSlop by mutableFloatStateOf(directionChangeSlop) var directionChangeSlop: Float get() = _directionChangeSlop /** * This flips the [direction], if the current [direction] is further than the new * directionChangeSlop [value] away from the furthest recorded value regarding to the * current [direction]. */ set(value) { require(value > 0) { "directionChangeSlop must be greater than 0, was $value" } _directionChangeSlop = value when (direction) { InputDirection.Max -> { if (furthestDistance - distance > directionChangeSlop) { furthestDistance = distance direction = InputDirection.Min } } InputDirection.Min -> { if (distance - furthestDistance > directionChangeSlop) { furthestDistance = value direction = InputDirection.Max } } } } /** * Sets [distance] and [direction] to the specified values. * * This also resets memoized [furthestDistance], which is used to determine the direction * change. */ fun reset(distance: Float, direction: InputDirection) { this.distance = distance this.direction = direction this.furthestDistance = distance } } mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt 0 → 100644 +151 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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 * * http://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.android.mechanics import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.mechanics.spec.InputDirection import com.google.common.truth.Truth.assertThat import kotlin.math.nextDown import kotlin.math.nextUp import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DistanceGestureContextTest { @Test fun setDistance_maxDirection_increasingInput_keepsDirection() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) for (value in 0..6) { underTest.distance = value.toFloat() assertThat(underTest.direction).isEqualTo(InputDirection.Max) } } @Test fun setDistance_minDirection_decreasingInput_keepsDirection() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Min, directionChangeSlop = 5f, ) for (value in 0 downTo -6) { underTest.distance = value.toFloat() assertThat(underTest.direction).isEqualTo(InputDirection.Min) } } @Test fun setDistance_maxDirection_decreasingInput_keepsDirection_belowDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) underTest.distance = -5f assertThat(underTest.direction).isEqualTo(InputDirection.Max) } @Test fun setDistance_maxDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) underTest.distance = (-5f).nextDown() assertThat(underTest.direction).isEqualTo(InputDirection.Min) } @Test fun setDistance_minDirection_increasingInput_keepsDirection_belowDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Min, directionChangeSlop = 5f, ) underTest.distance = 5f assertThat(underTest.direction).isEqualTo(InputDirection.Min) } @Test fun setDistance_minDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Min, directionChangeSlop = 5f, ) underTest.distance = 5f.nextUp() assertThat(underTest.direction).isEqualTo(InputDirection.Max) } @Test fun reset_resetsFurthestValue() { val underTest = DistanceGestureContext( initialDistance = 10f, initialDirection = InputDirection.Max, directionChangeSlop = 1f, ) underTest.reset(5f, direction = InputDirection.Max) assertThat(underTest.direction).isEqualTo(InputDirection.Max) assertThat(underTest.distance).isEqualTo(5f) underTest.distance -= 1f assertThat(underTest.direction).isEqualTo(InputDirection.Max) assertThat(underTest.distance).isEqualTo(4f) underTest.distance = underTest.distance.nextDown() assertThat(underTest.direction).isEqualTo(InputDirection.Min) assertThat(underTest.distance).isWithin(0.0001f).of(4f) } @Test fun setDirectionChangeSlop_smallerThanCurrentDelta_switchesDirection() { val underTest = DistanceGestureContext( initialDistance = 10f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) underTest.distance -= 2f assertThat(underTest.direction).isEqualTo(InputDirection.Max) assertThat(underTest.distance).isEqualTo(8f) underTest.directionChangeSlop = 1f assertThat(underTest.direction).isEqualTo(InputDirection.Min) assertThat(underTest.distance).isEqualTo(8f) } } Loading
mechanics/src/com/android/mechanics/GestureContext.kt 0 → 100644 +171 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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 * * http://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.android.mechanics import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.mechanics.spec.InputDirection import kotlin.math.max import kotlin.math.min /** * Gesture-specific context to augment [MotionValue.currentInput]. * * This context helps to capture the user's intent, and should be provided to [MotionValue]s that * respond to a user gesture. */ @Stable interface GestureContext { /** * The intrinsic direction of the [MotionValue.currentInput]. * * This property determines which of the [DirectionalMotionSpec] from the [MotionSpec] is used, * and also prevents flip-flopping of the output value on tiny input-changes around a * breakpoint. * * If the [MotionValue.currentInput] is driven - directly or indirectly - by a user gesture, * this property should only change direction after the gesture travelled a significant distance * in the opposite direction. * * @see DistanceGestureContext for a default implementation. */ val direction: InputDirection /** * The gesture distance of the current gesture, in pixels. * * Used solely for the [GestureDistance] [Guarantee]. Can be hard-coded to a static value if * this type of [Guarantee] is not used. */ val distance: Float } /** [GestureContext] implementation for manually set values. */ class ProvidedGestureContext(direction: InputDirection, distance: Float) : GestureContext { override var direction by mutableStateOf(direction) override var distance by mutableFloatStateOf(distance) } /** * [GestureContext] driven by a gesture distance. * * The direction is determined from the gesture input, where going further than * [directionChangeSlop] in the opposite direction toggles the direction. * * @param initialDistance The initial [distance] of the [GestureContext] * @param initialDirection The initial [direction] of the [GestureContext] * @param directionChangeSlop the amount [distance] must be moved in the opposite direction for the * [direction] to flip. */ class DistanceGestureContext( initialDistance: Float, initialDirection: InputDirection, directionChangeSlop: Float, ) : GestureContext { init { require(directionChangeSlop > 0) { "directionChangeSlop must be greater than 0, was $directionChangeSlop" } } override var direction by mutableStateOf(initialDirection) private set private var furthestDistance by mutableFloatStateOf(initialDistance) private var _distance by mutableFloatStateOf(initialDistance) override var distance: Float get() = _distance /** * Updates the [distance]. * * This flips the [direction], if the [value] is further than [directionChangeSlop] away * from the furthest recorded value regarding to the current [direction]. */ set(value) { _distance = value this.direction = when (direction) { InputDirection.Max -> { if (furthestDistance - value > directionChangeSlop) { furthestDistance = value InputDirection.Min } else { furthestDistance = max(value, furthestDistance) InputDirection.Max } } InputDirection.Min -> { if (value - furthestDistance > directionChangeSlop) { furthestDistance = value InputDirection.Max } else { furthestDistance = min(value, furthestDistance) InputDirection.Min } } } } private var _directionChangeSlop by mutableFloatStateOf(directionChangeSlop) var directionChangeSlop: Float get() = _directionChangeSlop /** * This flips the [direction], if the current [direction] is further than the new * directionChangeSlop [value] away from the furthest recorded value regarding to the * current [direction]. */ set(value) { require(value > 0) { "directionChangeSlop must be greater than 0, was $value" } _directionChangeSlop = value when (direction) { InputDirection.Max -> { if (furthestDistance - distance > directionChangeSlop) { furthestDistance = distance direction = InputDirection.Min } } InputDirection.Min -> { if (distance - furthestDistance > directionChangeSlop) { furthestDistance = value direction = InputDirection.Max } } } } /** * Sets [distance] and [direction] to the specified values. * * This also resets memoized [furthestDistance], which is used to determine the direction * change. */ fun reset(distance: Float, direction: InputDirection) { this.distance = distance this.direction = direction this.furthestDistance = distance } }
mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt 0 → 100644 +151 −0 Original line number Diff line number Diff line /* * Copyright (C) 2024 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 * * http://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.android.mechanics import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.mechanics.spec.InputDirection import com.google.common.truth.Truth.assertThat import kotlin.math.nextDown import kotlin.math.nextUp import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DistanceGestureContextTest { @Test fun setDistance_maxDirection_increasingInput_keepsDirection() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) for (value in 0..6) { underTest.distance = value.toFloat() assertThat(underTest.direction).isEqualTo(InputDirection.Max) } } @Test fun setDistance_minDirection_decreasingInput_keepsDirection() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Min, directionChangeSlop = 5f, ) for (value in 0 downTo -6) { underTest.distance = value.toFloat() assertThat(underTest.direction).isEqualTo(InputDirection.Min) } } @Test fun setDistance_maxDirection_decreasingInput_keepsDirection_belowDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) underTest.distance = -5f assertThat(underTest.direction).isEqualTo(InputDirection.Max) } @Test fun setDistance_maxDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) underTest.distance = (-5f).nextDown() assertThat(underTest.direction).isEqualTo(InputDirection.Min) } @Test fun setDistance_minDirection_increasingInput_keepsDirection_belowDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Min, directionChangeSlop = 5f, ) underTest.distance = 5f assertThat(underTest.direction).isEqualTo(InputDirection.Min) } @Test fun setDistance_minDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { val underTest = DistanceGestureContext( initialDistance = 0f, initialDirection = InputDirection.Min, directionChangeSlop = 5f, ) underTest.distance = 5f.nextUp() assertThat(underTest.direction).isEqualTo(InputDirection.Max) } @Test fun reset_resetsFurthestValue() { val underTest = DistanceGestureContext( initialDistance = 10f, initialDirection = InputDirection.Max, directionChangeSlop = 1f, ) underTest.reset(5f, direction = InputDirection.Max) assertThat(underTest.direction).isEqualTo(InputDirection.Max) assertThat(underTest.distance).isEqualTo(5f) underTest.distance -= 1f assertThat(underTest.direction).isEqualTo(InputDirection.Max) assertThat(underTest.distance).isEqualTo(4f) underTest.distance = underTest.distance.nextDown() assertThat(underTest.direction).isEqualTo(InputDirection.Min) assertThat(underTest.distance).isWithin(0.0001f).of(4f) } @Test fun setDirectionChangeSlop_smallerThanCurrentDelta_switchesDirection() { val underTest = DistanceGestureContext( initialDistance = 10f, initialDirection = InputDirection.Max, directionChangeSlop = 5f, ) underTest.distance -= 2f assertThat(underTest.direction).isEqualTo(InputDirection.Max) assertThat(underTest.distance).isEqualTo(8f) underTest.directionChangeSlop = 1f assertThat(underTest.direction).isEqualTo(InputDirection.Min) assertThat(underTest.distance).isEqualTo(8f) } }