Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 74a2fd26 authored by Android Build Coastguard Worker's avatar Android Build Coastguard Worker
Browse files

Snap for 12770256 from 31161792 to 25Q1-release

Change-Id: I6966f72db1c68d7b39d44c3393a3c71626e2e323
parents 8b55e46d 31161792
Loading
Loading
Loading
Loading
+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
    }
}
+3 −3
Original line number Diff line number Diff line
@@ -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),
}
+76 −2
Original line number Diff line number Diff line
@@ -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
@@ -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())
@@ -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 =
+13 −0
Original line number Diff line number Diff line
@@ -40,6 +40,7 @@ data class SegmentKey(
 * respective breakpoint.
 */
data class SegmentData(
    val spec: MotionSpec,
    val minBreakpoint: Breakpoint,
    val maxBreakpoint: Breakpoint,
    val direction: InputDirection,
@@ -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
            }
}

/**
+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