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

Commit 26104a85 authored by Mike Schneider's avatar Mike Schneider
Browse files

Implement reusable `MagneticDetach` effect

Test: MagneticDetachTest
Flag: EXEMPT Not yet used
Bug: 406969900
Change-Id: Ibb63bee5ead4f8746a0fcc62df78af321fa98f03
parent 2c9cd8b1
Loading
Loading
Loading
Loading
+151 −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.
 */

@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)

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.PreventDirectionChangeWithinCurrentSegment
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.EffectPlacement
import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.with
import com.android.mechanics.spring.SpringParameters

/**
 * Gesture effect that emulates effort to detach an element from its resting position.
 *
 * @param semanticState semantic state used to check the state of this effect.
 * @param detachPosition distance from the origin to detach
 * @param attachPosition distance from the origin to re-attach
 * @param detachScale fraction of input changes propagated during detach.
 * @param attachScale fraction of input changes propagated after re-attach.
 * @param detachSpring spring used during detach
 * @param attachSpring spring used during attach
 */
class MagneticDetach(
    private val semanticState: SemanticKey<State> = Defaults.AttachDetachState,
    private val detachPosition: Dp = Defaults.DetachPosition,
    private val attachPosition: Dp = Defaults.AttachPosition,
    private val detachScale: Float = Defaults.AttachDetachScale,
    private val attachScale: Float = Defaults.AttachDetachScale * (attachPosition / detachPosition),
    private val detachSpring: SpringParameters = Defaults.Spring,
    private val attachSpring: SpringParameters = Defaults.Spring,
) : Effect {

    init {
        require(attachPosition <= detachPosition)
    }

    enum class State {
        Attached,
        Detached,
    }

    override fun MotionBuilderContext.measure(effectPlacement: EffectPlacement): Float {
        return detachPosition.toPx() * effectPlacement.directionSign
    }

    override fun EffectApplyScope.createSpec() {
        val startPos = minLimit
        val reattachPos = startPos + attachPosition.toPx()
        val detachPos = maxLimit
        val startValue = baseValue(startPos)
        val detachValue = baseValue(detachPos)
        val reattachValue = baseValue(reattachPos)

        val scaledDetachValue = startValue + (detachValue - startValue) * detachScale
        val scaledReattachValue = startValue + (reattachValue - startValue) * attachScale

        val attachKey = BreakpointKey("attach")

        forward(
            initialMapping = Mapping.Linear(startPos, startValue, detachPos, scaledDetachValue),
            semantics = listOf(semanticState with State.Attached),
        ) {
            maxLimitSpring = detachSpring
            maxLimitSemantics = listOf(semanticState with State.Detached)
        }

        backward(
            initialMapping = Mapping.Linear(startPos, startValue, reattachPos, scaledReattachValue),
            semantics = listOf(semanticState with State.Attached),
        ) {
            mapping(
                breakpoint = reattachPos,
                key = attachKey,
                spring = attachSpring,
                semantics = listOf(semanticState with State.Detached),
                mapping = baseMapping,
            )
        }

        val beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Max)
        val beforeAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Min)
        val afterAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Min)

        // Suppress direction change during detach. This prevents snapping to the origin when
        // changing the direction while detaching.
        addSegmentHandler(beforeDetachSegment, PreventDirectionChangeWithinCurrentSegment)
        // Suppress direction when approaching attach. This prevents the detach effect when changing
        // direction just before reattaching.
        addSegmentHandler(beforeAttachSegment, PreventDirectionChangeWithinCurrentSegment)

        // 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) {

                val pivotPos = newInput
                val pivotValue = currentSegment.mapping.map(pivotPos)

                val tweakedMapping = Mapping { input ->
                    if (input <= pivotPos) {
                        val t = (input - startPos) / (pivotPos - startPos)
                        lerp(startValue, pivotValue, t)
                    } else {
                        val t = (input - pivotPos) / (detachPos - pivotPos)
                        lerp(pivotValue, scaledDetachValue, t)
                    }
                }
                nextSegment.copy(mapping = tweakedMapping)
            } else {
                nextSegment
            }
        }
    }

    companion object {
        object Defaults {
            val AttachDetachState = SemanticKey<State>()
            val AttachDetachScale = .3f
            val DetachPosition = 80.dp
            val AttachPosition = 40.dp
            val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f)
        }
    }
}
+0 −19
Original line number Diff line number Diff line
@@ -19,25 +19,6 @@ package com.android.mechanics.spec
import androidx.compose.ui.util.fastFirstOrNull
import com.android.mechanics.spring.SpringParameters

/**
 * Handler to allow for custom segment-change logic.
 *
 * This handler is called whenever the new input (position or direction) does not match
 * [currentSegment] anymore (see [SegmentData.isValidForInput]).
 *
 * This is intended to implement custom effects on direction-change.
 *
 * Implementations can return:
 * 1. [currentSegment] to delay/suppress segment change.
 * 2. `null` to use the default segment lookup based on [newPosition] and [newDirection]
 * 3. manually looking up segments on this [MotionSpec]
 * 4. create a [SegmentData] that is not in the spec.
 */
typealias OnChangeSegmentHandler =
    MotionSpec.(
        currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection,
    ) -> SegmentData?

/**
 * Specification for the mapping of input values to output values.
 *
+11 −0
Original line number Diff line number Diff line
@@ -138,5 +138,16 @@ fun interface Mapping {
        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)
        }
    }
}
+48 −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

/**
 * Handler to allow for custom segment-change logic.
 *
 * This handler is called whenever the new input (position or direction) does not match
 * [currentSegment] anymore (see [SegmentData.isValidForInput]).
 *
 * This is intended to implement custom effects on direction-change.
 *
 * Implementations can return:
 * 1. [currentSegment] to delay/suppress segment change.
 * 2. `null` to use the default segment lookup based on [newPosition] and [newDirection]
 * 3. manually looking up segments on this [MotionSpec]
 * 4. create a [SegmentData] that is not in the spec.
 */
typealias OnChangeSegmentHandler =
    MotionSpec.(
        currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection,
    ) -> SegmentData?

/** Generic change segment handlers. */
object ChangeSegmentHandlers {
    /** Prevents direction changes, as long as the input is still valid on the current segment. */
    val PreventDirectionChangeWithinCurrentSegment: OnChangeSegmentHandler =
        { currentSegment, newInput, newDirection ->
            currentSegment.takeIf {
                newDirection != currentSegment.direction &&
                    it.isValidForInput(newInput, currentSegment.direction)
            }
        }
}
+662 −0
Original line number Diff line number Diff line
{
  "frame_ids": [
    0,
    16,
    32,
    48,
    64,
    80,
    96,
    112,
    128,
    144,
    160,
    176,
    192,
    208,
    224,
    240,
    256,
    272,
    288,
    304,
    320,
    336,
    352,
    368,
    384,
    400,
    416,
    432,
    448,
    464,
    480,
    496,
    512,
    528,
    544,
    560,
    576,
    592,
    608,
    624,
    640,
    656,
    672,
    688,
    704,
    720,
    736,
    752,
    768,
    784,
    800,
    816,
    832,
    848,
    864,
    880,
    896,
    912,
    928,
    944,
    960,
    976
  ],
  "features": [
    {
      "name": "input",
      "type": "float",
      "data_points": [
        100,
        95,
        90,
        85,
        80,
        75,
        70,
        65,
        60,
        55,
        50,
        45,
        40,
        35,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        30,
        35,
        40,
        45,
        50,
        55,
        60,
        65,
        70,
        75,
        80,
        85,
        90,
        95,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100
      ]
    },
    {
      "name": "gestureDirection",
      "type": "string",
      "data_points": [
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Min",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max"
      ]
    },
    {
      "name": "output",
      "type": "float",
      "data_points": [
        100,
        95,
        90,
        85,
        80,
        75,
        70,
        65,
        60,
        55,
        50,
        43.38443,
        36.351646,
        29.990938,
        24.672552,
        21.162388,
        18.574236,
        16.725906,
        15.440355,
        14.566638,
        13.985239,
        13.6060915,
        13.363756,
        13.212058,
        13.11921,
        13.063812,
        13.031747,
        13.013887,
        13.004453,
        13,
        13.75,
        14.5,
        16.449999,
        18.400002,
        20.35,
        22.300001,
        24.25,
        26.2,
        28.15,
        30.1,
        32.05,
        34,
        44.585567,
        58.759357,
        68.21262,
        76.507256,
        83.19111,
        88.2904,
        92.03026,
        94.689606,
        96.532425,
        97.780754,
        98.60885,
        99.14723,
        99.49028,
        99.70432,
        99.83485,
        99.9124,
        99.957054,
        99.98176,
        99.994675,
        100
      ]
    },
    {
      "name": "outputTarget",
      "type": "float",
      "data_points": [
        100,
        95,
        90,
        85,
        80,
        75,
        70,
        65,
        60,
        55,
        16,
        15.25,
        14.5,
        13.75,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13,
        13.75,
        14.5,
        16.449999,
        18.400002,
        20.35,
        22.300001,
        24.25,
        26.2,
        28.15,
        30.1,
        32.05,
        90,
        95,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100,
        100
      ]
    },
    {
      "name": "outputSpring",
      "type": "springParameters",
      "data_points": [
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        },
        {
          "stiffness": 800,
          "dampingRatio": 0.95
        }
      ]
    },
    {
      "name": "isStable",
      "type": "boolean",
      "data_points": [
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        false,
        true
      ]
    }
  ]
}
 No newline at end of file
Loading