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

Commit 4b6c5728 authored by Mike Schneider's avatar Mike Schneider
Browse files

`MotionSpecBuilder` to allow composing motion specs from behaviors.

Test: MotionSpecBuilderTest.kt
Flag: EXEMPT not yet used
Bug: 401500734
Change-Id: I761996a99d100c048f344ffff75cea64968664e0
parent a2d9f56d
Loading
Loading
Loading
Loading
+56 −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.effects

import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.builder.Effect
import com.android.mechanics.spec.builder.EffectApplyScope
import com.android.mechanics.spec.builder.EffectPlacement
import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.builder.MotionSpecBuilderScope

/** Creates a [FixedValue] effect with the given [value]. */
fun MotionSpecBuilderScope.fixed(value: Float) = FixedValue(value)

val MotionSpecBuilderScope.zero: FixedValue
    get() = FixedValue.Zero
val MotionSpecBuilderScope.one: FixedValue
    get() = FixedValue.One

/** Produces a fixed [value]. */
open class FixedValue(val value: Float) : Effect {

    override fun EffectApplyScope.createSpec() {
        return unidirectional(Mapping.Fixed(value))
    }

    companion object {
        val Zero = FixedValue(0f)
        val One = FixedValue(1f)
    }
}

/** Produces a fixed [value], for a predefined [extent]. */
class FixedValueWithExtent(value: Float, private val extent: Float) : FixedValue(value) {
    init {
        require(extent > 0)
    }

    override fun MotionBuilderContext.measure(effectPlacement: EffectPlacement): Float {
        return extent * effectPlacement.directionSign
    }
}
+6 −5
Original line number Diff line number Diff line
@@ -18,7 +18,6 @@ package com.android.mechanics.spec

import com.android.mechanics.spec.builder.DirectionalBuilderFn
import com.android.mechanics.spec.builder.DirectionalBuilderImpl
import com.android.mechanics.spec.builder.SegmentSemanticValuesBuilder
import com.android.mechanics.spring.SpringParameters

/**
@@ -63,10 +62,12 @@ fun buildDirectionalMotionSpec(
    semantics: List<SemanticValue<*>> = emptyList(),
    init: DirectionalBuilderFn,
): DirectionalMotionSpec {
    return DirectionalBuilderImpl(defaultSpring)
        .also { it.mappings += initialMapping }
        .also { it.semantics += semantics.map { SegmentSemanticValuesBuilder(it) } }
        .also { it.init() }
    return DirectionalBuilderImpl(defaultSpring, semantics)
        .apply {
            prepareBuilderFn(initialMapping)
            init()
            finalizeBuilderFn(Breakpoint.maxLimit)
        }
        .build()
}

+103 −16
Original line number Diff line number Diff line
@@ -30,8 +30,10 @@ import com.android.mechanics.spring.SpringParameters
 *
 * Clients must use [buildDirectionalMotionSpec] instead.
 */
internal class DirectionalBuilderImpl(override val defaultSpring: SpringParameters) :
    DirectionalBuilderScope {
internal class DirectionalBuilderImpl(
    override val defaultSpring: SpringParameters,
    baseSemantics: List<SemanticValue<*>>,
) : DirectionalBuilderScope {
    internal val breakpoints = mutableListOf(Breakpoint.minLimit)
    internal val semantics = mutableListOf<SegmentSemanticValuesBuilder<*>>()
    internal val mappings = mutableListOf<Mapping>()
@@ -41,9 +43,79 @@ internal class DirectionalBuilderImpl(override val defaultSpring: SpringParamete
    private var breakpointPosition: Float = Float.NaN
    private var breakpointKey: BreakpointKey? = null

    init {
        baseSemantics.forEach { semantics.add(SegmentSemanticValuesBuilder(it)) }
    }

    /** Prepares the builder for invoking the [DirectionalBuilderFn] on it. */
    fun prepareBuilderFn(
        initialMapping: Mapping = Mapping.Identity,
        initialSemantics: List<SemanticValue<*>> = emptyList(),
    ) {
        check(mappings.size == breakpoints.size - 1)

        mappings.add(initialMapping)
        initialSemantics.forEach { semantic ->
            val existingBuilder = semantics.firstOrNull { it.key == semantic.key }
            if (existingBuilder != null) {
                existingBuilder.backfill(mappings.size)
                existingBuilder.append(semantic.value)
            } else {
                SegmentSemanticValuesBuilder(semantic).also { semantics.add(it) }
            }
        }
    }

    /**
     * Finalizes open segments, after invoking a [DirectionalBuilderFn].
     *
     * Afterwards, either [build] or another pair of {[prepareBuilderFn], [finalizeBuilderFn]} calls
     * can be done.
     */
    fun finalizeBuilderFn(
        atPosition: Float,
        key: BreakpointKey,
        springSpec: SpringParameters,
        guarantee: Guarantee,
        semantics: List<SemanticValue<*>>,
    ) {
        if (!(targetValue.isNaN() && fractionalMapping.isNaN())) {
            // Finalizing will produce the mapping and breakpoint
            check(mappings.size == breakpoints.size - 1)
        } else {
            // Mapping is already added, this will add the breakpoint
            check(mappings.size == breakpoints.size)
        }

        if (key == Breakpoint.maxLimit.key) {
            check(targetValue.isNaN()) { "cant specify target value for last segment" }
            check(semantics.isEmpty()) { "cant specify semantics for last breakpoint" }
        } else {
            check(atPosition.isFinite())
            check(atPosition > breakpoints.last().position) {
                "Breakpoints were placed outside of partial sequence"
            }
            applySemantics(semantics)
        }

        toBreakpointImpl(atPosition, key)
        doAddBreakpointImpl(springSpec, guarantee)
    }

    fun finalizeBuilderFn(breakpoint: Breakpoint) =
        finalizeBuilderFn(
            breakpoint.position,
            breakpoint.key,
            breakpoint.spring,
            breakpoint.guarantee,
            emptyList(),
        )

    /* Creates the [DirectionalMotionSpec] from the current builder state. */
    fun build(): DirectionalMotionSpec {
        completeImpl()
        require(mappings.size == breakpoints.size - 1)
        check(breakpoints.last() == Breakpoint.maxLimit)

        val semantics =
            semantics.map { builder ->
                with(builder) {
@@ -217,6 +289,10 @@ internal class DirectionalBuilderImpl(override val defaultSpring: SpringParamete
        check(breakpointPosition.isNaN())
        check(breakpointKey == null)

        check(atPosition >= breakpoints.last().position) {
            "Breakpoint position specified is before last breakpoint"
        }

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

@@ -249,7 +325,31 @@ internal class DirectionalBuilderImpl(override val defaultSpring: SpringParamete
        breakpointKey = key
    }

    private fun doAddBreakpointImpl(
        springSpec: SpringParameters,
        guarantee: Guarantee,
    ): Breakpoint {
        val breakpoint =
            if (breakpointKey == Breakpoint.maxLimit.key) {
                check(breakpointPosition == Float.POSITIVE_INFINITY)
                Breakpoint.maxLimit
            } else {
                check(breakpointPosition.isFinite())
                Breakpoint(checkNotNull(breakpointKey), breakpointPosition, springSpec, guarantee)
            }

        breakpoints.add(breakpoint)
        breakpointPosition = Float.NaN
        breakpointKey = null

        return breakpoint
    }

    private fun completeImpl() {
        if (breakpoints.last() == Breakpoint.maxLimit) {
            return
        }

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

        if (!fractionalMapping.isNaN()) {
@@ -267,19 +367,6 @@ internal class DirectionalBuilderImpl(override val defaultSpring: SpringParamete

        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
            }
    }
}

internal class SegmentSemanticValuesBuilder<T>(seed: SemanticValue<T>) {
+61 −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.builder

/**
 * Blueprint for a reusable behavior in a [MotionSpec].
 *
 * [Effect] instances are reusable for building multiple
 */
interface Effect {
    /**
     * Defines the intrinsic length of the effect.
     *
     * The return value takes precedent over the [effectPlacement]. By default, the result is
     * derived from the [effectPlacement].
     *
     * Returning `Float.POSITIVE/NEGATIVE_INFINITY` will cause the effect to extend to the start of
     * the next effect, or the boundary of the effect.
     */
    fun MotionBuilderContext.measure(effectPlacement: EffectPlacement): Float {
        return if (effectPlacement.end.isFinite()) {
            effectPlacement.end - effectPlacement.start
        } else {
            effectPlacement.end
        }
    }

    /**
     * Applies the effect to the motion spec.
     *
     * The boundaries of the effect are defined by the [EffectApplyScope.minLimit] and
     * [EffectApplyScope.maxLimit] properties, and extend in both, the min and max direction by the
     * same amount.
     *
     * Implementations must invoke either [EffectApplyScope.unidirectional] or both,
     * [EffectApplyScope.forward] and [EffectApplyScope.backward]. The motion spec builder will
     * throw if neither is called.
     */
    fun EffectApplyScope.createSpec()
}

/**
 * Handle for an [Effect] that was placed within a [MotionSpecBuilderScope].
 *
 * Used to place effects relative to each other.
 */
@JvmInline value class PlacedEffect internal constructor(internal val id: Int)
+186 −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.builder

import com.android.mechanics.spec.BreakpointKey
import com.android.mechanics.spec.Guarantee
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.OnChangeSegmentHandler
import com.android.mechanics.spec.SegmentKey
import com.android.mechanics.spec.SemanticValue
import com.android.mechanics.spring.SpringParameters

/**
 * Defines the contract for applying [Effect]s within a [MotionSpecBuilder]
 *
 * Provides methods to define breakpoints and mappings for the motion specification.
 *
 * Breakpoints for [minLimit] and [maxLimit] will be created, with the specified key and parameters.
 */
interface EffectApplyScope : MotionBuilderContext {
    /** Default spring in use when not otherwise specified. */
    val defaultSpring: SpringParameters

    /** Mapping used outside of the defined effects. */
    val baseMapping: Mapping

    /** This effects `minLimit` position. */
    val minLimit: Float
    /** This effects `minLimit` [BreakpointKey].* */
    val minLimitKey: BreakpointKey
    /** [Guarantee] of the `minLimit` breakpoint. */
    var minLimitGuarantee: Guarantee
    /** [SpringParameters] of the `minLimit` breakpoint. */
    var minLimitSpring: SpringParameters

    /** This effects `maxLimit` position. */
    val maxLimit: Float
    /** This effects `maxLimit` [BreakpointKey].* */
    val maxLimitKey: BreakpointKey
    /** [Guarantee] of the `maxLimit` breakpoint. */
    var maxLimitGuarantee: Guarantee
    /** [SpringParameters] of the `maxLimit` breakpoint. */
    var maxLimitSpring: SpringParameters
    /** Semantics to be applied at the `maxLimit` breakpoint. */
    var maxLimitSemantics: List<SemanticValue<*>>

    /**
     * Defines spec simultaneously for both, the min and max direction.
     *
     * The behavior is the same as for `buildDirectionalMotionSpec`, with the notable exception that
     * the spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints
     * outside of this range will throw.
     *
     * Will throw if [forward] or [unidirectional] has been called in this scope before.
     *
     * The first / last semantic value will implicitly extend to the start / end of the resulting
     * spec, unless redefined in another spec.
     *
     * @param initialMapping [Mapping] for the first segment after [minLimit].
     * @param semantics Initial semantics for the effect.
     * @param init Configures the effect's spec using [DirectionalBuilderScope].
     * @see com.android.mechanics.spec.buildDirectionalMotionSpec for in-depth documentation.
     */
    fun unidirectional(
        initialMapping: Mapping,
        semantics: List<SemanticValue<*>> = emptyList(),
        init: DirectionalBuilderScope.() -> Unit,
    )

    /**
     * Defines spec simultaneously for both, the min and max direction, using a single segment only.
     *
     * The behavior is the same as for `buildDirectionalMotionSpec`, with the notable exception that
     * the spec to be defined is confined within [minLimit] and [maxLimit].
     *
     * Will throw if [forward] or [unidirectional] has been called in this scope before.
     *
     * The first / last semantic value will implicitly extend to the start / end of the resulting
     * spec, unless redefined in another spec.
     *
     * @param mapping [Mapping] to be used between [minLimit] and [maxLimit].
     * @param semantics Initial semantics for the effect.
     * @see com.android.mechanics.spec.buildDirectionalMotionSpec for in depth documentation.
     */
    fun unidirectional(mapping: Mapping, semantics: List<SemanticValue<*>> = emptyList())

    /**
     * Defines the spec for max direction.
     *
     * The behavior is the same as for `buildDirectionalMotionSpec`, with the notable exception that
     * the spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints
     * outside of this range will throw.
     *
     * Will throw if [forward] or [unidirectional] has been called in this scope before.
     *
     * The first / last semantic value will implicitly extend to the start / end of the resulting
     * spec, unless redefined in another spec.
     *
     * @param initialMapping [Mapping] for the first segment after [minLimit].
     * @param semantics Initial semantics for the effect.
     * @param init Configures the effect's spec using [DirectionalBuilderScope].
     * @see com.android.mechanics.spec.buildDirectionalMotionSpec for in-depth documentation.
     */
    fun forward(
        initialMapping: Mapping,
        semantics: List<SemanticValue<*>> = emptyList(),
        init: DirectionalBuilderScope.() -> Unit,
    )

    /**
     * Defines the spec for max direction, using a single segment only.
     *
     * The behavior is the same as for `buildDirectionalMotionSpec`, with the notable exception that
     * the spec to be defined is confined within [minLimit] and [maxLimit].
     *
     * Will throw if [forward] or [unidirectional] has been called in this scope before.
     *
     * The first / last semantic value will implicitly extend to the start / end of the resulting
     * spec, unless redefined in another spec.
     *
     * @param mapping [Mapping] to be used between [minLimit] and [maxLimit].
     * @param semantics Initial semantics for the effect.
     * @see com.android.mechanics.spec.buildDirectionalMotionSpec for in depth documentation.
     */
    fun forward(mapping: Mapping, semantics: List<SemanticValue<*>> = emptyList())

    /**
     * Defines the spec for min direction.
     *
     * The behavior is the same as for `buildDirectionalMotionSpec`, with the notable exception that
     * the spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints
     * outside of this range will throw.
     *
     * Will throw if [forward] or [unidirectional] has been called in this scope before.
     *
     * The first / last semantic value will implicitly extend to the start / end of the resulting
     * spec, unless redefined in another spec.
     *
     * @param initialMapping [Mapping] for the first segment after [minLimit].
     * @param semantics Initial semantics for the effect.
     * @param init Configures the effect's spec using [DirectionalBuilderScope].
     * @see com.android.mechanics.spec.buildDirectionalMotionSpec for in-depth documentation.
     */
    fun backward(
        initialMapping: Mapping,
        semantics: List<SemanticValue<*>> = emptyList(),
        init: DirectionalBuilderScope.() -> Unit,
    )

    /**
     * Defines the spec for min direction, using a single segment only.
     *
     * The behavior is the same as for `buildDirectionalMotionSpec`, with the notable exception that
     * the spec to be defined is confined within [minLimit] and [maxLimit].
     *
     * Will throw if [forward] or [unidirectional] has been called in this scope before.
     *
     * The first / last semantic value will implicitly extend to the start / end of the resulting
     * spec, unless redefined in another spec.
     *
     * @param mapping [Mapping] to be used between [minLimit] and [maxLimit].
     * @param semantics Initial semantics for the effect.
     * @see com.android.mechanics.spec.buildDirectionalMotionSpec for in depth documentation.
     */
    fun backward(mapping: Mapping, semantics: List<SemanticValue<*>> = emptyList())

    /** Adds a segment handler to the resulting [MotionSpec]. */
    fun addSegmentHandler(key: SegmentKey, handler: OnChangeSegmentHandler)

    /** Returns the value of [baseValue] at [position]. */
    fun baseValue(position: Float): Float
}
Loading