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

Commit 3ab70e95 authored by Mike Schneider's avatar Mike Schneider Committed by Android (Google) Code Review
Browse files

Merge "Add mechanic toggle effect" into main

parents 9aec2cd7 f9972b67
Loading
Loading
Loading
Loading
+23 −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.SemanticKey

object CommonSemantics {
    val RestingValueKey = SemanticKey<Float?>("")
}
+2 −46
Original line number Diff line number Diff line
@@ -21,8 +21,8 @@ package com.android.mechanics.effects
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.android.mechanics.spec.BreakpointKey
import com.android.mechanics.spec.ChangeSegmentHandlers.DirectionChangePreservesCurrentValue
import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.Mapping
@@ -144,8 +144,6 @@ class MagneticDetach(
            beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Max),
            beforeAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Min),
            afterAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Min),
            minLimit = minLimit,
            maxLimit = maxLimit,
        )
    }

@@ -195,8 +193,6 @@ class MagneticDetach(
            beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Min),
            beforeAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Max),
            afterAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Max),
            minLimit = minLimit,
            maxLimit = maxLimit,
        )
    }

@@ -204,8 +200,6 @@ class MagneticDetach(
        beforeDetachSegment: SegmentKey,
        beforeAttachSegment: SegmentKey,
        afterAttachSegment: SegmentKey,
        minLimit: Float,
        maxLimit: Float,
    ) {
        // Suppress direction change during detach. This prevents snapping to the origin when
        // changing the direction while detaching.
@@ -216,44 +210,6 @@ class MagneticDetach(

        // When changing direction after re-attaching, the pre-detach ratio is tweaked to
        // interpolate between the direction change-position and the detach point.
        addSegmentHandler(afterAttachSegment) { currentSegment, newInput, newDirection ->
            val nextSegment = segmentAtInput(newInput, newDirection)
            if (nextSegment.key == beforeDetachSegment) {
                nextSegment.copy(
                    mapping =
                        switchMappingWithSamePivotValue(
                            currentSegment.mapping,
                            nextSegment.mapping,
                            minLimit,
                            newInput,
                            maxLimit,
                        )
                )
            } else {
                nextSegment
            }
        }
    }

    private fun switchMappingWithSamePivotValue(
        source: Mapping,
        target: Mapping,
        minLimit: Float,
        pivot: Float,
        maxLimit: Float,
    ): Mapping {
        val minValue = target.map(minLimit)
        val pivotValue = source.map(pivot)
        val maxValue = target.map(maxLimit)

        return Mapping { input ->
            if (input <= pivot) {
                val t = (input - minLimit) / (pivot - minLimit)
                lerp(minValue, pivotValue, t)
            } else {
                val t = (input - pivot) / (maxLimit - pivot)
                lerp(pivotValue, maxValue, t)
            }
        }
        addSegmentHandler(afterAttachSegment, DirectionChangePreservesCurrentValue)
    }
}
+176 −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.BreakpointKey
import com.android.mechanics.spec.ChangeSegmentHandlers.DirectionChangePreservesCurrentValue
import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment
import com.android.mechanics.spec.Guarantee
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.SegmentKey
import com.android.mechanics.spec.SemanticKey
import com.android.mechanics.spec.builder.Effect
import com.android.mechanics.spec.builder.EffectApplyScope
import com.android.mechanics.spec.builder.EffectPlacemenType
import com.android.mechanics.spec.builder.EffectPlacement
import com.android.mechanics.spec.with
import com.android.mechanics.spring.SpringParameters

/**
 * A gesture effect that toggles the output value between the placement's `start` and `end` values.
 *
 * The toggle action is triggered when the input changes by a specified fraction ([toggleFraction])
 * of the total input range, measured from the start of the effect.
 *
 * The logical state of the toggle is exposed via the SemanticKey [stateKey], and is either
 * [minState] or [maxState], based on the input gesture's progress.
 *
 * @param T The type of the state being toggled.
 * @property stateKey A [SemanticKey] used to identify the current state of the toggle (either
 *   [minState] or [maxState]).
 * @property minState The value representing the logical state when toggled to the `min` side.
 * @property minState The value representing the logical state when toggled to the `max` side.
 * @property restingValueKey A [SemanticKey] used to identify the resting value of the input.
 * @property toggleFraction The fraction of the input range (between `minLimit` and `maxLimit` of
 *   the effect placement) at which the toggle action occurs. For example, a value of 0.7 means the
 *   toggle happens when the input has covered 70% of the distance from `minLimit` towards
 *   `maxLimit`.
 * @property preToggleScale A scaling factor applied to the output value *before* the toggle point
 *   is reached. This controls how much the output changes leading up to the toggle.
 * @property postToggleScale A scaling factor applied to the output value *after* the toggle point
 *   is reached. This controls the initial change in output immediately after toggling.
 * @property spring The [SpringParameters] used for the animation when the toggle action occurs.
 *   This defines the physics of the transition between states.
 */
class Toggle<T>(
    private val stateKey: SemanticKey<T>,
    private val minState: T,
    private val maxState: T,
    private val restingValueKey: SemanticKey<Float?> = CommonSemantics.RestingValueKey,
    private val toggleFraction: Float = Defaults.ToggleFraction,
    private val preToggleScale: Float = Defaults.PreToggleScale,
    private val postToggleScale: Float = Defaults.PostToggleScale,
    private val spring: SpringParameters = Defaults.Spring,
) : Effect.PlaceableBetween {

    override fun EffectApplyScope.createSpec(
        minLimit: Float,
        minLimitKey: BreakpointKey,
        maxLimit: Float,
        maxLimitKey: BreakpointKey,
        placement: EffectPlacement,
    ) {
        check(placement.type == EffectPlacemenType.Between)
        val minValue = baseValue(minLimit)
        val maxValue = baseValue(maxLimit)
        val valueRange = maxValue - minValue

        val distance = maxLimit - minLimit

        val minTargetSemantics = listOf(restingValueKey with minValue, stateKey with minState)
        val maxTargetSemantics = listOf(restingValueKey with maxValue, stateKey with maxState)

        val toggleKey = BreakpointKey("toggle")

        val forwardTogglePos = minLimit + distance * toggleFraction
        forward(
            initialMapping =
                Mapping.Linear(
                    minLimit,
                    minValue,
                    forwardTogglePos,
                    minValue + valueRange * preToggleScale,
                ),
            semantics = minTargetSemantics,
        ) {
            target(
                forwardTogglePos,
                from = maxValue - valueRange * postToggleScale,
                to = maxValue,
                spring = spring,
                semantics = maxTargetSemantics,
                key = toggleKey,
                guarantee = Guarantee.GestureDragDelta(distance * 2),
            )
        }

        val reverseTogglePos = minLimit + distance * (1 - toggleFraction)
        backward(
            initialMapping =
                Mapping.Linear(
                    minLimit,
                    minValue,
                    reverseTogglePos,
                    minValue + valueRange * postToggleScale,
                ),
            semantics = minTargetSemantics,
        ) {
            target(
                reverseTogglePos,
                from = maxValue - valueRange * preToggleScale,
                to = maxValue,
                spring = spring,
                key = toggleKey,
                semantics = maxTargetSemantics,
                guarantee = Guarantee.GestureDragDelta(distance * 2),
            )
        }

        // Before toggling, suppress direction change
        addSegmentHandler(
            SegmentKey(minLimitKey, toggleKey, InputDirection.Max),
            PreventDirectionChangeWithinCurrentSegment,
        )
        addSegmentHandler(
            SegmentKey(toggleKey, maxLimitKey, InputDirection.Min),
            PreventDirectionChangeWithinCurrentSegment,
        )

        // after toggling, ensure a direction change does
        addSegmentHandler(
            SegmentKey(toggleKey, maxLimitKey, InputDirection.Max),
            DirectionChangePreservesCurrentValue,
        )

        addSegmentHandler(
            SegmentKey(minLimitKey, toggleKey, InputDirection.Min),
            DirectionChangePreservesCurrentValue,
        )
    }

    object Defaults {
        val ToggleFraction = 0.7f
        val PreToggleScale = 0.2f
        val PostToggleScale = 0.01f
        val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f)
    }
}

/**
 * Convenience implementation of a [Toggle] effect for an expanding / collapsing element.
 *
 * This object provides a pre-configured [Toggle] specifically designed for elements that can be
 * expanded or collapsed. It exposes the logical expansion state via the semantic [IsExpandedKey].
 */
object ExpansionToggle {
    /** Semantic key for a boolean flag indicating whether the element is expanded. */
    val IsExpandedKey: SemanticKey<Boolean> = SemanticKey("IsToggleExpanded")

    /** Toggle effect with default values. */
    val Default = Toggle(IsExpandedKey, minState = false, maxState = true)
}
+110 −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.compose.ui.util.lerp

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

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

        override fun toString(): String {
            return "Identity"
        }
    }

    /** `f(x) = value` */
    data class Fixed(val value: Float) : Mapping {
        init {
            require(value.isFinite())
        }

        override fun map(input: Float): Float {
            return value
        }
    }

    /** `f(x) = factor*x + offset` */
    data class Linear(val factor: Float, val offset: Float = 0f) : Mapping {
        init {
            require(factor.isFinite())
            require(offset.isFinite())
        }

        override fun map(input: Float): Float {
            return input * factor + offset
        }
    }

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

        /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */
        fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear {
            require(in0 != in1) {
                "Cannot define a linear function with both inputs being the same ($in0)."
            }

            val factor = (out1 - out0) / (in1 - in0)
            val offset = out0 - factor * in0
            return Linear(factor, offset)
        }
    }
}

/** Convenience helper to create a linear mappings */
object LinearMappings {

    /**
     * Creates a mapping defined as two line segments between {in0,out0} -> {in1,out1}, and
     * {in1,out1} -> {in2,out2}.
     *
     * The inputs must strictly be `in0 < in1 < in2`
     */
    fun linearMappingWithPivot(
        in0: Float,
        out0: Float,
        in1: Float,
        out1: Float,
        in2: Float,
        out2: Float,
    ): Mapping {
        require(in0 < in1 && in1 < in2)
        return Mapping { input ->
            if (input <= in1) {
                val t = (input - in0) / (in1 - in0)
                lerp(out0, out1, t)
            } else {
                val t = (input - in1) / (in2 - in1)
                lerp(out1, out2, t)
            }
        }
    }
}
+0 −61
Original line number Diff line number Diff line
@@ -92,64 +92,3 @@ data class SegmentData(
        return "SegmentData(key=$key, range=$range, mapping=$mapping)"
    }
}

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

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

        override fun toString(): String {
            return "Identity"
        }
    }

    /** `f(x) = value` */
    data class Fixed(val value: Float) : Mapping {
        init {
            require(value.isFinite())
        }

        override fun map(input: Float): Float {
            return value
        }
    }

    /** `f(x) = factor*x + offset` */
    data class Linear(val factor: Float, val offset: Float = 0f) : Mapping {
        init {
            require(factor.isFinite())
            require(offset.isFinite())
        }

        override fun map(input: Float): Float {
            return input * factor + offset
        }
    }

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

        /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */
        fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear {
            require(in0 != in1) {
                "Cannot define a linear function with both inputs being the same ($in0)."
            }

            val factor = (out1 - out0) / (in1 - in0)
            val offset = out0 - factor * in0
            return Linear(factor, offset)
        }
    }
}
Loading