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

Commit 4fb8e634 authored by Mike Schneider's avatar Mike Schneider
Browse files

Allow MagneticEffect to be used in reverse as well

Bug: 406969900
Test: Unit tests
Flag: EXEMPT not yet used in production
Change-Id: I0c69251a153b62557148a8245a2e18dd05a47d65
parent d9f1302b
Loading
Loading
Loading
Loading
+141 −34
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ 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.EffectPlacemenType
import com.android.mechanics.spec.builder.EffectPlacement
import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.with
@@ -48,6 +49,7 @@ import com.android.mechanics.spring.SpringParameters
 */
class MagneticDetach(
    private val semanticState: SemanticKey<State> = Defaults.AttachDetachState,
    private val semanticAttachedValue: SemanticKey<Float?> = Defaults.AttachedValue,
    private val detachPosition: Dp = Defaults.DetachPosition,
    private val attachPosition: Dp = Defaults.AttachPosition,
    private val detachScale: Float = Defaults.AttachDetachScale,
@@ -76,40 +78,135 @@ class MagneticDetach(
        maxLimitKey: BreakpointKey,
        placement: EffectPlacement,
    ) {
        if (placement.type == EffectPlacemenType.Before) {
            createPlacedBeforeSpec(minLimit, minLimitKey, maxLimit, maxLimitKey)
        } else {
            assert(placement.type == EffectPlacemenType.After)
            createPlacedAfterSpec(minLimit, minLimitKey, maxLimit, maxLimitKey)
        }
    }

    object Defaults {
        val AttachDetachState = SemanticKey<State>()
        val AttachedValue = SemanticKey<Float?>()
        val AttachDetachScale = .3f
        val DetachPosition = 80.dp
        val AttachPosition = 40.dp
        val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f)
    }

    /* Effect is attached at minLimit, and detaches at maxLimit. */
    private fun EffectApplyScope.createPlacedAfterSpec(
        minLimit: Float,
        minLimitKey: BreakpointKey,
        maxLimit: Float,
        maxLimitKey: BreakpointKey,
    ) {
        val attachedValue = baseValue(minLimit)
        val detachedValue = baseValue(maxLimit)
        val reattachPos = minLimit + attachPosition.toPx()
        val startValue = baseValue(minLimit)
        val detachValue = baseValue(maxLimit)
        val reattachValue = baseValue(reattachPos)

        val scaledDetachValue = startValue + (detachValue - startValue) * detachScale
        val scaledReattachValue = startValue + (reattachValue - startValue) * attachScale
        val attachedSemantics =
            listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue)
        val detachedSemantics =
            listOf(semanticState with State.Detached, semanticAttachedValue with null)

        val attachKey = BreakpointKey("attach")
        val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale
        val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale

        val attachKey = BreakpointKey("attach")
        forward(
            initialMapping = Mapping.Linear(minLimit, startValue, maxLimit, scaledDetachValue),
            semantics = listOf(semanticState with State.Attached),
            initialMapping = Mapping.Linear(minLimit, attachedValue, maxLimit, scaledDetachValue),
            semantics = attachedSemantics,
        ) {
            after(spring = detachSpring, semantics = listOf(semanticState with State.Detached))
            after(spring = detachSpring, semantics = detachedSemantics)
            before(semantics = listOf(semanticAttachedValue with null))
        }

        backward(
            initialMapping = Mapping.Linear(minLimit, startValue, reattachPos, scaledReattachValue),
            semantics = listOf(semanticState with State.Attached),
            initialMapping =
                Mapping.Linear(minLimit, attachedValue, reattachPos, scaledReattachValue),
            semantics = attachedSemantics,
        ) {
            mapping(
                breakpoint = reattachPos,
                key = attachKey,
                spring = attachSpring,
                semantics = listOf(semanticState with State.Detached),
                semantics = detachedSemantics,
                mapping = baseMapping,
            )
            before(semantics = listOf(semanticAttachedValue with null))
            after(semantics = listOf(semanticAttachedValue with null))
        }

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

    /* Effect is attached at maxLimit, and detaches at minLimit. */
    private fun EffectApplyScope.createPlacedBeforeSpec(
        minLimit: Float,
        minLimitKey: BreakpointKey,
        maxLimit: Float,
        maxLimitKey: BreakpointKey,
    ) {
        val attachedValue = baseValue(maxLimit)
        val detachedValue = baseValue(minLimit)
        val reattachPos = maxLimit - attachPosition.toPx()
        val reattachValue = baseValue(reattachPos)

        val attachedSemantics =
            listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue)
        val detachedSemantics =
            listOf(semanticState with State.Detached, semanticAttachedValue with null)

        val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale
        val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale

        val attachKey = BreakpointKey("attach")

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

        forward(initialMapping = baseMapping, semantics = detachedSemantics) {
            target(
                breakpoint = reattachPos,
                key = attachKey,
                from = scaledReattachValue,
                to = attachedValue,
                spring = attachSpring,
                semantics = attachedSemantics,
            )
            after(semantics = listOf(semanticAttachedValue with null))
        }

        addSegmentHandlers(
            beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Min),
            beforeAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Max),
            afterAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Max),
            minLimit = minLimit,
            maxLimit = maxLimit,
        )
    }

    private fun EffectApplyScope.addSegmentHandlers(
        beforeDetachSegment: SegmentKey,
        beforeAttachSegment: SegmentKey,
        afterAttachSegment: SegmentKey,
        minLimit: Float,
        maxLimit: Float,
    ) {
        // Suppress direction change during detach. This prevents snapping to the origin when
        // changing the direction while detaching.
        addSegmentHandler(beforeDetachSegment, PreventDirectionChangeWithinCurrentSegment)
@@ -122,31 +219,41 @@ class MagneticDetach(
        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 - minLimit) / (pivotPos - minLimit)
                        lerp(startValue, pivotValue, t)
                    } else {
                        val t = (input - pivotPos) / (maxLimit - pivotPos)
                        lerp(pivotValue, scaledDetachValue, t)
                    }
                }
                nextSegment.copy(mapping = tweakedMapping)
                nextSegment.copy(
                    mapping =
                        switchMappingWithSamePivotValue(
                            currentSegment.mapping,
                            nextSegment.mapping,
                            minLimit,
                            newInput,
                            maxLimit,
                        )
                )
            } else {
                nextSegment
            }
        }
    }

    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)
    private fun switchMappingWithSamePivotValue(
        source: Mapping,
        target: Mapping,
        minLimit: Float,
        pivot: Float,
        maxLimit: Float,
    ): Mapping {
        val minValue = target.map(minLimit)
        val pivotValue = source.map(pivot)
        val maxValue = target.map(maxLimit)

        return Mapping { input ->
            if (input <= pivot) {
                val t = (input - minLimit) / (pivot - minLimit)
                lerp(minValue, pivotValue, t)
            } else {
                val t = (input - pivot) / (maxLimit - pivot)
                lerp(pivotValue, maxValue, t)
            }
        }
    }
}
+2 −0
Original line number Diff line number Diff line
@@ -69,4 +69,6 @@ class SegmentSemanticValues<T>(val key: SemanticKey<T>, val values: List<T>) {
    operator fun get(segmentIndex: Int): SemanticValue<T> {
        return SemanticValue(key, values[segmentIndex])
    }

    override fun toString() = "Semantics($key): [$values]"
}
+4 −1
Original line number Diff line number Diff line
@@ -100,11 +100,14 @@ internal open class DirectionalBuilderImpl(
            check(atPosition > breakpoints.last().position) {
                "Breakpoint ${breakpoints.last()} placed after partial sequence (end=$atPosition)"
            }
            applySemantics(semantics)
        }

        toBreakpointImpl(atPosition, key)
        doAddBreakpointImpl(springSpec, guarantee)

        if (key != BreakpointKey.MaxLimit) {
            applySemantics(semantics)
        }
    }

    fun finalizeBuilderFn(breakpoint: Breakpoint) =
+16 −8
Original line number Diff line number Diff line
@@ -67,7 +67,9 @@ fun <
    initialDirection: InputDirection = InputDirection.Max,
    directionChangeSlop: Float = 5f,
    stableThreshold: Float = 0.01f,
    verifyTimeSeries: VerifyTimeSeriesFn = { VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden },
    verifyTimeSeries: VerifyTimeSeriesFn = {
        VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden()
    },
    createDerived: (underTest: MotionValueType) -> List<MotionValueType> = { emptyList() },
    testInput: suspend (InputScope<MotionValueType, GestureContextType>).() -> Unit,
) {
@@ -140,9 +142,11 @@ suspend fun InputScope<*, *>.animatedInputSequence(vararg values: Float) {
typealias VerifyTimeSeriesFn = TimeSeries.() -> VerifyTimeSeriesResult

/** [VerifyTimeSeriesFn] indicating whether the timeseries should be verified the golden file. */
enum class VerifyTimeSeriesResult {
    SkipGoldenVerification,
    AssertTimeSeriesMatchesGolden,
interface VerifyTimeSeriesResult {
    data object SkipGoldenVerification : VerifyTimeSeriesResult

    data class AssertTimeSeriesMatchesGolden(val goldenName: String? = null) :
        VerifyTimeSeriesResult
}

/** A semantic value to capture in the golden. */
@@ -214,13 +218,17 @@ sealed class MotionValueToolkit<MotionValueType, GestureContextType> {
    ) {
        val recordedMotion = motionTestRule.create(timeSeries, screenshots = null)
        var assertTimeseriesMatchesGolden = false
        var goldenName: String? = null
        try {
            assertTimeseriesMatchesGolden =
                verificationFn.invoke(recordedMotion.timeSeries) ==
                    VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden

            val result = verificationFn.invoke(recordedMotion.timeSeries)
            if (result is VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden) {
                assertTimeseriesMatchesGolden = true
                goldenName = result.goldenName
            }
        } finally {
            try {
                motionTestRule.assertThat(recordedMotion).timeSeriesMatchesGolden()
                motionTestRule.assertThat(recordedMotion).timeSeriesMatchesGolden(goldenName)
            } catch (e: AssertionError) {
                if (assertTimeseriesMatchesGolden) {
                    throw e
Loading