Loading mechanics/src/com/android/mechanics/ComposableMotionValue.kt +6 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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, ) } Loading @@ -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, Loading @@ -63,6 +67,7 @@ fun rememberMotionValue( spec = spec::value, label = label, stableThreshold = stableThreshold, hapticPlayer = hapticPlayer, ) } Loading mechanics/src/com/android/mechanics/MotionValue.kt +39 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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, Loading @@ -129,6 +134,7 @@ class MotionValue( spec: () -> MotionSpec, label: String? = null, stableThreshold: Float = StableThresholdEffect, hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, ) : MotionValueState { private val impl = ObservableComputations( Loading @@ -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. */ Loading Loading @@ -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 --------------------------------------------------------------------- Loading @@ -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 Loading Loading @@ -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 } Loading Loading @@ -446,6 +458,13 @@ private class ObservableComputations( scheduleNextFrame = true } // Perform haptics if (breakpointHaptics != null) { performBreakpointHapticFeedback(breakpointHaptics) } else { performSegmentHapticFeedback(capturedSegment.haptics) } capturedFrameTimeNanos = currentAnimationTimeNanos debugInspector?.run { Loading Loading @@ -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) } } mechanics/src/com/android/mechanics/MotionValueCollection.kt +3 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading mechanics/src/com/android/mechanics/effects/MagneticDetach.kt +27 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading Loading @@ -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, Loading @@ -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)) } Loading @@ -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)) Loading mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt 0 → 100644 +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
mechanics/src/com/android/mechanics/ComposableMotionValue.kt +6 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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, ) } Loading @@ -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, Loading @@ -63,6 +67,7 @@ fun rememberMotionValue( spec = spec::value, label = label, stableThreshold = stableThreshold, hapticPlayer = hapticPlayer, ) } Loading
mechanics/src/com/android/mechanics/MotionValue.kt +39 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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, Loading @@ -129,6 +134,7 @@ class MotionValue( spec: () -> MotionSpec, label: String? = null, stableThreshold: Float = StableThresholdEffect, hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, ) : MotionValueState { private val impl = ObservableComputations( Loading @@ -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. */ Loading Loading @@ -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 --------------------------------------------------------------------- Loading @@ -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 Loading Loading @@ -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 } Loading Loading @@ -446,6 +458,13 @@ private class ObservableComputations( scheduleNextFrame = true } // Perform haptics if (breakpointHaptics != null) { performBreakpointHapticFeedback(breakpointHaptics) } else { performSegmentHapticFeedback(capturedSegment.haptics) } capturedFrameTimeNanos = currentAnimationTimeNanos debugInspector?.run { Loading Loading @@ -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) } }
mechanics/src/com/android/mechanics/MotionValueCollection.kt +3 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading
mechanics/src/com/android/mechanics/effects/MagneticDetach.kt +27 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 { Loading Loading @@ -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, Loading @@ -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)) } Loading @@ -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)) Loading
mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt 0 → 100644 +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, ) {} } } }