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

Commit 7406ab15 authored by Steve Elliott's avatar Steve Elliott
Browse files

AnimatedValue per-value animation end signal

This change reworks animation end signalling for AnimatedValue; rather
than consuming a top-level Flow to signal that an animation has ended,
each AnimatedValue exposes its own stopAnimating() method that,
critically, only affects that specific AnimatedValue; if a new
AnimatedValue is emitted by the Flow returned from
toAnimatedValueFlow(), then a invoking stopAnimating() on a
previously-emitted AnimatedValue will be ignored.

This helps avoid a common pitfall with modelling animation state, where
a new AnimatedValue is emitted whilst a previous animation is still
occurring. In many cases, we want to cancel() the previous animation,
which without careful management, will result in a cancel signal making
it back to the toAnimatedValueFlow(), *before* the new animation is even
started. This will cause a new AnimatedValue to be emitted with
isAnimating == false, immediately cancelling the new animation.

Bug: 278765923
Test: atest SystemUITests
Change-Id: I3503cbf604d6b85b573987264c0bb7611632b293
parent da362ca1
Loading
Loading
Loading
Loading
+1 −2
Original line number Diff line number Diff line
@@ -652,8 +652,7 @@ public class ComplicationLayoutEngine implements Complication.VisibilityControll
            CrossFadeHelper.fadeOut(
                    mLayout,
                    mFadeOutDuration,
                    /* delay= */ 0,
                    /* endRunnable= */ null);
                    /* delay= */ 0);
        }
    }

+64 −12
Original line number Diff line number Diff line
@@ -16,7 +16,9 @@

package com.android.systemui.statusbar;

import android.animation.Animator;
import android.view.View;
import android.view.ViewPropertyAnimator;

import androidx.annotation.Nullable;

@@ -31,32 +33,58 @@ public class CrossFadeHelper {
    public static final long ANIMATION_DURATION_LENGTH = 210;

    public static void fadeOut(final View view) {
        fadeOut(view, null);
        fadeOut(view, (Runnable) null);
    }

    public static void fadeOut(final View view, final Runnable endRunnable) {
        fadeOut(view, ANIMATION_DURATION_LENGTH, 0, endRunnable);
    }

    public static void fadeOut(final View view, final Animator.AnimatorListener listener) {
        fadeOut(view, ANIMATION_DURATION_LENGTH, 0, listener);
    }

    public static void fadeOut(final View view, long duration, int delay) {
        fadeOut(view, duration, delay, (Runnable) null);
    }

    public static void fadeOut(final View view, long duration, int delay,
            final Runnable endRunnable) {
            @Nullable final Runnable endRunnable) {
        view.animate().cancel();
        view.animate()
                .alpha(0f)
                .setDuration(duration)
                .setInterpolator(Interpolators.ALPHA_OUT)
                .setStartDelay(delay)
                .withEndAction(new Runnable() {
                    @Override
                    public void run() {
                .withEndAction(() -> {
                    if (endRunnable != null) {
                        endRunnable.run();
                    }
                    if (view.getVisibility() != View.GONE) {
                        view.setVisibility(View.INVISIBLE);
                    }
                });
        if (view.hasOverlappingRendering()) {
            view.animate().withLayer();
        }
    }

    public static void fadeOut(final View view, long duration, int delay,
            @Nullable final Animator.AnimatorListener listener) {
        view.animate().cancel();
        ViewPropertyAnimator animator = view.animate()
                .alpha(0f)
                .setDuration(duration)
                .setInterpolator(Interpolators.ALPHA_OUT)
                .setStartDelay(delay)
                .withEndAction(() -> {
                    if (view.getVisibility() != View.GONE) {
                        view.setVisibility(View.INVISIBLE);
                    }
                });
        if (listener != null) {
            animator.setListener(listener);
        }
        if (view.hasOverlappingRendering()) {
            view.animate().withLayer();
        }
@@ -119,8 +147,12 @@ public class CrossFadeHelper {
        fadeIn(view, ANIMATION_DURATION_LENGTH, /* delay= */ 0, endRunnable);
    }

    public static void fadeIn(final View view, Animator.AnimatorListener listener) {
        fadeIn(view, ANIMATION_DURATION_LENGTH, /* delay= */ 0, listener);
    }

    public static void fadeIn(final View view, long duration, int delay) {
        fadeIn(view, duration, delay, /* endRunnable= */ null);
        fadeIn(view, duration, delay, /* endRunnable= */ (Runnable) null);
    }

    public static void fadeIn(final View view, long duration, int delay,
@@ -141,6 +173,26 @@ public class CrossFadeHelper {
        }
    }

    public static void fadeIn(final View view, long duration, int delay,
            @Nullable Animator.AnimatorListener listener) {
        view.animate().cancel();
        if (view.getVisibility() == View.INVISIBLE) {
            view.setAlpha(0.0f);
            view.setVisibility(View.VISIBLE);
        }
        ViewPropertyAnimator animator = view.animate()
                .alpha(1f)
                .setDuration(duration)
                .setStartDelay(delay)
                .setInterpolator(Interpolators.ALPHA_IN);
        if (listener != null) {
            animator.setListener(listener);
        }
        if (view.hasOverlappingRendering() && view.getLayerType() != View.LAYER_TYPE_HARDWARE) {
            view.animate().withLayer();
        }
    }

    public static void fadeIn(View view, float fadeInAmount) {
        fadeIn(view, fadeInAmount, false /* remap */);
    }
+45 −27
Original line number Diff line number Diff line
@@ -15,8 +15,11 @@
 */
package com.android.systemui.statusbar.notification.icon.ui.viewbinder

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.graphics.Rect
import android.view.View
import android.view.ViewPropertyAnimator
import android.widget.FrameLayout
import androidx.collection.ArrayMap
import androidx.lifecycle.Lifecycle
@@ -46,6 +49,9 @@ import com.android.systemui.util.children
import com.android.systemui.util.kotlin.mapValuesNotNullTo
import com.android.systemui.util.kotlin.sample
import com.android.systemui.util.kotlin.stateFlow
import com.android.systemui.util.ui.isAnimating
import com.android.systemui.util.ui.stopAnimating
import com.android.systemui.util.ui.value
import javax.inject.Inject
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.coroutineScope
@@ -73,16 +79,24 @@ object NotificationIconContainerViewBinder {
            repeatOnLifecycle(Lifecycle.State.CREATED) {
                launch { viewModel.animationsEnabled.collect(view::setAnimationsEnabled) }
                launch {
                    viewModel.isDozing.collect { (isDozing, animate) ->
                        val animateIfNotBlanking = animate && !dozeParameters.displayNeedsBlanking
                    viewModel.isDozing.collect { isDozing ->
                        if (isDozing.isAnimating) {
                            val animate = !dozeParameters.displayNeedsBlanking
                            view.setDozing(
                            /* dozing = */ isDozing,
                            /* fade = */ animateIfNotBlanking,
                                /* dozing = */ isDozing.value,
                                /* fade = */ animate,
                                /* delay = */ 0,
                                /* endRunnable = */ isDozing::stopAnimating,
                            )
                        } else {
                            view.setDozing(
                                /* dozing = */ isDozing.value,
                                /* fade= */ false,
                                /* delay= */ 0,
                            /* endRunnable = */ viewModel::completeDozeAnimation,
                            )
                        }
                    }
                }
                // TODO(b/278765923): this should live where AOD is bound, not inside of the NIC
                //  view-binder
                launch {
@@ -92,7 +106,6 @@ object NotificationIconContainerViewBinder {
                        configuration,
                        featureFlags,
                        screenOffAnimationController,
                        onAnimationEnd = viewModel::completeVisibilityAnimation,
                    )
                }
                launch {
@@ -225,33 +238,38 @@ object NotificationIconContainerViewBinder {
        configuration: ConfigurationState,
        featureFlags: FeatureFlagsClassic,
        screenOffAnimationController: ScreenOffAnimationController,
        onAnimationEnd: () -> Unit,
    ): Unit = coroutineScope {
        val iconAppearTranslation =
            configuration.getDimensionPixelSize(R.dimen.shelf_appear_translation).stateIn(this)
        val statusViewMigrated = featureFlags.isEnabled(Flags.MIGRATE_KEYGUARD_STATUS_VIEW)
        viewModel.isVisible.collect { (isVisible, animate) ->
        viewModel.isVisible.collect { isVisible ->
            view.animate().cancel()
            val animatorListener =
                object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(animation: Animator) {
                        isVisible.stopAnimating()
                    }
                }
            when {
                !animate -> {
                !isVisible.isAnimating -> {
                    view.alpha = 1f
                    if (!statusViewMigrated) {
                        view.translationY = 0f
                    }
                    view.visibility = if (isVisible) View.VISIBLE else View.INVISIBLE
                    view.visibility = if (isVisible.value) View.VISIBLE else View.INVISIBLE
                }
                featureFlags.isEnabled(Flags.NEW_AOD_TRANSITION) -> {
                    animateInIconTranslation(view, statusViewMigrated)
                    if (isVisible) {
                        CrossFadeHelper.fadeIn(view, onAnimationEnd)
                    if (isVisible.value) {
                        CrossFadeHelper.fadeIn(view, animatorListener)
                    } else {
                        CrossFadeHelper.fadeOut(view, onAnimationEnd)
                        CrossFadeHelper.fadeOut(view, animatorListener)
                    }
                }
                !isVisible -> {
                !isVisible.value -> {
                    // Let's make sure the icon are translated to 0, since we cancelled it above
                    animateInIconTranslation(view, statusViewMigrated)
                    CrossFadeHelper.fadeOut(view, onAnimationEnd)
                    CrossFadeHelper.fadeOut(view, animatorListener)
                }
                view.visibility != View.VISIBLE -> {
                    // No fading here, let's just appear the icons instead!
@@ -262,14 +280,14 @@ object NotificationIconContainerViewBinder {
                        animate = screenOffAnimationController.shouldAnimateAodIcons(),
                        iconAppearTranslation.value,
                        statusViewMigrated,
                        animatorListener,
                    )
                    onAnimationEnd()
                }
                else -> {
                    // Let's make sure the icons are translated to 0, since we cancelled it above
                    animateInIconTranslation(view, statusViewMigrated)
                    // We were fading out, let's fade in instead
                    CrossFadeHelper.fadeIn(view, onAnimationEnd)
                    CrossFadeHelper.fadeIn(view, animatorListener)
                }
            }
        }
@@ -280,18 +298,20 @@ object NotificationIconContainerViewBinder {
        animate: Boolean,
        iconAppearTranslation: Int,
        statusViewMigrated: Boolean,
        animatorListener: Animator.AnimatorListener,
    ) {
        if (animate) {
            if (!statusViewMigrated) {
                view.translationY = -iconAppearTranslation.toFloat()
            }
            view.alpha = 0f
            animateInIconTranslation(view, statusViewMigrated)
            view
                .animate()
                .alpha(1f)
                .setInterpolator(Interpolators.LINEAR)
                .setDuration(AOD_ICONS_APPEAR_DURATION)
                .apply { if (statusViewMigrated) animateInIconTranslation() }
                .setListener(animatorListener)
                .start()
        } else {
            view.alpha = 1.0f
@@ -303,15 +323,13 @@ object NotificationIconContainerViewBinder {

    private fun animateInIconTranslation(view: View, statusViewMigrated: Boolean) {
        if (!statusViewMigrated) {
            view
                .animate()
                .setInterpolator(Interpolators.DECELERATE_QUINT)
                .translationY(0f)
                .setDuration(AOD_ICONS_APPEAR_DURATION)
                .start()
            view.animate().animateInIconTranslation().setDuration(AOD_ICONS_APPEAR_DURATION).start()
        }
    }

    private fun ViewPropertyAnimator.animateInIconTranslation(): ViewPropertyAnimator =
        setInterpolator(Interpolators.DECELERATE_QUINT).translationY(0f)

    private const val AOD_ICONS_APPEAR_DURATION: Long = 200

    private val View.viewBounds: Rect
+24 −28
Original line number Diff line number Diff line
@@ -41,9 +41,9 @@ import com.android.systemui.util.kotlin.sample
import com.android.systemui.util.ui.AnimatableEvent
import com.android.systemui.util.ui.AnimatedValue
import com.android.systemui.util.ui.toAnimatedValueFlow
import com.android.systemui.util.ui.zip
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@@ -65,9 +65,6 @@ constructor(
    shadeInteractor: ShadeInteractor,
) : NotificationIconContainerViewModel {

    private val onDozeAnimationComplete = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
    private val onVisAnimationComplete = MutableSharedFlow<Unit>(extraBufferCapacity = 1)

    override val iconColors: Flow<ColorLookup> =
        configuration.getColorAttr(R.attr.wallpaperTextColor, DEFAULT_AOD_ICON_COLOR).map { tint ->
            ColorLookup { IconColorsImpl(tint) }
@@ -96,7 +93,7 @@ constructor(
                AnimatableEvent(isDozing, animate)
            }
            .distinctUntilChanged()
            .toAnimatedValueFlow(completionEvents = onDozeAnimationComplete)
            .toAnimatedValueFlow()

    override val isVisible: Flow<AnimatedValue<Boolean>> =
        combine(
@@ -106,36 +103,35 @@ constructor(
                isPulseExpandingAnimated(),
            ) {
                onKeyguard: Boolean,
                bypassEnabled: Boolean,
                (notifsFullyHidden: Boolean, isAnimatingHide: Boolean),
                (pulseExpanding: Boolean, isAnimatingPulse: Boolean),
                isBypassEnabled: Boolean,
                notifsFullyHidden: AnimatedValue<Boolean>,
                pulseExpanding: AnimatedValue<Boolean>,
                ->
                val isAnimating = isAnimatingHide || isAnimatingPulse
                when {
                    // Hide the AOD icons if we're not in the KEYGUARD state unless the screen off
                    // animation is playing, in which case we want them to be visible if we're
                    // animating in the AOD UI and will be switching to KEYGUARD shortly.
                    !onKeyguard && !screenOffAnimationController.shouldShowAodIconsWhenShade() ->
                        AnimatedValue(false, isAnimating = false)
                        AnimatedValue.NotAnimating(false)
                    else ->
                        zip(notifsFullyHidden, pulseExpanding) {
                            areNotifsFullyHidden,
                            isPulseExpanding,
                            ->
                            when {
                                // If we're bypassing, then we're visible
                    bypassEnabled -> AnimatedValue(true, isAnimating)
                                isBypassEnabled -> true
                                // If we are pulsing (and not bypassing), then we are hidden
                    pulseExpanding -> AnimatedValue(false, isAnimating)
                                isPulseExpanding -> false
                                // If notifs are fully gone, then we're visible
                    notifsFullyHidden -> AnimatedValue(true, isAnimating)
                                areNotifsFullyHidden -> true
                                // Otherwise, we're hidden
                    else -> AnimatedValue(false, isAnimating)
                                else -> false
                            }
                        }
            .distinctUntilChanged()

    override fun completeDozeAnimation() {
        onDozeAnimationComplete.tryEmit(Unit)
                }

    override fun completeVisibilityAnimation() {
        onVisAnimationComplete.tryEmit(Unit)
            }
            .distinctUntilChanged()

    override val iconsViewData: Flow<IconsViewData> =
        iconsInteractor.aodNotifs.map { entries ->
@@ -150,7 +146,7 @@ constructor(
            .pairwise(initialValue = null)
            // If pulsing changes, start animating, unless it's the first emission
            .map { (prev, expanding) -> AnimatableEvent(expanding, startAnimating = prev != null) }
            .toAnimatedValueFlow(completionEvents = onVisAnimationComplete)
            .toAnimatedValueFlow()
    }

    /** Are notifications completely hidden from view, are we animating in response? */
@@ -176,7 +172,7 @@ constructor(
                    }
                AnimatableEvent(fullyHidden, animate)
            }
            .toAnimatedValueFlow(completionEvents = onVisAnimationComplete)
            .toAnimatedValueFlow()
    }

    private class IconColorsImpl(override val tint: Int) : IconColors {
+1 −2
Original line number Diff line number Diff line
@@ -31,11 +31,10 @@ class NotificationIconContainerShelfViewModel
constructor(
    interactor: NotificationIconsInteractor,
) : NotificationIconContainerViewModel {

    override val animationsEnabled: Flow<Boolean> = flowOf(true)
    override val isDozing: Flow<AnimatedValue<Boolean>> = emptyFlow()
    override val isVisible: Flow<AnimatedValue<Boolean>> = emptyFlow()
    override fun completeDozeAnimation() {}
    override fun completeVisibilityAnimation() {}
    override val iconColors: Flow<ColorLookup> = emptyFlow()

    override val iconsViewData: Flow<IconsViewData> =
Loading