Loading mechanics/src/com/android/mechanics/effects/MagneticDetach.kt 0 → 100644 +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) } } } mechanics/src/com/android/mechanics/spec/Breakpoint.kt +2 −1 Original line number Diff line number Diff line Loading @@ -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')})" } } Loading mechanics/src/com/android/mechanics/spec/MotionSpec.kt +0 −19 Original line number Diff line number Diff line Loading @@ -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. * Loading mechanics/src/com/android/mechanics/spec/Segment.kt +27 −1 Original line number Diff line number Diff line Loading @@ -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]. Loading Loading @@ -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)" } } /** Loading @@ -96,6 +107,10 @@ fun interface Mapping { override fun map(input: Float): Float { return input } override fun toString(): String { return "Identity" } } /** `f(x) = value` */ Loading Loading @@ -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) } } } mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt 0 → 100644 +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
mechanics/src/com/android/mechanics/effects/MagneticDetach.kt 0 → 100644 +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) } } }
mechanics/src/com/android/mechanics/spec/Breakpoint.kt +2 −1 Original line number Diff line number Diff line Loading @@ -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')})" } } Loading
mechanics/src/com/android/mechanics/spec/MotionSpec.kt +0 −19 Original line number Diff line number Diff line Loading @@ -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. * Loading
mechanics/src/com/android/mechanics/spec/Segment.kt +27 −1 Original line number Diff line number Diff line Loading @@ -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]. Loading Loading @@ -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)" } } /** Loading @@ -96,6 +107,10 @@ fun interface Mapping { override fun map(input: Float): Float { return input } override fun toString(): String { return "Identity" } } /** `f(x) = value` */ Loading Loading @@ -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) } } }
mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt 0 → 100644 +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) } } }