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

Commit d6cb35dc authored by Mike Schneider's avatar Mike Schneider
Browse files

[MotionSpec] implementation for the #MotionMechanics library

This introduces an immutable [MotionSpec] and its related data types.

Background on design and motivation in go/motionvalue-design

Flag: NONE Initial commits for new library, currently unused.
Test: atest mechanics_tests
Bug: 379248269
Change-Id: I94251e1327720a9e99ba473ac95583bc37374847
parent 84d543d5
Loading
Loading
Loading
Loading
+91 −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.spec

import com.android.mechanics.spring.SpringParameters

/**
 * Key to identify a breakpoint in a [DirectionalMotionSpec].
 *
 * @param debugLabel name of the breakpoint, for tooling and debugging.
 * @param identity is used to check the equality of two key instances.
 */
class BreakpointKey(val debugLabel: String? = null, val identity: Any = Object()) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as BreakpointKey

        return identity == other.identity
    }

    override fun hashCode(): Int {
        return identity.hashCode()
    }

    override fun toString(): String {
        return if (debugLabel != null) "BreakpointKey(label=$debugLabel)" else "BreakpointKey()"
    }
}

/**
 * Specification of a breakpoint, in the context of a [DirectionalMotionSpec].
 *
 * The [spring] and [guarantee] define the physics animation for the discontinuity at this
 * breakpoint.They are applied in the direction of the containing [DirectionalMotionSpec].
 *
 * This [Breakpoint]'s animation definition is valid while the input is within the next segment. If
 * the animation is still in progress when the input value reaches the next breakpoint, the
 * remaining animation will be blended with the animation starting at the next breakpoint.
 *
 * @param key Identity of the [Breakpoint], unique within a [DirectionalMotionSpec].
 * @param position The position of the [Breakpoint], in the domain of the `MotionValue`'s input.
 * @param spring Parameters of the spring used to animate the breakpoints discontinuity.
 * @param guarantee Optional constraints to accelerate the completion of the spring motion, based on
 *   `MotionValue`'s input or other non-time signals.
 */
data class Breakpoint(
    val key: BreakpointKey,
    val position: Float,
    val spring: SpringParameters,
    val guarantee: Guarantee,
) : Comparable<Breakpoint> {
    companion object {
        /** First breakpoint of each spec. */
        val minLimit =
            Breakpoint(
                BreakpointKey("built-in::min"),
                Float.NEGATIVE_INFINITY,
                SpringParameters.Snap,
                Guarantee.None,
            )

        /** Last breakpoint of each spec. */
        val maxLimit =
            Breakpoint(
                BreakpointKey("built-in::max"),
                Float.POSITIVE_INFINITY,
                SpringParameters.Snap,
                Guarantee.None,
            )
    }

    override fun compareTo(other: Breakpoint): Int {
        return position.compareTo(other.position)
    }
}
+45 −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.spec

/**
 * Describes the condition by which a discontinuity at a breakpoint must have finished animating.
 *
 * With a guarantee in effect, the spring parameters will be continuously adjusted, ensuring the
 * guarantee's target will be met.
 */
sealed class Guarantee {
    /**
     * No guarantee is provided.
     *
     * The spring animation will proceed at its natural pace, regardless of the input or gesture's
     * progress.
     */
    data object None : Guarantee()

    /**
     * Guarantees that the animation will be complete before the input value is [delta] away from
     * the [Breakpoint] position.
     */
    data class InputDelta(val delta: Float) : Guarantee()

    /**
     * Guarantees to complete the animation before the gesture is [distance] away from the gesture
     * position captured when the breakpoint was crossed.
     */
    data class GestureDistance(val distance: Float) : Guarantee()
}
+31 −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.spec

/**
 * The intrinsic direction of the input value.
 *
 * It reflects the user's intent, that is its meant to be derived from a gesture. If the input is
 * driven by an animation, the direction is expected to not change.
 *
 * 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,
}
+105 −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.spec

import com.android.mechanics.spring.SpringParameters

/**
 * Handler to allow for custom segment-change logic.
 *
 * This handler is called whenever the new input (position or direction) does not match
 * [currentSegment] anymore (see [SegmentData.isValidForInput]).
 *
 * This is intended to implement custom effects on direction-change.
 *
 * Implementations can return:
 * 1. [currentSegment] to delay/suppress segment change.
 * 2. `null` to use the default segment lookup based on [newPosition] and [newDirection]
 * 3. manually looking up segments on this [MotionSpec]
 * 4. create a [SegmentData] that is not in the spec.
 */
typealias OnChangeSegmentHandler =
    MotionSpec.(
        currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection,
    ) -> SegmentData?

/**
 * Specification for the mapping of input values to output values.
 *
 * The spec consists of two independent directional spec's, while only one the one matching
 * `MotionInput`'s `direction` is used at any given time.
 *
 * @param maxDirection spec used when the MotionInput's direction is [InputDirection.Max]
 * @param minDirection spec used when the MotionInput's direction is [InputDirection.Min]
 * @param resetSpring spring parameters to animate a difference in output, if the difference is
 *   caused by setting this new spec.
 * @param segmentHandlers allow for custom segment-change logic, when the `MotionValue` runtime
 *   would leave the [SegmentKey].
 */
data class MotionSpec(
    val maxDirection: DirectionalMotionSpec,
    val minDirection: DirectionalMotionSpec = maxDirection,
    val resetSpring: SpringParameters = DefaultResetSpring,
    val segmentHandlers: Map<SegmentKey, OnChangeSegmentHandler> = emptyMap(),
) {
    companion object {
        /**
         * Default spring parameters for the reset spring. Matches the Fast Spatial spring of the
         * standard motion scheme.
         */
        private val DefaultResetSpring = SpringParameters(stiffness = 1400f, dampingRatio = 1f)

        /* Empty motion spec, the output is the same as the input. */
        val Empty = MotionSpec(DirectionalMotionSpec.Empty)
    }
}

/**
 * Defines the [breakpoints], as well as the [mappings] in-between adjacent [Breakpoint] pairs.
 *
 * This [DirectionalMotionSpec] is applied in the direction defined by the containing [MotionSpec]:
 * especially the direction in which the `breakpoint` [Guarantee] are applied depend on how this is
 * used; this type does not have an inherit direction.
 *
 * All [breakpoints] are sorted in ascending order by their `position`, with the first and last
 * breakpoints are guaranteed to be sentinel values for negative and positive infinity respectively.
 *
 * @param breakpoints All breakpoints in the spec, must contain [Breakpoint.minLimit] as the first
 *   element, and [Breakpoint.maxLimit] as the last element.
 * @param mappings All mappings in between the breakpoints, thus must always contain
 *   `breakpoints.size - 1` elements.
 */
data class DirectionalMotionSpec(val breakpoints: List<Breakpoint>, val mappings: List<Mapping>) {
    init {
        require(breakpoints.size >= 2)
        require(breakpoints.first() == Breakpoint.minLimit)
        require(breakpoints.last() == Breakpoint.maxLimit)
        require(breakpoints.zipWithNext { a, b -> a <= b }.all { it }) {
            "Breakpoints are not sorted ascending ${breakpoints.map { "${it.key}@${it.position}" }}"
        }
        require(mappings.size == breakpoints.size - 1)
    }

    companion object {
        /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */
        val Empty =
            DirectionalMotionSpec(
                listOf(Breakpoint.minLimit, Breakpoint.maxLimit),
                listOf(Mapping.Identity),
            )
    }
}
+102 −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.spec

/**
 * Identifies a segment in a [MotionSpec].
 *
 * A segment only exists between two adjacent [Breakpoint]s; it cannot span multiple breakpoints.
 * The [direction] indicates to the relevant [DirectionalMotionSpec] of the [MotionSpec].
 *
 * The position of the [minBreakpoint] must be less or equal to the position of the [maxBreakpoint].
 */
data class SegmentKey(
    val minBreakpoint: BreakpointKey,
    val maxBreakpoint: BreakpointKey,
    val direction: InputDirection,
)

/**
 * Captures denormalized segment data from a [MotionSpec].
 *
 * Instances are created by the [MotionSpec] and used by the [MotionValue] runtime to compute the
 * output value. By default, the [SegmentData] is cached while [isValidForInput] returns true.
 *
 * The [SegmentData] has an intrinsic direction, thus the segment has an entry and exit side, at the
 * respective breakpoint.
 */
data class SegmentData(
    val minBreakpoint: Breakpoint,
    val maxBreakpoint: Breakpoint,
    val direction: InputDirection,
    val mapping: Mapping,
) {
    val key = SegmentKey(minBreakpoint.key, maxBreakpoint.key, direction)

    /**
     * Whether the given [inputPosition] and [inputDirection] should be handled by this segment.
     *
     * The input is considered invalid only if the direction changes or the input is *at or outside*
     * the segment on the exit-side. The input remains intentionally valid outside the segment on
     * the entry-side, to avoid flip-flopping.
     */
    fun isValidForInput(inputPosition: Float, inputDirection: InputDirection): Boolean {
        if (inputDirection != direction) return false

        return when (inputDirection) {
            InputDirection.Max -> inputPosition < maxBreakpoint.position
            InputDirection.Min -> inputPosition > minBreakpoint.position
        }
    }
}

/**
 * Maps the `input` of a [MotionValue] to the desired output value.
 *
 * The mapping implementation can be arbitrary, but must not produce discontinuities.
 */
fun interface Mapping {
    /** Computes the [MotionValue]'s target output, given the input. */
    fun map(input: Float): Float

    /** `f(x) = x` */
    object Identity : Mapping {
        override fun map(input: Float): Float {
            return input
        }
    }

    /** `f(x) = value` */
    data class Fixed(val value: Float) : Mapping {
        override fun map(input: Float): Float {
            return value
        }
    }

    /** `f(x) = factor*x + offset` */
    data class Linear(val factor: Float, val offset: Float = 0f) : Mapping {
        override fun map(input: Float): Float {
            return input * factor + offset
        }
    }

    companion object {
        val Zero = Fixed(0f)
        val One = Fixed(1f)
        val Two = Fixed(2f)
    }
}
Loading