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

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

Merge changes I9cde0e6d,Ibb63bee5 into main

* changes:
  Make debug printing more useful
  Implement reusable `MagneticDetach` effect
parents bed8baae e1e21b93
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)
        }
    }
}
+2 −1
Original line number Diff line number Diff line
@@ -39,7 +39,8 @@ class BreakpointKey(val debugLabel: String? = null, val identity: Any = Object()
    }

    override fun toString(): String {
        return if (debugLabel != null) "BreakpointKey(label=$debugLabel)" else "BreakpointKey()"
        return "BreakpointKey(${debugLabel ?: ""}" +
            "@${System.identityHashCode(identity).toString(16).padStart(8,'0')})"
    }
}

+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.
 *
+27 −1
Original line number Diff line number Diff line
@@ -28,7 +28,11 @@ data class SegmentKey(
    val minBreakpoint: BreakpointKey,
    val maxBreakpoint: BreakpointKey,
    val direction: InputDirection,
)
) {
    override fun toString(): String {
        return "SegmentKey(min=$minBreakpoint, max=$maxBreakpoint, direction=$direction)"
    }
}

/**
 * Captures denormalized segment data from a [MotionSpec].
@@ -80,6 +84,13 @@ data class SegmentData(
    fun <T> semantic(semanticKey: SemanticKey<T>): T? {
        return spec.semanticState(semanticKey, key)
    }

    val range: ClosedFloatingPointRange<Float>
        get() = minBreakpoint.position..maxBreakpoint.position

    override fun toString(): String {
        return "SegmentData(key=$key, range=$range, mapping=$mapping)"
    }
}

/**
@@ -96,6 +107,10 @@ fun interface Mapping {
        override fun map(input: Float): Float {
            return input
        }

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

    /** `f(x) = value` */
@@ -138,5 +153,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)
            }
        }
}
Loading