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

Commit 2cc3b6d9 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Don't decay when it will be too slow

This CL makes the settle animation of STL swipe transitions feel better
by avoiding the case when the velocity is just high enough to reach the
target offset but still a bit too low, causing the settle animation to
be too slow.

The idea is to simply perform whichever of the spring or decay animation
will be the fastest. Unfortunately there are no APIs to know how long it
will take for an animation to reach the target offset for the first
time, so these durations have to be computed analytically. See
b/417444347#comment3 for details.

Bug: 417444347
Flag: com.android.systemui.scene_container
Test: atest SwipeAnimationTest
Change-Id: Ifb2ee648c6a3937050fcf27319b588b66c89a01b
parent 2e5b9e70
Loading
Loading
Loading
Loading
+118 −5
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -413,7 +414,19 @@ internal class SwipeAnimation<T : ContentKey>(
                else -> error("Target $targetOffset should be $lowerBound or $upperBound")
            }

        if (willDecayReachBounds) {
        // TODO(b/417444347): Use the default or fast spatial spec for small STLs, or make it a
        // parameter of the transitions spec.
        val animationSpec = spec ?: layoutState.motionScheme.slowSpatialSpec()
        if (
            willDecayReachBounds &&
                willDecayFasterThanAnimating(
                    animationSpec,
                    decayAnimationSpec,
                    initialOffset,
                    targetOffset,
                    initialVelocity,
                )
        ) {
            val result = animatable.animateDecay(initialVelocity, decayAnimationSpec)
            check(animatable.value == targetOffset) {
                buildString {
@@ -433,12 +446,9 @@ internal class SwipeAnimation<T : ContentKey>(
            return initialVelocity - result.endState.velocity
        }

        // TODO(b/417444347): Use the default or fast spatial spec for small STLs, or make it a
        // parameter of the transitions spec.
        val motionSpatialSpec = spec ?: layoutState.motionScheme.slowSpatialSpec()
        animatable.animateTo(
            targetValue = targetOffset,
            animationSpec = motionSpatialSpec,
            animationSpec = animationSpec,
            initialVelocity = initialVelocity,
        )

@@ -477,6 +487,106 @@ internal class SwipeAnimation<T : ContentKey>(
    }
}

internal fun willDecayFasterThanAnimating(
    animationSpec: AnimationSpec<Float>,
    decayAnimationSpec: DecayAnimationSpec<Float>,
    initialOffset: Float,
    targetOffset: Float,
    initialVelocity: Float,
): Boolean {
    if (initialOffset == targetOffset) {
        return true
    }

    fun hasReachedTargetOffset(value: Float): Boolean {
        return when {
            initialOffset < targetOffset -> value >= targetOffset
            else -> value <= targetOffset
        }
    }

    val converter = Float.VectorConverter
    val decayAnimationSpecVector = decayAnimationSpec.vectorize(converter)
    val initialOffsetVector = converter.convertToVector(initialOffset)
    val initialVelocityVector = converter.convertToVector(initialVelocity)

    // Given that the Animatable that we are going to animate with animationSpec or
    // decayAnimationSpec has bounds and will stop as soon as the targetOffset is reached, we
    // can not use the getDurationNanos() API from VectorizedAnimationSpec and
    // VectorizedDecayAnimationSpec.
    //
    // For the decay, we can use a simple binary search given that once the decay has reached
    // the target value it will never change direction.
    val decayDuration = binarySearch { timeMs ->
        hasReachedTargetOffset(
            converter.convertFromVector(
                decayAnimationSpecVector.getValueFromNanos(
                    playTimeNanos = timeMs * MillisToNanos,
                    initialValue = initialOffsetVector,
                    initialVelocity = initialVelocityVector,
                )
            )
        )
    }

    // For the animation we can't use binary search given that springs and eased interpolations
    // can oscillate around the target offset. Given that it's ok to estimate this duration, we
    // simply check whether we passed the threshold for each single frame step time (~8ms).
    val animationSpecVector = animationSpec.vectorize(converter)
    val targetOffsetVector = converter.convertToVector(targetOffset)
    val maxAnimationDurationMs =
        animationSpecVector.getDurationNanos(
            initialOffsetVector,
            targetOffsetVector,
            initialVelocityVector,
        ) / MillisToNanos
    var animationDurationMs = 0
    var hasReachedTarget = false
    while (!hasReachedTarget && animationDurationMs < maxAnimationDurationMs) {
        animationDurationMs += ApproximateFrameTime
        hasReachedTarget =
            hasReachedTargetOffset(
                converter.convertFromVector(
                    animationSpecVector.getValueFromNanos(
                        playTimeNanos = animationDurationMs * MillisToNanos,
                        initialValue = initialOffsetVector,
                        initialVelocity = initialVelocityVector,
                        targetValue = targetOffsetVector,
                    )
                )
            )
    }

    return decayDuration <= animationDurationMs
}

/** Returns the lowest timeMs >= 0 for which [f] is true. */
private fun binarySearch(f: (timeMs: Long) -> Boolean): Long {
    check(!f(0)) { "f should return false for timeMillis=0" }
    var low = 0L
    var high = 128L // common duration that is also a power of 2.
    while (!f(high)) {
        if (high > Long.MAX_VALUE / 2) {
            error("overflow, f($high) returned false")
        }

        low = high
        high *= 2
    }

    var result = high
    while (low <= high) {
        val mid = low + (high - low) / 2
        if (f(mid)) {
            result = mid
            high = mid - 1
        } else {
            low = mid + 1
        }
    }
    return result
}

private object DefaultSwipeDistance : UserActionDistance {
    override fun UserActionDistanceScope.absoluteDistance(
        fromContent: ContentKey,
@@ -641,3 +751,6 @@ private class ReplaceOverlaySwipeTransition(
        swipeAnimation.freezeAndAnimateToCurrentState()
    }
}

private const val MillisToNanos = 1_000_000L
private const val ApproximateFrameTime = 1_000 / 120
+80 −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.compose.animation.scene

import androidx.compose.animation.SplineBasedFloatDecayAnimationSpec
import androidx.compose.animation.core.generateDecayAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.Density
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SwipeAnimationTest {
    @Test
    fun animationSlowerThanDecay() {
        assertThat(
                willDecayFasterThanAnimating(
                    // High animation duration.
                    animationSpec = tween(durationMillis = 1_000),
                    decayAnimationSpec =
                        SplineBasedFloatDecayAnimationSpec(density = Density(1f))
                            .generateDecayAnimationSpec(),
                    initialOffset = 0f,
                    targetOffset = 1_000f,
                    initialVelocity = 4_000f,
                )
            )
            .isTrue()
    }

    @Test
    fun animationFasterThanDecay() {
        assertThat(
                willDecayFasterThanAnimating(
                    // Low animation duration.
                    animationSpec = tween(durationMillis = 1),
                    decayAnimationSpec =
                        SplineBasedFloatDecayAnimationSpec(density = Density(1f))
                            .generateDecayAnimationSpec(),
                    initialOffset = 0f,
                    targetOffset = 1_000f,
                    initialVelocity = 4_000f,
                )
            )
            .isFalse()
    }

    @Test
    fun sameInitialAndTargetOffset() {
        assertThat(
                willDecayFasterThanAnimating(
                    // Low animation duration.
                    animationSpec = tween(durationMillis = 1),
                    decayAnimationSpec =
                        SplineBasedFloatDecayAnimationSpec(density = Density(1f))
                            .generateDecayAnimationSpec(),
                    initialOffset = 1_000f,
                    targetOffset = 1_000f,
                    initialVelocity = 4_000f,
                )
            )
            .isTrue()
    }
}