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/src/com/android/mechanics/spec/InputDirection.kt +3 −3 Original line number Diff line number Diff line Loading @@ -25,7 +25,7 @@ package com.android.mechanics.spec * The directions are labelled [Min] and [Max] to reflect descending and ascending input values * respectively, but it does not imply an spatial direction. */ enum class InputDirection { Min, Max, enum class InputDirection(val sign: Int) { Min(sign = -1), Max(sign = +1), } mechanics/src/com/android/mechanics/spec/MotionSpec.kt +76 −2 Original line number Diff line number Diff line Loading @@ -56,6 +56,63 @@ data class MotionSpec( val resetSpring: SpringParameters = DefaultResetSpring, val segmentHandlers: Map<SegmentKey, OnChangeSegmentHandler> = emptyMap(), ) { /** The [DirectionalMotionSpec] for the specified [direction]. */ operator fun get(direction: InputDirection): DirectionalMotionSpec { return when (direction) { InputDirection.Min -> minDirection InputDirection.Max -> maxDirection } } /** Whether this spec contains a segment with the specified [segmentKey]. */ fun containsSegment(segmentKey: SegmentKey): Boolean { return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1 } /** * The [SegmentData] for an input with the specified [position] and [direction]. * * The returned [SegmentData] will be cached while [SegmentData.isValidForInput] returns `true`. */ fun segmentAtInput(position: Float, direction: InputDirection): SegmentData { require(position.isFinite()) return with(get(direction)) { var idx = findBreakpointIndex(position) if (direction == InputDirection.Min && breakpoints[idx].position == position) { // The segment starts at `position`. Since the breakpoints are sorted ascending, no // matter the spec's direction, need to return the previous segment in the min // direction. idx-- } SegmentData( this@MotionSpec, breakpoints[idx], breakpoints[idx + 1], direction, mappings[idx], ) } } /** * Looks up the new [SegmentData] once the [currentSegment] is not valid for an input with * [newPosition] and [newDirection]. * * This will delegate to the [segmentHandlers], if registered for the [currentSegment]'s key. */ internal fun onChangeSegment( currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection, ): SegmentData { val segmentChangeHandler = segmentHandlers[currentSegment.key] return segmentChangeHandler?.invoke(this, currentSegment, newPosition, newDirection) ?: segmentAtInput(newPosition, newDirection) } companion object { /** * Default spring parameters for the reset spring. Matches the Fast Spatial spring of the Loading Loading @@ -97,10 +154,11 @@ data class DirectionalMotionSpec(val breakpoints: List<Breakpoint>, val mappings /** * Returns the index of the closest breakpoint where `Breakpoint.position <= position`. * * Guaranteed to be a valid index into [breakpoints], and guaranteed not to be the last element. * Guaranteed to be a valid index into [breakpoints], and guaranteed to be neither the first nor * the last element. * * @param position the position in the input domain. * @return Index into [breakpoints], guaranteed to be in range `0..breakpoints.size - 2` * @return Index into [breakpoints], guaranteed to be in range `1..breakpoints.size - 2` */ fun findBreakpointIndex(position: Float): Int { require(position.isFinite()) Loading @@ -119,6 +177,22 @@ data class DirectionalMotionSpec(val breakpoints: List<Breakpoint>, val mappings return result } /** * The index of the breakpoint with the specified [breakpointKey], or `-1` if no such breakpoint * exists. */ fun findBreakpointIndex(breakpointKey: BreakpointKey): Int { return breakpoints.indexOfFirst { it.key == breakpointKey } } /** Index into [mappings] for the specified [segmentKey], or `-1` if no such segment exists. */ fun findSegmentIndex(segmentKey: SegmentKey): Int { val result = breakpoints.indexOfFirst { it.key == segmentKey.minBreakpoint } if (result < 0 || breakpoints[result + 1].key != segmentKey.maxBreakpoint) return -1 return result } companion object { /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */ val Empty = Loading mechanics/src/com/android/mechanics/spec/Segment.kt +13 −0 Original line number Diff line number Diff line Loading @@ -40,6 +40,7 @@ data class SegmentKey( * respective breakpoint. */ data class SegmentData( val spec: MotionSpec, val minBreakpoint: Breakpoint, val maxBreakpoint: Breakpoint, val direction: InputDirection, Loading @@ -62,6 +63,18 @@ data class SegmentData( InputDirection.Min -> inputPosition > minBreakpoint.position } } /** * The breakpoint at the side of the segment's start. * * The [entryBreakpoint]'s [Guarantee] is the relevant guarantee for this segment. */ val entryBreakpoint: Breakpoint get() = when (direction) { InputDirection.Max -> minBreakpoint InputDirection.Min -> maxBreakpoint } } /** Loading 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/src/com/android/mechanics/spec/InputDirection.kt +3 −3 Original line number Diff line number Diff line Loading @@ -25,7 +25,7 @@ package com.android.mechanics.spec * The directions are labelled [Min] and [Max] to reflect descending and ascending input values * respectively, but it does not imply an spatial direction. */ enum class InputDirection { Min, Max, enum class InputDirection(val sign: Int) { Min(sign = -1), Max(sign = +1), }
mechanics/src/com/android/mechanics/spec/MotionSpec.kt +76 −2 Original line number Diff line number Diff line Loading @@ -56,6 +56,63 @@ data class MotionSpec( val resetSpring: SpringParameters = DefaultResetSpring, val segmentHandlers: Map<SegmentKey, OnChangeSegmentHandler> = emptyMap(), ) { /** The [DirectionalMotionSpec] for the specified [direction]. */ operator fun get(direction: InputDirection): DirectionalMotionSpec { return when (direction) { InputDirection.Min -> minDirection InputDirection.Max -> maxDirection } } /** Whether this spec contains a segment with the specified [segmentKey]. */ fun containsSegment(segmentKey: SegmentKey): Boolean { return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1 } /** * The [SegmentData] for an input with the specified [position] and [direction]. * * The returned [SegmentData] will be cached while [SegmentData.isValidForInput] returns `true`. */ fun segmentAtInput(position: Float, direction: InputDirection): SegmentData { require(position.isFinite()) return with(get(direction)) { var idx = findBreakpointIndex(position) if (direction == InputDirection.Min && breakpoints[idx].position == position) { // The segment starts at `position`. Since the breakpoints are sorted ascending, no // matter the spec's direction, need to return the previous segment in the min // direction. idx-- } SegmentData( this@MotionSpec, breakpoints[idx], breakpoints[idx + 1], direction, mappings[idx], ) } } /** * Looks up the new [SegmentData] once the [currentSegment] is not valid for an input with * [newPosition] and [newDirection]. * * This will delegate to the [segmentHandlers], if registered for the [currentSegment]'s key. */ internal fun onChangeSegment( currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection, ): SegmentData { val segmentChangeHandler = segmentHandlers[currentSegment.key] return segmentChangeHandler?.invoke(this, currentSegment, newPosition, newDirection) ?: segmentAtInput(newPosition, newDirection) } companion object { /** * Default spring parameters for the reset spring. Matches the Fast Spatial spring of the Loading Loading @@ -97,10 +154,11 @@ data class DirectionalMotionSpec(val breakpoints: List<Breakpoint>, val mappings /** * Returns the index of the closest breakpoint where `Breakpoint.position <= position`. * * Guaranteed to be a valid index into [breakpoints], and guaranteed not to be the last element. * Guaranteed to be a valid index into [breakpoints], and guaranteed to be neither the first nor * the last element. * * @param position the position in the input domain. * @return Index into [breakpoints], guaranteed to be in range `0..breakpoints.size - 2` * @return Index into [breakpoints], guaranteed to be in range `1..breakpoints.size - 2` */ fun findBreakpointIndex(position: Float): Int { require(position.isFinite()) Loading @@ -119,6 +177,22 @@ data class DirectionalMotionSpec(val breakpoints: List<Breakpoint>, val mappings return result } /** * The index of the breakpoint with the specified [breakpointKey], or `-1` if no such breakpoint * exists. */ fun findBreakpointIndex(breakpointKey: BreakpointKey): Int { return breakpoints.indexOfFirst { it.key == breakpointKey } } /** Index into [mappings] for the specified [segmentKey], or `-1` if no such segment exists. */ fun findSegmentIndex(segmentKey: SegmentKey): Int { val result = breakpoints.indexOfFirst { it.key == segmentKey.minBreakpoint } if (result < 0 || breakpoints[result + 1].key != segmentKey.maxBreakpoint) return -1 return result } companion object { /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */ val Empty = Loading
mechanics/src/com/android/mechanics/spec/Segment.kt +13 −0 Original line number Diff line number Diff line Loading @@ -40,6 +40,7 @@ data class SegmentKey( * respective breakpoint. */ data class SegmentData( val spec: MotionSpec, val minBreakpoint: Breakpoint, val maxBreakpoint: Breakpoint, val direction: InputDirection, Loading @@ -62,6 +63,18 @@ data class SegmentData( InputDirection.Min -> inputPosition > minBreakpoint.position } } /** * The breakpoint at the side of the segment's start. * * The [entryBreakpoint]'s [Guarantee] is the relevant guarantee for this segment. */ val entryBreakpoint: Breakpoint get() = when (direction) { InputDirection.Max -> minBreakpoint InputDirection.Min -> maxBreakpoint } } /** Loading
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) } }