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

Commit f9972b67 authored by Mike Schneider's avatar Mike Schneider
Browse files

Add mechanic toggle effect

- This refactors the MechanicsDetach to extract the helper
  `switchMappingWithSamePivotValue` into an
  `DirectionChangePreservesCurrentValue`

Test: Unit Tests
Bug: 391553479
Flag: EXEMPT Not yet used
Change-Id: Ib12062c081a04f1612cba0c73e6a336ec73de0df
parent 3bef4d81
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