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

Commit 53ad621b authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Introducing the FVF model in motion mechanics.

The implementation consists of:

* Segment haptic types that describe a particular haptic behavior. One
  description can be given per segment or groups of segments in a
  directional motion spec.
* Breakpoint haptic types that describe haptics when breakpoints are
  crossed.
* A haptic player that translates haptic descriptions into haptic
  feedback.

Test: manual. Verified haptics in the MagneticDetachDemo
Test: modified and added Unit tests
Flag: NONE prototyping structure for haptics
Bug: 441561363

Change-Id: I24e1cf43d436b9d5bd72ca4b7c900e274f624390
parent 4a4d840f
Loading
Loading
Loading
Loading
+6 −1
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import com.android.mechanics.haptics.HapticPlayer
import com.android.mechanics.spec.MotionSpec
import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.builder.rememberMotionBuilderContext
@@ -33,15 +34,17 @@ fun rememberMotionValue(
    spec: () -> MotionSpec,
    label: String? = null,
    stableThreshold: Float = 0.01f,
    hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer,
): MotionValue {
    val motionValue =
        remember(input) {
        remember(input, hapticPlayer) {
            MotionValue(
                input = input,
                gestureContext = gestureContext,
                spec = spec,
                label = label,
                stableThreshold = stableThreshold,
                hapticPlayer = hapticPlayer,
            )
        }

@@ -56,6 +59,7 @@ fun rememberMotionValue(
    spec: State<MotionSpec>,
    label: String? = null,
    stableThreshold: Float = 0.01f,
    hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer,
): MotionValue {
    return rememberMotionValue(
        input = input,
@@ -63,6 +67,7 @@ fun rememberMotionValue(
        spec = spec::value,
        label = label,
        stableThreshold = stableThreshold,
        hapticPlayer = hapticPlayer,
    )
}

+39 −0
Original line number Diff line number Diff line
@@ -29,6 +29,9 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import com.android.mechanics.debug.DebugInspector
import com.android.mechanics.debug.FrameData
import com.android.mechanics.haptics.BreakpointHaptics
import com.android.mechanics.haptics.HapticPlayer
import com.android.mechanics.haptics.SegmentHaptics
import com.android.mechanics.impl.Computations
import com.android.mechanics.impl.DiscontinuityAnimation
import com.android.mechanics.impl.GuaranteeState
@@ -122,6 +125,8 @@ import kotlinx.coroutines.withContext
 * @param label An optional label to aid in debugging.
 * @param stableThreshold A threshold value (in output units) that determines when the
 *   [MotionValue]'s internal spring animation is considered stable.
 * @param hapticPlayer When specifying segment and breakpoint haptics, this player will be used to
 *   deliver haptic feedback.
 */
class MotionValue(
    input: () -> Float,
@@ -129,6 +134,7 @@ class MotionValue(
    spec: () -> MotionSpec,
    label: String? = null,
    stableThreshold: Float = StableThresholdEffect,
    hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer,
) : MotionValueState {
    private val impl =
        ObservableComputations(
@@ -137,6 +143,7 @@ class MotionValue(
            specProvider = spec,
            stableThreshold = stableThreshold,
            label = label,
            hapticPlayer = hapticPlayer,
        )

    /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */
@@ -291,6 +298,7 @@ private class ObservableComputations(
    private val specProvider: () -> MotionSpec,
    override val stableThreshold: Float,
    override val label: String?,
    private val hapticPlayer: HapticPlayer,
) : Computations() {

    // ----  CurrentFrameInput ---------------------------------------------------------------------
@@ -309,6 +317,8 @@ private class ObservableComputations(

    override var currentAnimationTimeNanos by mutableLongStateOf(-1L)

    override var lastHapticsTimeNanos by mutableLongStateOf(-1L)

    // ----  LastFrameState ---------------------------------------------------------------------

    override var lastSegment: SegmentData by
@@ -406,12 +416,14 @@ private class ObservableComputations(
                }

                var scheduleNextFrame = false
                var breakpointHaptics: BreakpointHaptics? = null
                if (!isSameSegmentAndAtRest) {
                    // Read currentComputedValues only once and update it, if necessary
                    val currentValues = currentComputedValues

                    if (capturedSegment != currentValues.segment) {
                        capturedSegment = currentValues.segment
                        breakpointHaptics = currentValues.breakpointHaptics
                        scheduleNextFrame = true
                    }

@@ -446,6 +458,13 @@ private class ObservableComputations(
                    scheduleNextFrame = true
                }

                // Perform haptics
                if (breakpointHaptics != null) {
                    performBreakpointHapticFeedback(breakpointHaptics)
                } else {
                    performSegmentHapticFeedback(capturedSegment.haptics)
                }

                capturedFrameTimeNanos = currentAnimationTimeNanos

                debugInspector?.run {
@@ -504,4 +523,24 @@ private class ObservableComputations(
        }

    var debugInspector: DebugInspector? = null

    private fun performSegmentHapticFeedback(segmentHaptics: SegmentHaptics) {
        val timeDelta = currentAnimationTimeNanos - lastHapticsTimeNanos
        if (timeDelta < hapticPlayer.getPlaybackIntervalNanos()) return

        val spatialInputPx = computedOutput
        val velocityPxPerSec = directMappedVelocity // we assume this is always in px/sec.
        lastHapticsTimeNanos = currentAnimationTimeNanos
        hapticPlayer.playSegmentHaptics(segmentHaptics, spatialInputPx, velocityPxPerSec)
    }

    private fun performBreakpointHapticFeedback(breakpointHaptics: BreakpointHaptics) {
        val timeDelta = currentAnimationTimeNanos - lastHapticsTimeNanos
        if (timeDelta < hapticPlayer.getPlaybackIntervalNanos()) return

        val spatialInputPx = computedOutput
        val velocityPxPerSec = directMappedVelocity // we assume this is always in px/sec.
        lastHapticsTimeNanos = currentAnimationTimeNanos
        hapticPlayer.playBreakpointHaptics(breakpointHaptics, spatialInputPx, velocityPxPerSec)
    }
}
+3 −0
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ package com.android.mechanics
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.setValue
@@ -330,6 +331,8 @@ internal class ManagedMotionComputation(
    override val lastGestureDragOffset
        get() = owner.lastGestureDragOffset

    override var lastHapticsTimeNanos: Long by mutableLongStateOf(-1L)

    // ---- Computations ---------------------------------------------------------------------------

    var debugInspector: DebugInspector? = null
+27 −1
Original line number Diff line number Diff line
@@ -21,6 +21,9 @@ package com.android.mechanics.effects
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.mechanics.haptics.BreakpointHaptics
import com.android.mechanics.haptics.HapticsExperimentalApi
import com.android.mechanics.haptics.SegmentHaptics
import com.android.mechanics.spec.BreakpointKey
import com.android.mechanics.spec.ChangeSegmentHandlers.DirectionChangePreservesCurrentValue
import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment
@@ -56,6 +59,7 @@ class MagneticDetach(
    private val attachScale: Float = Defaults.AttachDetachScale * (attachPosition / detachPosition),
    private val detachSpring: SpringParameters = Defaults.Spring,
    private val attachSpring: SpringParameters = Defaults.Spring,
    private val enableHaptics: Boolean = false,
) : Effect.PlaceableAfter, Effect.PlaceableBefore {

    init {
@@ -96,6 +100,7 @@ class MagneticDetach(
    }

    /* Effect is attached at minLimit, and detaches at maxLimit. */
    @OptIn(HapticsExperimentalApi::class)
    private fun EffectApplyScope.createPlacedAfterSpec(
        minLimit: Float,
        minLimitKey: BreakpointKey,
@@ -115,12 +120,32 @@ class MagneticDetach(
        val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale
        val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale

        // Haptic specs
        val tensionHaptics =
            if (enableHaptics) {
                SegmentHaptics.SpringTension(anchorPointPx = minLimit)
            } else {
                SegmentHaptics.None
            }
        val thresholdHaptics =
            if (enableHaptics) {
                BreakpointHaptics.GenericThreshold
            } else {
                BreakpointHaptics.None
            }

        val attachKey = BreakpointKey("attach")

        forward(
            initialMapping = Mapping.Linear(minLimit, attachedValue, maxLimit, scaledDetachValue),
            initialSegmentHaptics = tensionHaptics,
            semantics = attachedSemantics,
        ) {
            after(spring = detachSpring, semantics = detachedSemantics)
            after(
                spring = detachSpring,
                semantics = detachedSemantics,
                breakpointHaptics = thresholdHaptics,
            )
            before(semantics = listOf(semanticAttachedValue with null))
        }

@@ -135,6 +160,7 @@ class MagneticDetach(
                spring = attachSpring,
                semantics = detachedSemantics,
                mapping = baseMapping,
                breakpointHaptics = thresholdHaptics,
            )
            before(semantics = listOf(semanticAttachedValue with null))
            after(semantics = listOf(semanticAttachedValue with null))
+52 −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.haptics

interface HapticPlayer {

    fun playSegmentHaptics(
        segmentHaptics: SegmentHaptics,
        spatialInput: Float,
        spatialVelocity: Float,
    )

    fun playBreakpointHaptics(
        breakpointHaptics: BreakpointHaptics,
        spatialInput: Float,
        spatialVelocity: Float,
    )

    /** Get the minimum interval required for haptics to play */
    fun getPlaybackIntervalNanos(): Long = 0L

    companion object {
        val NoPlayer =
            object : HapticPlayer {
                override fun playSegmentHaptics(
                    segmentHaptics: SegmentHaptics,
                    spatialInput: Float,
                    spatialVelocity: Float,
                ) {}

                override fun playBreakpointHaptics(
                    breakpointHaptics: BreakpointHaptics,
                    spatialInput: Float,
                    spatialVelocity: Float,
                ) {}
            }
    }
}
Loading