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

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

Merge "[MotionSpecBuilder] for the #MotionMechanics library" into main

parents e6350d2b ac0a76d2
Loading
Loading
Loading
Loading
+369 −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

/**
 * Fluent builder for [DirectionalMotionSpec].
 *
 * This builder ensures correctness at compile-time, and simplifies the expression of the
 * input-to-output mapping.
 *
 * The [MotionSpec] is defined by specify interleaved [Mapping]s and [Breakpoint]s. [Breakpoint]s
 * must be specified in ascending order.
 *
 * NOTE: The returned fluent interfaces must only be used for chaining calls to build exactly one
 * [DirectionalMotionSpec], otherwise resulting behavior is undefined, since the builder is
 * internally mutated.
 *
 * @param defaultSpring spring to use for all breakpoints by default.
 * @param initialMapping the [Mapping] from [Breakpoint.minLimit] to the next [Breakpoint].
 * @see reverseBuilder to specify [Breakpoint]s in descending order.
 */
fun DirectionalMotionSpec.Companion.builder(
    defaultSpring: SpringParameters,
    initialMapping: Mapping = Mapping.Identity,
): FluentSpecEndSegmentWithNextBreakpoint<DirectionalMotionSpec> {
    return FluentSpecBuilder(defaultSpring, InputDirection.Max) { it }
        .apply { mappings.add(initialMapping) }
}

/**
 * Fluent builder for [DirectionalMotionSpec], specifying breakpoints and mappings in reverse order.
 *
 * Variant of [DirectionalMotionSpec.Companion.builder], where [Breakpoint]s must be specified in
 * *descending* order. The resulting [DirectionalMotionSpec] will contain the breakpoints in
 * ascending order.
 *
 * @param defaultSpring spring to use for all breakpoints by default.
 * @param initialMapping the [Mapping] from [Breakpoint.maxLimit] to the next [Breakpoint].
 * @see DirectionalMotionSpec.Companion.builder for more documentation.
 */
fun DirectionalMotionSpec.Companion.reverseBuilder(
    defaultSpring: SpringParameters,
    initialMapping: Mapping = Mapping.Identity,
): FluentSpecEndSegmentWithNextBreakpoint<DirectionalMotionSpec> {
    return FluentSpecBuilder(defaultSpring, InputDirection.Min) { it }
        .apply { mappings.add(initialMapping) }
}

/**
 * Fluent builder for a [MotionSpec], which uses the same spec in both directions.
 *
 * @param defaultSpring spring to use for all breakpoints by default.
 * @param initialMapping [Mapping] for the first segment
 * @param resetSpring the [MotionSpec.resetSpring].
 */
fun MotionSpec.Companion.builder(
    defaultSpring: SpringParameters,
    initialMapping: Mapping = Mapping.Identity,
    resetSpring: SpringParameters = defaultSpring,
): FluentSpecEndSegmentWithNextBreakpoint<MotionSpec> {
    return FluentSpecBuilder(defaultSpring, InputDirection.Max) {
            MotionSpec(it, resetSpring = resetSpring)
        }
        .apply { mappings.add(initialMapping) }
}

/** Fluent-interface to end the current segment, by placing the next [Breakpoint]. */
interface FluentSpecEndSegmentWithNextBreakpoint<R> {
    /**
     * Adds a new [Breakpoint] at the specified position.
     *
     * @param atPosition The position of the breakpoint, in the input domain of the [MotionValue].
     * @param key identifies the breakpoint in the [DirectionalMotionSpec]. Must be specified to
     *   reference the breakpoint or segment.
     */
    fun toBreakpoint(
        atPosition: Float,
        key: BreakpointKey = BreakpointKey(),
    ): FluentSpecDefineBreakpointAndStartNextSegment<R>

    /** Completes the spec by placing the last, implicit [Breakpoint]. */
    fun complete(): R
}

/** Fluent-interface to define the [Breakpoint]'s properties and start to start the next segment. */
interface FluentSpecDefineBreakpointAndStartNextSegment<R> {
    /**
     * Default spring parameters for breakpoint, as specified at creation time of the builder.
     *
     * Used as the default `spring` parameters.
     */
    val defaultSpring: SpringParameters

    /**
     * Starts the next segment, using the specified mapping.
     *
     * @param mapping the mapping to use for the next segment.
     * @param spring the spring to animate this breakpoints discontinuity.
     * @param guarantee a guarantee by when the animation must be complete
     */
    fun continueWith(
        mapping: Mapping,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
    ): FluentSpecEndSegmentWithNextBreakpoint<R>

    /**
     * Starts the next linear-mapped segment, by specifying the output [value] this breakpoint.
     *
     * @param value the output value the new mapping will produce at this breakpoints position.
     * @param spring the spring to animate this breakpoints discontinuity.
     * @param guarantee a guarantee by when the animation must be complete
     */
    fun jumpTo(
        value: Float,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
    ): FluentSpecDefineLinearSegmentMapping<R>

    /**
     * Starts the next linear-mapped segment, by offsetting the output by [delta] from the incoming
     * mapping.
     *
     * @param delta the delta in output from the previous mapping's output.
     * @param spring the spring to animate this breakpoints discontinuity.
     * @param guarantee a guarantee by when the animation must be complete
     */
    fun jumpBy(
        delta: Float,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
    ): FluentSpecDefineLinearSegmentMapping<R>

    /**
     * Completes the spec by using [mapping] between the this and the implicit sentinel breakpoint
     * at infinity.
     *
     * @param mapping the mapping to use for the final segment.
     * @param spring the spring to animate this breakpoints discontinuity.
     * @param guarantee a guarantee by when the animation must be complete
     */
    fun completeWith(
        mapping: Mapping,
        spring: SpringParameters = defaultSpring,
        guarantee: Guarantee = Guarantee.None,
    ): R
}

/** Fluent-interface to define a linear mapping between two breakpoints. */
interface FluentSpecDefineLinearSegmentMapping<R> {
    /**
     * The linear-mapping will produce the specified [target] output at the next breakpoint
     * position.
     *
     * @param target the output value the new mapping will produce at the next breakpoint position.
     */
    fun continueWithTargetValue(target: Float): FluentSpecEndSegmentWithNextBreakpoint<R>

    /**
     * Defines the slope for the linear mapping, as a fraction of the input value.
     *
     * @param fraction the multiplier applied to the input value..
     */
    fun continueWithFractionalInput(fraction: Float): FluentSpecEndSegmentWithNextBreakpoint<R>

    /**
     * The linear-mapping will produce a constant value, as defined at the source breakpoint of this
     * segment.
     */
    fun continueWithConstantValue(): FluentSpecEndSegmentWithNextBreakpoint<R>
}

/** Implements the fluent spec builder logic. */
private class FluentSpecBuilder<R>(
    override val defaultSpring: SpringParameters,
    buildDirection: InputDirection = InputDirection.Max,
    private val toResult: (DirectionalMotionSpec) -> R,
) :
    FluentSpecDefineLinearSegmentMapping<R>,
    FluentSpecDefineBreakpointAndStartNextSegment<R>,
    FluentSpecEndSegmentWithNextBreakpoint<R> {
    private val buildForward = buildDirection == InputDirection.Max

    val breakpoints = mutableListOf<Breakpoint>()
    val mappings = mutableListOf<Mapping>()

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

    init {
        val initialBreakpoint = if (buildForward) Breakpoint.minLimit else Breakpoint.maxLimit
        breakpoints.add(initialBreakpoint)
    }

    //  FluentSpecDefineLinearSegmentMapping

    override fun continueWithTargetValue(target: Float): FluentSpecEndSegmentWithNextBreakpoint<R> {
        check(sourceValue.isFinite())

        // memoize for FluentSpecEndSegmentWithNextBreakpoint
        targetValue = target

        return this
    }

    override fun continueWithFractionalInput(
        fraction: Float
    ): FluentSpecEndSegmentWithNextBreakpoint<R> {
        check(sourceValue.isFinite())

        // memoize for FluentSpecEndSegmentWithNextBreakpoint
        fractionalMapping = fraction

        return this
    }

    override fun continueWithConstantValue(): FluentSpecEndSegmentWithNextBreakpoint<R> {
        check(sourceValue.isFinite())

        mappings.add(Mapping.Fixed(sourceValue))

        sourceValue = Float.NaN
        return this
    }

    // FluentSpecDefineBreakpointAndStartNextSegment implementation

    override fun jumpTo(
        value: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
    ): FluentSpecDefineLinearSegmentMapping<R> {
        check(sourceValue.isNaN())

        doAddBreakpoint(spring, guarantee)
        sourceValue = value

        return this
    }

    override fun jumpBy(
        delta: Float,
        spring: SpringParameters,
        guarantee: Guarantee,
    ): FluentSpecDefineLinearSegmentMapping<R> {
        check(sourceValue.isNaN())

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

        return this
    }

    override fun continueWith(
        mapping: Mapping,
        spring: SpringParameters,
        guarantee: Guarantee,
    ): FluentSpecEndSegmentWithNextBreakpoint<R> {
        check(sourceValue.isNaN())

        doAddBreakpoint(spring, guarantee)
        mappings.add(mapping)

        return this
    }

    override fun completeWith(mapping: Mapping, spring: SpringParameters, guarantee: Guarantee): R {
        check(sourceValue.isNaN())

        doAddBreakpoint(spring, guarantee)
        mappings.add(mapping)

        return complete()
    }

    // FluentSpecEndSegmentWithNextBreakpoint implementation

    override fun toBreakpoint(
        atPosition: Float,
        key: BreakpointKey,
    ): FluentSpecDefineBreakpointAndStartNextSegment<R> {
        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 =
                if (buildForward) sourceValue - (sourcePosition * fractionalMapping)
                else targetValue - (atPosition * fractionalMapping)

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

        breakpointPosition = atPosition
        breakpointKey = key

        return this
    }

    override fun complete(): R {
        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),
                )
            )
        }

        if (buildForward) {
            breakpoints.add(Breakpoint.maxLimit)
        } else {
            breakpoints.add(Breakpoint.minLimit)
            breakpoints.reverse()
            mappings.reverse()
        }

        return toResult(DirectionalMotionSpec(breakpoints.toList(), mappings.toList()))
    }

    private fun doAddBreakpoint(springSpec: SpringParameters, guarantee: Guarantee): Breakpoint {
        check(breakpointPosition.isFinite())
        return Breakpoint(checkNotNull(breakpointKey), breakpointPosition, springSpec, guarantee)
            .also {
                breakpoints.add(it)
                breakpointPosition = Float.NaN
                breakpointKey = null
            }
    }
}
+25 −0
Original line number Diff line number Diff line
@@ -94,6 +94,31 @@ data class DirectionalMotionSpec(val breakpoints: List<Breakpoint>, val mappings
        require(mappings.size == breakpoints.size - 1)
    }

    /**
     * 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.
     *
     * @param position the position in the input domain.
     * @return Index into [breakpoints], guaranteed to be in range `0..breakpoints.size - 2`
     */
    fun findBreakpointIndex(position: Float): Int {
        require(position.isFinite())
        val breakpointPosition = breakpoints.binarySearchBy(position) { it.position }

        val result =
            when {
                // position is between two anchors, return the min one.
                breakpointPosition < 0 -> -breakpointPosition - 2
                else -> breakpointPosition
            }

        check(result >= 0)
        check(result < breakpoints.size - 1)

        return result
    }

    companion object {
        /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */
        val Empty =
+254 −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 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 FluentSpecBuilderTest {

    @Test
    fun directionalSpec_buildEmptySpec() {
        val result = DirectionalMotionSpec.builder(spring).complete()

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

    @Test
    fun directionalSpec_buildEmptySpec_inReverse() {
        val result = DirectionalMotionSpec.reverseBuilder(spring).complete()

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

    @Test
    fun motionSpec_sameSpecInBothDirections() {
        val result =
            MotionSpec.builder(spring, Mapping.Zero)
                .toBreakpoint(0f, b1)
                .continueWith(Mapping.One)
                .toBreakpoint(10f, b2)
                .completeWith(Mapping.Two)

        assertThat(result.maxDirection).isSameInstanceAs(result.minDirection)

        assertThat(result.minDirection).breakpoints().keys().containsExactly(b1, b2).inOrder()
        assertThat(result.minDirection)
            .mappings()
            .containsExactly(Mapping.Zero, Mapping.One, Mapping.Two)
            .inOrder()
    }

    @Test
    fun directionalSpec_addBreakpointsAndMappings() {
        val result =
            DirectionalMotionSpec.builder(spring, Mapping.Zero)
                .toBreakpoint(0f, b1)
                .continueWith(Mapping.One)
                .toBreakpoint(10f, b2)
                .completeWith(Mapping.Two)

        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_addBreakpointsAndMappings_inReverse() {
        val result =
            DirectionalMotionSpec.reverseBuilder(spring, Mapping.Two)
                .toBreakpoint(10f, b2)
                .continueWith(Mapping.One)
                .toBreakpoint(0f, b1)
                .completeWith(Mapping.Zero)

        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 =
            DirectionalMotionSpec.builder(spring)
                .toBreakpoint(10f)
                .jumpTo(20f)
                .continueWithConstantValue()
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_canOverrideDefaultSpring() {
        val otherSpring = SpringParameters(stiffness = 10f, dampingRatio = 0.1f)
        val result =
            DirectionalMotionSpec.builder(spring)
                .toBreakpoint(10f)
                .jumpTo(20f, spring = otherSpring)
                .continueWithConstantValue()
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_defaultsToNoGuarantee() {
        val result =
            DirectionalMotionSpec.builder(spring)
                .toBreakpoint(10f)
                .jumpTo(20f)
                .continueWithConstantValue()
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_canSetGuarantee() {
        val guarantee = Guarantee.InputDelta(10f)
        val result =
            DirectionalMotionSpec.builder(spring)
                .toBreakpoint(10f)
                .jumpTo(20f, guarantee = guarantee)
                .continueWithConstantValue()
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_jumpTo_setsAbsoluteValue() {
        val result =
            DirectionalMotionSpec.builder(spring, Mapping.Fixed(99f))
                .toBreakpoint(10f)
                .jumpTo(20f)
                .continueWithConstantValue()
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_jumpBy_setsRelativeValue() {
        val result =
            DirectionalMotionSpec.builder(spring, Mapping.Linear(factor = 0.5f))
                .toBreakpoint(10f)
                .jumpBy(30f)
                .continueWithConstantValue()
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_continueWithConstantValue_usesSourceValue() {
        val result =
            DirectionalMotionSpec.builder(spring, Mapping.Linear(factor = 0.5f))
                .toBreakpoint(5f)
                .jumpBy(0f)
                .continueWithConstantValue()
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_continueWithFractionalInput_matchesLinearMapping() {
        val result =
            DirectionalMotionSpec.builder(spring)
                .toBreakpoint(5f)
                .jumpTo(1f)
                .continueWithFractionalInput(fraction = .1f)
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_reverse_continueWithFractionalInput_matchesLinearMapping() {
        val result =
            DirectionalMotionSpec.reverseBuilder(spring)
                .toBreakpoint(15f)
                .jumpTo(2f)
                .continueWithFractionalInput(fraction = .1f)
                .complete()

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

    @Test
    fun directionalSpec_mappingBuilder_continueWithTargetValue_matchesLinearMapping() {
        val result =
            DirectionalMotionSpec.builder(spring)
                .toBreakpoint(5f)
                .jumpTo(1f)
                .continueWithTargetValue(target = 20f)
                .toBreakpoint(30f)
                .completeWith(Mapping.Identity)

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

    @Test
    fun directionalSpec_mappingBuilder_reverse_continueWithTargetValue_matchesLinearMapping() {
        val result =
            DirectionalMotionSpec.reverseBuilder(spring)
                .toBreakpoint(30f)
                .jumpTo(20f)
                .continueWithTargetValue(target = 1f)
                .toBreakpoint(5f)
                .completeWith(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")
        val b3 = BreakpointKey("Three")
    }
}
+49 −0

File changed.

Preview size limit exceeded, changes collapsed.

+195 −0

File added.

Preview size limit exceeded, changes collapsed.