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

Commit 5f57e4c4 authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "MM Introduce a Kotlin type-safe builder for DirectionalMotionSpec" into main

parents 7e967e41 ad6bfdf4
Loading
Loading
Loading
Loading
+472 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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

/**
 * Builds a [DirectionalMotionSpec] by defining a sequence of ([Breakpoint], [Mapping]) pairs.
 *
 * This function simplifies the creation of complex motion specifications. It allows you to define a
 * series of motion segments, each with its own behavior, separated by breakpoints. The breakpoints
 * and their corresponding segments will always be ordered from min to max value, regardless of how
 * the `DirectionalMotionSpec` is applied.
 *
 * Example Usage:
 * ```kotlin
 * val motionSpec = buildDirectionalMotionSpec(
 *     defaultSpring = materialSpatial,
 *
 *     // Start as a constant transition, always 0.
 *     initialMapping = Mapping.Zero
 * ) {
 *     // At breakpoint 10: Linear transition from 0 to 50.
 *     target(breakpoint = 10f, from = 0f, to = 50f)
 *
 *     // At breakpoint 20: Jump +5, and constant value 55.
 *     constantValueFromCurrent(breakpoint = 20f, delta = 5f)
 *
 *     // At breakpoint 30: Jump to 40. Linear mapping using: progress_since_breakpoint * fraction.
 *     fractionalInput(breakpoint = 30f, from = 40f, fraction = 2f)
 * }
 * ```
 *
 * @param defaultSpring The default [SpringParameters] to use for all breakpoints.
 * @param initialMapping The initial [Mapping] for the first segment (defaults to
 *   [Mapping.Identity]).
 * @param init A lambda function that configures the [DirectionalMotionSpecBuilder]. The lambda
 *   should return a [CanBeLastSegment] to indicate the end of the spec.
 * @return The constructed [DirectionalMotionSpec].
 */
fun buildDirectionalMotionSpec(
    defaultSpring: SpringParameters,
    initialMapping: Mapping = Mapping.Identity,
    init: DirectionalMotionSpecBuilder.() -> CanBeLastSegment,
): DirectionalMotionSpec {
    return DirectionalMotionSpecBuilderImpl(defaultSpring)
        .also { it.mappings += initialMapping }
        .also { it.init() }
        .build()
}

/**
 * Builds a simple [DirectionalMotionSpec] with a single segment.
 *
 * @param mapping The [Mapping] to apply to the segment. Defaults to [Mapping.Identity].
 * @return A new [DirectionalMotionSpec] instance configured with the provided parameters.
 */
fun buildDirectionalMotionSpec(mapping: Mapping = Mapping.Identity): DirectionalMotionSpec {
    return DirectionalMotionSpec(listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(mapping))
}

/**
 * Defines the contract for building a [DirectionalMotionSpec].
 *
 * Provides methods to define breakpoints and mappings for the motion specification.
 */
interface DirectionalMotionSpecBuilder {
    /** The default [SpringParameters] used for breakpoints. */
    val defaultSpring: SpringParameters

    /**
     * Ends the current segment at the [breakpoint] position and defines the next segment to
     * linearly interpolate from a starting value ([from]) to the desired target value ([to]).
     *
     * Note: This segment cannot be used as the last segment in the specification, as it requires a
     * subsequent breakpoint to define the target value.
     *
     * @param breakpoint The breakpoint defining the end of the current segment and the start of the
     *   next.
     * @param from The output value at the previous breakpoint, explicitly setting the starting
     *   point for the linear mapping.
     * @param to The desired output value at the new breakpoint.
     * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
     *   [defaultSpring].
     * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
     * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
     */
    fun target(
        breakpoint: Float,
        from: Float,
        to: Float,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
        key: BreakpointKey = BreakpointKey(),
    )

    /**
     * Ends the current segment at the [breakpoint] position and defines the next segment to
     * linearly interpolate from the current output value (optionally with an offset of [delta]) to
     * the desired target value ([to]).
     *
     * Note: This segment cannot be used as the last segment in the specification, as it requires a
     * subsequent breakpoint to define the target value.
     *
     * @param breakpoint The breakpoint defining the end of the current segment and the start of the
     *   next.
     * @param to The desired output value at the new breakpoint.
     * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f.
     * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
     *   [defaultSpring].
     * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
     * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
     */
    fun targetFromCurrent(
        breakpoint: Float,
        to: Float,
        delta: Float = 0f,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
        key: BreakpointKey = BreakpointKey(),
    )

    /**
     * Ends the current segment at the [breakpoint] position and defines the next segment to
     * linearly interpolate from a starting value ([from]) and then continue with a fractional input
     * ([fraction]).
     *
     * Note: This segment can be used as the last segment in the specification.
     *
     * @param breakpoint The breakpoint defining the end of the current segment and the start of the
     *   next.
     * @param from The output value at the previous breakpoint, explicitly setting the starting
     *   point for the linear mapping.
     * @param fraction The fractional multiplier applied to the input difference between
     *   breakpoints.
     * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
     *   [defaultSpring].
     * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
     * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
     */
    fun fractionalInput(
        breakpoint: Float,
        from: Float,
        fraction: Float,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
        key: BreakpointKey = BreakpointKey(),
    ): CanBeLastSegment

    /**
     * Ends the current segment at the [breakpoint] position and defines the next segment to
     * linearly interpolate from the current output value (optionally with an offset of [delta]) and
     * then continue with a fractional input ([fraction]).
     *
     * Note: This segment can be used as the last segment in the specification.
     *
     * @param breakpoint The breakpoint defining the end of the current segment and the start of the
     *   next.
     * @param fraction The fractional multiplier applied to the input difference between
     *   breakpoints.
     * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f.
     * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
     *   [defaultSpring].
     * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
     * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
     */
    fun fractionalInputFromCurrent(
        breakpoint: Float,
        fraction: Float,
        delta: Float = 0f,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
        key: BreakpointKey = BreakpointKey(),
    ): CanBeLastSegment

    /**
     * Ends the current segment at the [breakpoint] position and defines the next segment to output
     * a constant value ([value]).
     *
     * Note: This segment can be used as the last segment in the specification.
     *
     * @param breakpoint The breakpoint defining the end of the current segment and the start of the
     *   next.
     * @param value The constant output value for this segment.
     * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
     *   [defaultSpring].
     * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
     * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
     */
    fun constantValue(
        breakpoint: Float,
        value: Float,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
        key: BreakpointKey = BreakpointKey(),
    ): CanBeLastSegment

    /**
     * Ends the current segment at the [breakpoint] position and defines the next segment to output
     * a constant value derived from the current output value (optionally with an offset of
     * [delta]).
     *
     * Note: This segment can be used as the last segment in the specification.
     *
     * @param breakpoint The breakpoint defining the end of the current segment and the start of the
     *   next.
     * @param delta An optional offset to apply to the mapped value to determine the constant value.
     *   Defaults to 0f.
     * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
     *   [defaultSpring].
     * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
     * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
     */
    fun constantValueFromCurrent(
        breakpoint: Float,
        delta: Float = 0f,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
        key: BreakpointKey = BreakpointKey(),
    ): CanBeLastSegment

    /**
     * Ends the current segment at the [breakpoint] position and defines the next segment using the
     * provided [mapping].
     *
     * Note: This segment can be used as the last segment in the specification.
     *
     * @param breakpoint The breakpoint defining the end of the current segment and the start of the
     *   next.
     * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
     *   [defaultSpring].
     * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
     * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
     * @param mapping The custom [Mapping] to use.
     */
    fun mapping(
        breakpoint: Float,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
        key: BreakpointKey = BreakpointKey(),
        mapping: Mapping,
    ): CanBeLastSegment
}

/** Marker interface to indicate that a segment can be the last one in a [DirectionalMotionSpec]. */
sealed interface CanBeLastSegment

private data object CanBeLastSegmentImpl : CanBeLastSegment

private class DirectionalMotionSpecBuilderImpl(override val defaultSpring: SpringParameters) :
    DirectionalMotionSpecBuilder {
    private val breakpoints = mutableListOf(Breakpoint.minLimit)
    val mappings = mutableListOf<Mapping>()

    private var sourceValue: Float = Float.NaN
    private var targetValue: Float = Float.NaN
    private var fractionalMapping: Float = Float.NaN
    private var breakpointPosition: Float = Float.NaN
    private var breakpointKey: BreakpointKey? = null

    override fun target(
        breakpoint: Float,
        from: Float,
        to: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
        key: BreakpointKey,
    ) {
        toBreakpointImpl(breakpoint, key)
        jumpToImpl(from, spring, guarantee)
        continueWithTargetValueImpl(to)
    }

    override fun targetFromCurrent(
        breakpoint: Float,
        to: Float,
        delta: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
        key: BreakpointKey,
    ) {
        toBreakpointImpl(breakpoint, key)
        jumpByImpl(delta, spring, guarantee)
        continueWithTargetValueImpl(to)
    }

    override fun fractionalInput(
        breakpoint: Float,
        from: Float,
        fraction: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
        key: BreakpointKey,
    ): CanBeLastSegment {
        toBreakpointImpl(breakpoint, key)
        jumpToImpl(from, spring, guarantee)
        continueWithFractionalInputImpl(fraction)
        return CanBeLastSegmentImpl
    }

    override fun fractionalInputFromCurrent(
        breakpoint: Float,
        fraction: Float,
        delta: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
        key: BreakpointKey,
    ): CanBeLastSegment {
        toBreakpointImpl(breakpoint, key)
        jumpByImpl(delta, spring, guarantee)
        continueWithFractionalInputImpl(fraction)
        return CanBeLastSegmentImpl
    }

    override fun constantValue(
        breakpoint: Float,
        value: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
        key: BreakpointKey,
    ): CanBeLastSegment {
        toBreakpointImpl(breakpoint, key)
        jumpToImpl(value, spring, guarantee)
        continueWithConstantValueImpl()
        return CanBeLastSegmentImpl
    }

    override fun constantValueFromCurrent(
        breakpoint: Float,
        delta: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
        key: BreakpointKey,
    ): CanBeLastSegment {
        toBreakpointImpl(breakpoint, key)
        jumpByImpl(delta, spring, guarantee)
        continueWithConstantValueImpl()
        return CanBeLastSegmentImpl
    }

    override fun mapping(
        breakpoint: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
        key: BreakpointKey,
        mapping: Mapping,
    ): CanBeLastSegment {
        toBreakpointImpl(breakpoint, key)
        continueWithImpl(mapping, spring, guarantee)
        return CanBeLastSegmentImpl
    }

    fun build(): DirectionalMotionSpec {
        completeImpl()
        return DirectionalMotionSpec(breakpoints.toList(), mappings.toList())
    }

    private fun continueWithTargetValueImpl(target: Float) {
        check(sourceValue.isFinite())

        targetValue = target
    }

    private fun continueWithFractionalInputImpl(fraction: Float) {
        check(sourceValue.isFinite())

        fractionalMapping = fraction
    }

    private fun continueWithConstantValueImpl() {
        check(sourceValue.isFinite())

        mappings.add(Mapping.Fixed(sourceValue))
        sourceValue = Float.NaN
    }

    private fun jumpToImpl(value: Float, spring: SpringParameters, guarantee: Guarantee) {
        check(sourceValue.isNaN())

        doAddBreakpointImpl(spring, guarantee)
        sourceValue = value
    }

    private fun jumpByImpl(delta: Float, spring: SpringParameters, guarantee: Guarantee) {
        check(sourceValue.isNaN())

        val breakpoint = doAddBreakpointImpl(spring, guarantee)
        sourceValue = mappings.last().map(breakpoint.position) + delta
    }

    private fun continueWithImpl(mapping: Mapping, spring: SpringParameters, guarantee: Guarantee) {
        check(sourceValue.isNaN())

        doAddBreakpointImpl(spring, guarantee)
        mappings.add(mapping)
    }

    private fun toBreakpointImpl(atPosition: Float, key: BreakpointKey) {
        check(breakpointPosition.isNaN())
        check(breakpointKey == null)

        if (!targetValue.isNaN() || !fractionalMapping.isNaN()) {
            check(!sourceValue.isNaN())

            val sourcePosition = breakpoints.last().position

            if (fractionalMapping.isNaN()) {
                val delta = targetValue - sourceValue
                fractionalMapping = delta / (atPosition - sourcePosition)
            } else {
                val delta = (atPosition - sourcePosition) * fractionalMapping
                targetValue = sourceValue + delta
            }

            val offset = sourceValue - (sourcePosition * fractionalMapping)

            mappings.add(Mapping.Linear(fractionalMapping, offset))
            targetValue = Float.NaN
            sourceValue = Float.NaN
            fractionalMapping = Float.NaN
        }

        breakpointPosition = atPosition
        breakpointKey = key
    }

    private fun completeImpl() {
        check(targetValue.isNaN()) { "cant specify target value for last segment" }

        if (!fractionalMapping.isNaN()) {
            check(!sourceValue.isNaN())

            val sourcePosition = breakpoints.last().position

            mappings.add(
                Mapping.Linear(
                    fractionalMapping,
                    sourceValue - (sourcePosition * fractionalMapping),
                )
            )
        }

        breakpoints.add(Breakpoint.maxLimit)
    }

    private fun doAddBreakpointImpl(
        springSpec: SpringParameters,
        guarantee: Guarantee,
    ): Breakpoint {
        check(breakpointPosition.isFinite())
        return Breakpoint(checkNotNull(breakpointKey), breakpointPosition, springSpec, guarantee)
            .also {
                breakpoints.add(it)
                breakpointPosition = Float.NaN
                breakpointKey = null
            }
    }
}
+157 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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 androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.mechanics.spring.SpringParameters
import com.android.mechanics.testing.DirectionalMotionSpecSubject.Companion.assertThat
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class DirectionalMotionSpecBuilderTest {

    @Test
    fun directionalSpec_buildEmptySpec() {
        val result = buildDirectionalMotionSpec()

        assertThat(result).breakpoints().isEmpty()
        assertThat(result).mappings().containsExactly(Mapping.Identity)
    }

    @Test
    fun directionalSpec_addBreakpointsAndMappings() {
        val result =
            buildDirectionalMotionSpec(Spring, Mapping.Zero) {
                mapping(breakpoint = 0f, mapping = Mapping.One, key = B1)
                mapping(breakpoint = 10f, mapping = Mapping.Two, key = B2)
            }

        assertThat(result).breakpoints().keys().containsExactly(B1, B2).inOrder()
        assertThat(result).breakpoints().withKey(B1).isAt(0f)
        assertThat(result).breakpoints().withKey(B2).isAt(10f)
        assertThat(result)
            .mappings()
            .containsExactly(Mapping.Zero, Mapping.One, Mapping.Two)
            .inOrder()
    }

    @Test
    fun directionalSpec_mappingBuilder_setsDefaultSpring() {
        val result =
            buildDirectionalMotionSpec(Spring) { constantValue(breakpoint = 10f, value = 20f) }

        assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(Spring)
    }

    @Test
    fun directionalSpec_mappingBuilder_canOverrideDefaultSpring() {
        val otherSpring = SpringParameters(stiffness = 10f, dampingRatio = 0.1f)
        val result =
            buildDirectionalMotionSpec(Spring) {
                constantValue(breakpoint = 10f, value = 20f, spring = otherSpring)
            }

        assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(otherSpring)
    }

    @Test
    fun directionalSpec_mappingBuilder_defaultsToNoGuarantee() {
        val result =
            buildDirectionalMotionSpec(Spring) { constantValue(breakpoint = 10f, value = 20f) }

        assertThat(result).breakpoints().atPosition(10f).guarantee().isEqualTo(Guarantee.None)
    }

    @Test
    fun directionalSpec_mappingBuilder_canSetGuarantee() {
        val guarantee = Guarantee.InputDelta(10f)
        val result =
            buildDirectionalMotionSpec(Spring) {
                constantValue(breakpoint = 10f, value = 20f, guarantee = guarantee)
            }

        assertThat(result).breakpoints().atPosition(10f).guarantee().isEqualTo(guarantee)
    }

    @Test
    fun directionalSpec_mappingBuilder_jumpTo_setsAbsoluteValue() {
        val result =
            buildDirectionalMotionSpec(Spring, Mapping.Fixed(99f)) {
                constantValue(breakpoint = 10f, value = 20f)
            }

        assertThat(result).breakpoints().positions().containsExactly(10f)
        assertThat(result).mappings().atOrAfter(10f).isConstantValue(20f)
    }

    @Test
    fun directionalSpec_mappingBuilder_jumpBy_setsRelativeValue() {
        val result =
            buildDirectionalMotionSpec(Spring, Mapping.Linear(factor = 0.5f)) {
                // At 10f the current value is 5f (10f * 0.5f)
                constantValueFromCurrent(breakpoint = 10f, delta = 30f)
            }

        assertThat(result).breakpoints().positions().containsExactly(10f)
        assertThat(result).mappings().atOrAfter(10f).isConstantValue(35f)
    }

    @Test
    fun directionalSpec_mappingBuilder_continueWithConstantValue_usesSourceValue() {
        val result =
            buildDirectionalMotionSpec(Spring, Mapping.Linear(factor = 0.5f)) {
                // At 5f the current value is 2.5f (5f * 0.5f)
                constantValueFromCurrent(breakpoint = 5f)
            }

        assertThat(result).mappings().atOrAfter(5f).isConstantValue(2.5f)
    }

    @Test
    fun directionalSpec_mappingBuilder_continueWithFractionalInput_matchesLinearMapping() {
        val result =
            buildDirectionalMotionSpec(Spring) {
                fractionalInput(breakpoint = 5f, from = 1f, fraction = .1f)
            }

        assertThat(result)
            .mappings()
            .atOrAfter(5f)
            .matchesLinearMapping(in1 = 5f, out1 = 1f, in2 = 15f, out2 = 2f)
    }

    @Test
    fun directionalSpec_mappingBuilder_continueWithTargetValue_matchesLinearMapping() {
        val result =
            buildDirectionalMotionSpec(Spring) {
                target(breakpoint = 5f, from = 1f, to = 20f)
                mapping(breakpoint = 30f, mapping = Mapping.Identity)
            }

        assertThat(result)
            .mappings()
            .atOrAfter(5f)
            .matchesLinearMapping(in1 = 5f, out1 = 1f, in2 = 30f, out2 = 20f)
    }

    companion object {
        val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f)
        val B1 = BreakpointKey("One")
        val B2 = BreakpointKey("Two")
    }
}