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

Commit fd04c7ff authored by Mike Schneider's avatar Mike Schneider Committed by Android Build Coastguard Worker
Browse files

Add max overshoot to PhysicsPropertyAnimator

Wire up the maxOvershoot to 1/4th of the target size when shrinking

Not flagging this change as it targets 25Q4.
This also includes the reformatting required by the latest version of
ktfmt, and is not in a base CL to simplify cherry-picking.

Bug: 439485406
Test: PhysicsPropertyAnimatorTest
Test: Manually collapsed bundle
Flag: EXEMPT BUGFIX
Cherrypick-From: https://googleplex-android-review.googlesource.com/q/commit:725cfae33c53e0ac2e0fbde62bc7cc2ee9e71285
Merged-In: I23bf13e0240595d8acf7da6d720cb5540f0ab3c2
Change-Id: I23bf13e0240595d8acf7da6d720cb5540f0ab3c2
parent 5b49e719
Loading
Loading
Loading
Loading
+105 −3
Original line number Diff line number Diff line
@@ -19,15 +19,22 @@ import android.animation.AnimatorTestRule
import android.util.FloatProperty
import android.util.Property
import android.view.View

import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.dynamicanimation.animation.DynamicAnimation
import com.android.internal.dynamicanimation.animation.SpringForce
import com.android.systemui.SysuiTestCase
import com.android.systemui.res.R
import com.android.systemui.statusbar.notification.stack.AnimationProperties
import com.android.systemui.statusbar.notification.stack.ViewState
import com.google.common.truth.Truth.assertThat
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sqrt
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
@@ -53,8 +60,7 @@ class PhysicsPropertyAnimatorTest : SysuiTestCase() {
                return _value
            }
        }
    @get:Rule
    val animatorTestRule = AnimatorTestRule(this)
    @get:Rule val animatorTestRule = AnimatorTestRule(this)
    private val property: PhysicsProperty =
        PhysicsProperty(R.id.scale_x_animator_tag, effectiveProperty)
    private var finishListener: DynamicAnimation.OnAnimationEndListener? = null
@@ -269,4 +275,100 @@ class PhysicsPropertyAnimatorTest : SysuiTestCase() {
        propertyData.animator?.cancel()
        Mockito.verify(finishListener2).onAnimationEnd(any(), any(), any(), any())
    }

    @Test
    fun limitOvershoot_zeroInitialDistance_doesNotModifySpring() {
        val underTest = createTestSpring()

        underTest.limitOvershoot(initialDisplacement = 0f, maxOvershoot = 100f)
        assertThat(underTest.stiffness).isEqualTo(STIFFNESS_TEST)
        assertThat(underTest.dampingRatio).isEqualTo(DAMPING_RATIO_TEST)
    }

    @Test
    fun limitOvershoot_initialDistanceLessThanMaxOvershoot_doesNotModifySpring() {
        val underTest = createTestSpring()

        underTest.limitOvershoot(initialDisplacement = 50f, maxOvershoot = 100f)
        assertThat(underTest.stiffness).isEqualTo(STIFFNESS_TEST)
        assertThat(underTest.dampingRatio).isEqualTo(DAMPING_RATIO_TEST)
    }

    @Test
    fun limitOvershoot_initialDistanceLessThanMaxOvershoot_negativeValue_doesNotModifySpring() {
        val underTest = createTestSpring()

        underTest.limitOvershoot(initialDisplacement = -50f, maxOvershoot = 100f)
        assertThat(underTest.stiffness).isEqualTo(STIFFNESS_TEST)
        assertThat(underTest.dampingRatio).isEqualTo(DAMPING_RATIO_TEST)
    }

    @Test
    fun limitOvershoot_initialDistanceDoesNotExceedMaxOvershoot_doesNotModifySpring() {
        val underTest = createTestSpring()

        // roughly ~5.43 in this scenario
        val maxOvershoot = calculateMaxOvershoot(DAMPING_RATIO_TEST, initialDisplacement = 100f)
        underTest.limitOvershoot(initialDisplacement = 100f, maxOvershoot = ceil(maxOvershoot))

        assertThat(underTest.stiffness).isEqualTo(STIFFNESS_TEST)
        assertThat(underTest.dampingRatio).isEqualTo(DAMPING_RATIO_TEST)
    }

    @Test
    fun limitOvershoot_initialDistanceDoesExceedMaxOvershoot_modifiedSpringAndStiffness() {
        val underTest = createTestSpring()

        // roughly ~5.43 in this scenario
        val maxOvershoot = calculateMaxOvershoot(DAMPING_RATIO_TEST, initialDisplacement = 100f)
        underTest.limitOvershoot(initialDisplacement = 100f, maxOvershoot = floor(maxOvershoot))

        assertThat(underTest.stiffness).isGreaterThan(STIFFNESS_TEST)
        assertThat(underTest.dampingRatio).isGreaterThan(DAMPING_RATIO_TEST)
    }

    @Test
    fun limitOvershoot_whenConstrained_newParametersAreCorrect() {
        val underTest = createTestSpring()

        val maxOvershoot =
            floor(calculateMaxOvershoot(DAMPING_RATIO_TEST, initialDisplacement = 100f))

        underTest.limitOvershoot(initialDisplacement = 100f, maxOvershoot = maxOvershoot)

        val dampingDisplacement100 = underTest.dampingRatio

        underTest.limitOvershoot(initialDisplacement = 1000f, maxOvershoot = maxOvershoot)

        val dampingDisplacement1000 = underTest.dampingRatio

        // Damping must be further constrained for a bigger initial displacement
        assertThat(dampingDisplacement1000).isGreaterThan(dampingDisplacement100)

        assertThat(calculateMaxOvershoot(dampingDisplacement1000, 1000f))
            .isWithin(0.1f)
            .of(maxOvershoot)
    }

    companion object {
        const val STIFFNESS_TEST = 380f
        const val DAMPING_RATIO_TEST = .68f

        fun createTestSpring() =
            SpringForce().setStiffness(STIFFNESS_TEST).setDampingRatio(DAMPING_RATIO_TEST)

        fun calculateMaxOvershoot(dampingRatio: Float, initialDisplacement: Float): Float {
            require(dampingRatio > 0 && dampingRatio < 1)

            val zeta = dampingRatio.toDouble()

            val numerator = -(zeta * PI)
            val denominator = sqrt(1.0 - zeta.pow(2))
            val exponent = numerator / denominator

            val overshootRatio = exp(exponent)

            return (initialDisplacement * overshootRatio).toFloat()
        }
    }
}
+138 −24
Original line number Diff line number Diff line
@@ -16,15 +16,24 @@
package com.android.systemui.statusbar.notification

import android.util.FloatProperty
import android.util.Log
import android.util.Property
import android.view.View
import androidx.annotation.VisibleForTesting
import com.android.internal.dynamicanimation.animation.DynamicAnimation
import com.android.internal.dynamicanimation.animation.SpringAnimation
import com.android.internal.dynamicanimation.animation.SpringForce
import com.android.systemui.res.R
import com.android.systemui.statusbar.notification.PhysicsPropertyAnimator.Companion.TAG
import com.android.systemui.statusbar.notification.PhysicsPropertyAnimator.Companion.createDefaultSpring
import com.android.systemui.statusbar.notification.stack.AnimationProperties
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.asin
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.sign
import kotlin.math.sqrt

/**
 * A physically animatable property of a view.
@@ -34,8 +43,11 @@ import kotlin.math.sign
 * @param avoidDoubleOvershoot should this property avoid double overshoot when animated
 */
data class PhysicsProperty
@JvmOverloads constructor(
    val tag: Int, val property: Property<View, Float>, val avoidDoubleOvershoot: Boolean = true
@JvmOverloads
constructor(
    val tag: Int,
    val property: Property<View, Float>,
    val avoidDoubleOvershoot: Boolean = true,
) {
    val offsetProperty =
        object : FloatProperty<View>(property.name) {
@@ -67,12 +79,12 @@ data class PropertyData(
    var delayRunnable: Runnable? = null,

    /**
     * A runnable that should be executed if the animation is skipped to end / cancelled before
     * the animation actually starts running.
     * A runnable that should be executed if the animation is skipped to end / cancelled before the
     * animation actually starts running.
     */
    var endedBeforeStartingCleanupHandler: ((Boolean) -> Unit)? = null,
    var startOffset: Float = 0f,
    var doubleOvershootAvoidingListener: DynamicAnimation.OnAnimationUpdateListener? = null
    var doubleOvershootAvoidingListener: DynamicAnimation.OnAnimationUpdateListener? = null,
)

/**
@@ -99,9 +111,7 @@ class PhysicsPropertyAnimator {
        // Uses the standard spatial material spring by default
        @JvmStatic
        fun createDefaultSpring(): SpringForce {
            return SpringForce()
                .setStiffness(380f)
                .setDampingRatio(0.68f);
            return SpringForce().setStiffness(380f).setDampingRatio(0.68f)
        }

        @JvmStatic
@@ -111,6 +121,9 @@ class PhysicsPropertyAnimator {
         * animated can be used to request an animation. If the view isn't animated, this utility
         * will update the current animation if existent, such that the end value will point
         * to @param newEndValue or apply it directly if there's no animation.
         *
         * @param maxOvershoot limit the spring overshoot of the animation. If specified, must be a
         *   positive, finite distance.
         */
        fun setProperty(
            view: View,
@@ -119,9 +132,17 @@ class PhysicsPropertyAnimator {
            properties: AnimationProperties? = null,
            animated: Boolean = false,
            endListener: DynamicAnimation.OnAnimationEndListener? = null,
            maxOvershoot: Float? = null,
        ) {
            if (animated) {
                startAnimation(view, animatableProperty, newEndValue, properties, endListener)
                startAnimation(
                    view,
                    animatableProperty,
                    newEndValue,
                    properties,
                    endListener,
                    maxOvershoot,
                )
            } else {
                animatableProperty.setFinalValue(view, newEndValue)
            }
@@ -131,6 +152,8 @@ class PhysicsPropertyAnimator {
            val (_, _, animator, _) = obtainPropertyData(view, property)
            return animator?.isRunning ?: false
        }

        internal val TAG = "PhysicsPropertyAnimator"
    }
}

@@ -140,6 +163,7 @@ private fun startAnimation(
    newEndValue: Float,
    properties: AnimationProperties?,
    endListener: DynamicAnimation.OnAnimationEndListener?,
    maxOvershoot: Float?,
) {
    val property = animatableProperty.property
    val propertyData = obtainPropertyData(view, animatableProperty)
@@ -168,8 +192,10 @@ private fun startAnimation(
            propertyData.offset = 0f
        }
    }
    if (animatableProperty.avoidDoubleOvershoot
        && propertyData.doubleOvershootAvoidingListener == null) {
    if (
        animatableProperty.avoidDoubleOvershoot &&
            propertyData.doubleOvershootAvoidingListener == null
    ) {
        propertyData.doubleOvershootAvoidingListener =
            DynamicAnimation.OnAnimationUpdateListener { _, offset: Float, velocity: Float ->
                val isOscillatingBackwards = velocity.sign == propertyData.startOffset.sign
@@ -179,25 +205,39 @@ private fun startAnimation(
                if (isOvershooting && isOscillatingBackwards && !didAlreadyRemoveBounciness) {
                    // our offset is starting to decrease, let's remove all overshoot
                    animator.spring.setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY)
                } else if (!isOvershooting
                    && (didAlreadyRemoveBounciness || isOscillatingBackwards)) {
                } else if (
                    !isOvershooting && (didAlreadyRemoveBounciness || isOscillatingBackwards)
                ) {
                    // we already did overshoot, let's skip to the end to avoid oscillations.
                    // Usually we shouldn't hit this as setting the damping ratio avoid overshoots
                    // but it may still happen if we see jank
                    animator.skipToEnd();
                    animator.skipToEnd()
                }
            }
        animator.addUpdateListener(propertyData.doubleOvershootAvoidingListener)
    } else if (!animatableProperty.avoidDoubleOvershoot
        && propertyData.doubleOvershootAvoidingListener != null) {
    } else if (
        !animatableProperty.avoidDoubleOvershoot &&
            propertyData.doubleOvershootAvoidingListener != null
    ) {
        animator.removeUpdateListener(propertyData.doubleOvershootAvoidingListener)
    }

    val startOffset = previousEndValue - newEndValue + propertyData.offset

    // reset a new spring as it may have been modified
    animator.setSpring(createDefaultSpring().setFinalPosition(0f))
    val spring = createDefaultSpring().setFinalPosition(0f)
    maxOvershoot
        ?.takeIf { it > 0f }
        ?.let {
            // The spring will animate from [startOffset] to 0. Modify the spring parameters to
            // guarantee the overshoot won't exceed [maxOvershoot].
            spring.limitOvershoot(initialDisplacement = startOffset, maxOvershoot = it)
        }
    animator.setSpring(spring)

    // TODO(b/393581344): look at custom spring
    endListener?.let { animator.addEndListener(it) }

    val startOffset = previousEndValue - newEndValue + propertyData.offset
    // Immediately set the new offset that compensates for the immediate end value change
    propertyData.offset = startOffset
    propertyData.startOffset = startOffset
@@ -213,26 +253,29 @@ private fun startAnimation(
        // conditions and will never actually end them only calling start explicitly does that,
        // so let's start them again!
        animator.start()
        propertyData.endedBeforeStartingCleanupHandler = null;
        propertyData.endedBeforeStartingCleanupHandler = null
    }
    propertyData.endedBeforeStartingCleanupHandler = { cancelled ->
        val listener = properties?.getAnimationEndListener(animatableProperty.property)
        listener?.onAnimationEnd(propertyData.animator,
        listener?.onAnimationEnd(
            propertyData.animator,
            cancelled,
            0f /* value */,
            0f /* velocity */
            0f, /* velocity */
        )
        endListener?.onAnimationEnd(propertyData.animator,
        endListener?.onAnimationEnd(
            propertyData.animator,
            cancelled,
            0f /* value */,
            0f /* velocity */)
            0f, /* velocity */
        )
        propertyData.animator = null
        propertyData.doubleOvershootAvoidingListener = null
        propertyData.offset = 0f
        // We always reset the offset as we never want to get stuck with old values. This is
        // consistent with the end listener above.
        property.set(view, propertyData.finalValue)
        propertyData.endedBeforeStartingCleanupHandler = null;
        propertyData.endedBeforeStartingCleanupHandler = null
    }
    if (properties != null && properties.delay > 0 && !animator.isRunning) {
        propertyData.delayRunnable = startRunnable
@@ -251,3 +294,74 @@ private fun obtainPropertyData(view: View, animatableProperty: PhysicsProperty):
    }
    return propertyData
}

/**
 * Modifies this spring's parameters to guarantee it overshoots by at most [maxOvershoot], when
 * started with [initialDisplacement] and an initial velocity of 0.
 *
 * This requires the current spring parameters to be under-damped.
 */
@VisibleForTesting
fun SpringForce.limitOvershoot(initialDisplacement: Float, maxOvershoot: Float) {
    require(maxOvershoot > 0)
    val absoluteDisplacement = initialDisplacement.absoluteValue.toDouble()
    if (absoluteDisplacement == 0.0) {
        // Nothing to animate, cannot compute the constraint
        return
    }

    val originalStiffness = stiffness.toDouble()
    val originalDamping = dampingRatio.toDouble()
    if (originalDamping <= 0 || originalDamping >= 1) {
        Log.w(
            TAG,
            "limitOvershoot can be applied to under-damped springs only, but is $originalDamping",
        )
        return
    }

    if (maxOvershoot >= absoluteDisplacement) {
        // the overshoot is guaranteed to be less than absoluteDisplacement, so we don't need to
        // adjust
        return
    }

    // Calculate required damping to guarantee the overshoot won't exceed maxOvershoot.
    val lnOvershootRatio = ln(absoluteDisplacement / maxOvershoot)
    val requiredDamping = lnOvershootRatio / sqrt(PI.pow(2) + lnOvershootRatio.pow(2))
    if (requiredDamping < originalDamping) {
        // The current damping is already sufficient to not exceed the maxOvershoot. No need to
        // modify the spring
        return
    }

    if (requiredDamping >= 1) {
        // A critically / over-damped spring would never overshoot. Given the initial conditions
        // above, the branch should not be reached. Log a warning and don't modify the spring, this
        // is a state we did not expect.
        Log.w(
            TAG,
            "Unexpected required damping of $requiredDamping. " +
                "(original: $originalDamping, " +
                "displacement: $absoluteDisplacement, " +
                "maxOvershoot: $maxOvershoot)",
        )
        return
    }

    // The requiredDamping computed above guarantees that the overshoot won't exceed maxOvershoot
    // Now tweak the stiffness to compensate for the shift in frequency. We do this by aligning the
    // first 0-crossing with the original parameters.

    // Compute the time 0 is crossed, assuming the spring starts with 0 initial velocity.
    val omegaN = sqrt(originalStiffness) // Natural frequency
    val omegaD = omegaN * sqrt(1f - originalDamping.pow(2)) // Damped frequency
    val targetTime = (PI / 2f + asin(originalDamping)) / omegaD

    val numerator = PI / 2.0 + asin(requiredDamping)
    val denominator = targetTime * sqrt(1.0 - requiredDamping.pow(2))
    val requiredStiffness = (numerator / denominator).pow(2)

    setStiffness(requiredStiffness.toFloat())
    setDampingRatio(requiredDamping.toFloat())
}
+15 −2
Original line number Diff line number Diff line
@@ -192,6 +192,7 @@ public class ExpandableViewState extends ViewState {
        if (this.height != expandableView.getActualHeight()) {
            if (mUsePhysicsForMovement) {
                boolean animateHeight = properties.getAnimationFilter().animateHeight;
                float maxOvershoot = Float.POSITIVE_INFINITY;
                if (animateHeight) {
                    expandableView.setActualHeightAnimating(true);
                }
@@ -204,10 +205,22 @@ public class ExpandableViewState extends ViewState {
                            row.setGroupExpansionChanging(false /* isExpansionChanging */);
                        }
                    };

                    float targetHeight = this.height;
                    float currentHeight = expandableView.getActualHeight();
                    if (targetHeight < currentHeight && targetHeight > 0) {
                        // Avoid elements become invisible / very squished when collapsing a large
                        // list, for example bundle headers. In cases where the start height is
                        // large, the resulting overshoot could render the collapsed element
                        // temporarily invisible.

                        // This heuristic to limits the overshoot when collapsing an element to
                        // never squish it by more than a quarter of its target size.
                        maxOvershoot = targetHeight / 4f;
                    }
                }
                PhysicsPropertyAnimator.setProperty(child, HEIGHT_PROPERTY, this.height, properties,
                        animateHeight,
                        endListener);
                        animateHeight, endListener, maxOvershoot);
            } else {
                startHeightAnimationInterpolator(expandableView, properties);
            }