Loading mechanics/src/com/android/mechanics/spec/DirectionalMotionSpecBuilder.kt 0 → 100644 +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 } } } mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecBuilderTest.kt 0 → 100644 +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") } } Loading
mechanics/src/com/android/mechanics/spec/DirectionalMotionSpecBuilder.kt 0 → 100644 +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 } } }
mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecBuilderTest.kt 0 → 100644 +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") } }