diff options
| author | 2023-10-10 15:12:19 -0400 | |
|---|---|---|
| committer | 2023-10-23 13:19:43 -0400 | |
| commit | 7406ab15eb14ac05885a40899b9a9e930c33e87a (patch) | |
| tree | 7ed8dbca00d73e9154a845f56f351edb32f39095 | |
| parent | da362ca165d9159475f2c52e2d297a781ddd9733 (diff) | |
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
10 files changed, 439 insertions, 133 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/complication/ComplicationLayoutEngine.java b/packages/SystemUI/src/com/android/systemui/complication/ComplicationLayoutEngine.java index 20b2494a6c29..f7b6b0f06a00 100644 --- a/packages/SystemUI/src/com/android/systemui/complication/ComplicationLayoutEngine.java +++ b/packages/SystemUI/src/com/android/systemui/complication/ComplicationLayoutEngine.java @@ -652,8 +652,7 @@ public class ComplicationLayoutEngine implements Complication.VisibilityControll CrossFadeHelper.fadeOut( mLayout, mFadeOutDuration, - /* delay= */ 0, - /* endRunnable= */ null); + /* delay= */ 0); } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java b/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java index 77b095802b00..7d81e55d336a 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java @@ -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() { - if (endRunnable != null) { - endRunnable.run(); - } - if (view.getVisibility() != View.GONE) { - view.setVisibility(View.INVISIBLE); - } + .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 */); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewbinder/NotificationIconContainerViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewbinder/NotificationIconContainerViewBinder.kt index c6d7e2193a23..a78fd9dfca43 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewbinder/NotificationIconContainerViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewbinder/NotificationIconContainerViewBinder.kt @@ -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,14 +79,22 @@ object NotificationIconContainerViewBinder { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.animationsEnabled.collect(view::setAnimationsEnabled) } launch { - viewModel.isDozing.collect { (isDozing, animate) -> - val animateIfNotBlanking = animate && !dozeParameters.displayNeedsBlanking - view.setDozing( - /* dozing = */ isDozing, - /* fade = */ animateIfNotBlanking, - /* delay = */ 0, - /* endRunnable = */ viewModel::completeDozeAnimation, - ) + viewModel.isDozing.collect { isDozing -> + if (isDozing.isAnimating) { + val animate = !dozeParameters.displayNeedsBlanking + view.setDozing( + /* dozing = */ isDozing.value, + /* fade = */ animate, + /* delay = */ 0, + /* endRunnable = */ isDozing::stopAnimating, + ) + } else { + view.setDozing( + /* dozing = */ isDozing.value, + /* fade= */ false, + /* delay= */ 0, + ) + } } } // TODO(b/278765923): this should live where AOD is bound, not inside of the NIC @@ -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 diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModel.kt index 885f449ecd19..e6788fb858bf 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModel.kt @@ -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,37 +103,36 @@ 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) - // If we're bypassing, then we're visible - bypassEnabled -> AnimatedValue(true, isAnimating) - // If we are pulsing (and not bypassing), then we are hidden - pulseExpanding -> AnimatedValue(false, isAnimating) - // If notifs are fully gone, then we're visible - notifsFullyHidden -> AnimatedValue(true, isAnimating) - // Otherwise, we're hidden - else -> AnimatedValue(false, isAnimating) + AnimatedValue.NotAnimating(false) + else -> + zip(notifsFullyHidden, pulseExpanding) { + areNotifsFullyHidden, + isPulseExpanding, + -> + when { + // If we're bypassing, then we're visible + isBypassEnabled -> true + // If we are pulsing (and not bypassing), then we are hidden + isPulseExpanding -> false + // If notifs are fully gone, then we're visible + areNotifsFullyHidden -> true + // Otherwise, we're hidden + else -> false + } + } } } .distinctUntilChanged() - override fun completeDozeAnimation() { - onDozeAnimationComplete.tryEmit(Unit) - } - - override fun completeVisibilityAnimation() { - onVisAnimationComplete.tryEmit(Unit) - } - override val iconsViewData: Flow<IconsViewData> = iconsInteractor.aodNotifs.map { entries -> IconsViewData( @@ -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 { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerShelfViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerShelfViewModel.kt index 38eae24d98b9..b8e0b5828831 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerShelfViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerShelfViewModel.kt @@ -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> = diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerStatusBarViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerStatusBarViewModel.kt index cdbabb601da9..046d364a407b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerStatusBarViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerStatusBarViewModel.kt @@ -68,8 +68,6 @@ constructor( override val isDozing: Flow<AnimatedValue<Boolean>> = emptyFlow() override val isVisible: Flow<AnimatedValue<Boolean>> = emptyFlow() - override fun completeDozeAnimation() {} - override fun completeVisibilityAnimation() {} override val iconsViewData: Flow<IconsViewData> = iconsInteractor.statusBarNotifs.map { entries -> diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerViewModel.kt index 0e8dfea76319..236a2371d9dd 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerViewModel.kt @@ -46,18 +46,6 @@ interface NotificationIconContainerViewModel { val iconsViewData: Flow<IconsViewData> /** - * Signal completion of the [isDozing] animation; if [isDozing]'s [AnimatedValue.isAnimating] - * property was `true`, calling this method will update it to `false`. - */ - fun completeDozeAnimation() - - /** - * Signal completion of the [isVisible] animation; if [isVisible]'s [AnimatedValue.isAnimating] - * property was `true`, calling this method will update it to `false`. - */ - fun completeVisibilityAnimation() - - /** * Lookup the colors to use for the notification icons based on the bounds of the icon * container. A result of `null` indicates that no color changes should be applied. */ diff --git a/packages/SystemUI/src/com/android/systemui/util/ui/AnimatedValue.kt b/packages/SystemUI/src/com/android/systemui/util/ui/AnimatedValue.kt index 51d2afabd7f9..1112d6f4f25c 100644 --- a/packages/SystemUI/src/com/android/systemui/util/ui/AnimatedValue.kt +++ b/packages/SystemUI/src/com/android/systemui/util/ui/AnimatedValue.kt @@ -17,26 +17,58 @@ package com.android.systemui.util.ui +import com.android.systemui.util.ui.AnimatedValue.Animating +import com.android.systemui.util.ui.AnimatedValue.NotAnimating +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transformLatest /** * A state comprised of a [value] of type [T] paired with a boolean indicating whether or not the * [value] [isAnimating] in the UI. */ -data class AnimatedValue<T>( - val value: T, - val isAnimating: Boolean, -) +sealed interface AnimatedValue<out T> { + + /** A [state][value] that is not actively animating in the UI. */ + data class NotAnimating<out T>(val value: T) : AnimatedValue<T> + + /** + * A [state][value] that is actively animating in the UI. Invoking [onStopAnimating] will signal + * the source of the state to stop animating. + */ + data class Animating<out T>( + val value: T, + val onStopAnimating: () -> Unit, + ) : AnimatedValue<T> +} + +/** The state held in this [AnimatedValue]. */ +inline val <T> AnimatedValue<T>.value: T + get() = + when (this) { + is Animating -> value + is NotAnimating -> value + } + +/** Returns whether or not this [AnimatedValue] is animating or not. */ +inline val <T> AnimatedValue<T>.isAnimating: Boolean + get() = this is Animating<T> + +/** + * If this [AnimatedValue] [isAnimating], then signal that the animation should be stopped. + * Otherwise, do nothing. + */ +@Suppress("NOTHING_TO_INLINE") +inline fun AnimatedValue<*>.stopAnimating() { + if (this is Animating) onStopAnimating() +} /** * An event comprised of a [value] of type [T] paired with a [boolean][startAnimating] indicating * whether or not this event should start an animation. */ -data class AnimatableEvent<T>( +data class AnimatableEvent<out T>( val value: T, val startAnimating: Boolean, ) @@ -47,16 +79,87 @@ data class AnimatableEvent<T>( * [AnimatableEvent.startAnimating] value is `true`. When [completionEvents] emits a value, the * [AnimatedValue.isAnimating] will flip to `false`. */ -fun <T> Flow<AnimatableEvent<T>>.toAnimatedValueFlow( - completionEvents: Flow<Any?>, -): Flow<AnimatedValue<T>> = transformLatest { (value, startAnimating) -> - emit(AnimatedValue(value, isAnimating = startAnimating)) - if (startAnimating) { - // Wait for a completion now that we've started animating - completionEvents - .map { Unit } // replace the event so that it's never `null` - .firstOrNull() // `null` indicates an empty flow - // emit the new state if the flow was not empty. - ?.run { emit(AnimatedValue(value, isAnimating = false)) } +fun <T> Flow<AnimatableEvent<T>>.toAnimatedValueFlow(): Flow<AnimatedValue<T>> = + transformLatest { (value, startAnimating) -> + if (startAnimating) { + val onCompleted = CompletableDeferred<Unit>() + emit(Animating(value) { onCompleted.complete(Unit) }) + // Wait for a completion now that we've started animating + onCompleted.await() + } + emit(NotAnimating(value)) + } + +/** + * Zip two [AnimatedValue]s together into a single [AnimatedValue], using [block] to combine the + * [value]s of each. + * + * If either [AnimatedValue] [isAnimating], then the result is also animating. Invoking + * [stopAnimating] on the result is equivalent to invoking [stopAnimating] on each input. + */ +inline fun <A, B, Z> zip( + valueA: AnimatedValue<A>, + valueB: AnimatedValue<B>, + block: (A, B) -> Z, +): AnimatedValue<Z> { + val zippedValue = block(valueA.value, valueB.value) + return when (valueA) { + is Animating -> + when (valueB) { + is Animating -> + Animating(zippedValue) { + valueA.onStopAnimating() + valueB.onStopAnimating() + } + is NotAnimating -> Animating(zippedValue, valueA.onStopAnimating) + } + is NotAnimating -> + when (valueB) { + is Animating -> Animating(zippedValue, valueB.onStopAnimating) + is NotAnimating -> NotAnimating(zippedValue) + } } } + +/** + * Flattens a nested [AnimatedValue], the result of which holds the [value] of the inner + * [AnimatedValue]. + * + * If either the outer or inner [AnimatedValue] [isAnimating], then the flattened result is also + * animating. Invoking [stopAnimating] on the result is equivalent to invoking [stopAnimating] on + * both the outer and inner values. + */ +@Suppress("NOTHING_TO_INLINE") +inline fun <T> AnimatedValue<AnimatedValue<T>>.flatten(): AnimatedValue<T> = flatMap { it } + +/** + * Returns an [AnimatedValue], the [value] of which is the result of the given [value] applied to + * [block]. + */ +inline fun <A, B> AnimatedValue<A>.map(block: (A) -> B): AnimatedValue<B> = + when (this) { + is Animating -> Animating(block(value), ::stopAnimating) + is NotAnimating -> NotAnimating(block(value)) + } + +/** + * Returns an [AnimatedValue] from the result of [block] being invoked on the [value] original + * [AnimatedValue]. + * + * If either the input [AnimatedValue] or the result of [block] [isAnimating], then the flattened + * result is also animating. Invoking [stopAnimating] on the result is equivalent to invoking + * [stopAnimating] on both values. + */ +inline fun <A, B> AnimatedValue<A>.flatMap(block: (A) -> AnimatedValue<B>): AnimatedValue<B> = + when (this) { + is NotAnimating -> block(value) + is Animating -> + when (val inner = block(value)) { + is Animating -> + Animating(inner.value) { + onStopAnimating() + inner.onStopAnimating() + } + is NotAnimating -> Animating(inner.value, onStopAnimating) + } + } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModelTest.kt index 31efebbc5b60..41c7071a616d 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/icon/ui/viewmodel/NotificationIconContainerAlwaysOnDisplayViewModelTest.kt @@ -44,7 +44,9 @@ import com.android.systemui.statusbar.phone.ScreenOffAnimationController import com.android.systemui.statusbar.policy.data.repository.FakeDeviceProvisioningRepository import com.android.systemui.user.domain.UserDomainLayerModule import com.android.systemui.util.mockito.whenever -import com.android.systemui.util.ui.AnimatedValue +import com.android.systemui.util.ui.isAnimating +import com.android.systemui.util.ui.stopAnimating +import com.android.systemui.util.ui.value import com.google.common.truth.Truth.assertThat import dagger.BindsInstance import dagger.Component @@ -243,6 +245,7 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { ) ) val animationsEnabled by collectLastValue(underTest.animationsEnabled) + runCurrent() keyguardRepository.setKeyguardShowing(true) keyguardRepository.setKeyguardOccluded(false) @@ -266,6 +269,7 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { fun isDozing_startAodTransition() = scope.runTest { val isDozing by collectLastValue(underTest.isDozing) + runCurrent() keyguardTransitionRepository.sendTransitionStep( TransitionStep( from = KeyguardState.GONE, @@ -274,13 +278,15 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { ) ) runCurrent() - assertThat(isDozing).isEqualTo(AnimatedValue(true, isAnimating = true)) + assertThat(isDozing?.value).isTrue() + assertThat(isDozing?.isAnimating).isTrue() } @Test fun isDozing_startDozeTransition() = scope.runTest { val isDozing by collectLastValue(underTest.isDozing) + runCurrent() keyguardTransitionRepository.sendTransitionStep( TransitionStep( from = KeyguardState.GONE, @@ -289,13 +295,15 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { ) ) runCurrent() - assertThat(isDozing).isEqualTo(AnimatedValue(true, isAnimating = false)) + assertThat(isDozing?.value).isTrue() + assertThat(isDozing?.isAnimating).isFalse() } @Test fun isDozing_startDozeToAodTransition() = scope.runTest { val isDozing by collectLastValue(underTest.isDozing) + runCurrent() keyguardTransitionRepository.sendTransitionStep( TransitionStep( from = KeyguardState.DOZING, @@ -304,13 +312,15 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { ) ) runCurrent() - assertThat(isDozing).isEqualTo(AnimatedValue(true, isAnimating = true)) + assertThat(isDozing?.value).isTrue() + assertThat(isDozing?.isAnimating).isTrue() } @Test fun isNotDozing_startAodToGoneTransition() = scope.runTest { val isDozing by collectLastValue(underTest.isDozing) + runCurrent() keyguardTransitionRepository.sendTransitionStep( TransitionStep( from = KeyguardState.AOD, @@ -319,13 +329,15 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { ) ) runCurrent() - assertThat(isDozing).isEqualTo(AnimatedValue(false, isAnimating = true)) + assertThat(isDozing?.value).isFalse() + assertThat(isDozing?.isAnimating).isTrue() } @Test fun isDozing_stopAnimation() = scope.runTest { val isDozing by collectLastValue(underTest.isDozing) + runCurrent() keyguardTransitionRepository.sendTransitionStep( TransitionStep( from = KeyguardState.AOD, @@ -335,7 +347,8 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { ) runCurrent() - underTest.completeDozeAnimation() + assertThat(isDozing?.isAnimating).isEqualTo(true) + isDozing?.stopAnimating() runCurrent() assertThat(isDozing?.isAnimating).isEqualTo(false) @@ -345,6 +358,7 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { fun isNotVisible_pulseExpanding() = scope.runTest { val isVisible by collectLastValue(underTest.isVisible) + runCurrent() notifsKeyguardRepository.setPulseExpanding(true) runCurrent() @@ -355,6 +369,7 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { fun isNotVisible_notOnKeyguard_dontShowAodIconsWhenShade() = scope.runTest { val isVisible by collectLastValue(underTest.isVisible) + runCurrent() keyguardTransitionRepository.sendTransitionStep( TransitionStep( to = KeyguardState.GONE, @@ -364,13 +379,15 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { whenever(screenOffAnimController.shouldShowAodIconsWhenShade()).thenReturn(false) runCurrent() - assertThat(isVisible).isEqualTo(AnimatedValue(false, isAnimating = false)) + assertThat(isVisible?.value).isFalse() + assertThat(isVisible?.isAnimating).isFalse() } @Test fun isVisible_bypassEnabled() = scope.runTest { val isVisible by collectLastValue(underTest.isVisible) + runCurrent() deviceEntryRepository.setBypassEnabled(true) runCurrent() @@ -381,6 +398,7 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { fun isNotVisible_pulseExpanding_notBypassing() = scope.runTest { val isVisible by collectLastValue(underTest.isVisible) + runCurrent() notifsKeyguardRepository.setPulseExpanding(true) deviceEntryRepository.setBypassEnabled(false) runCurrent() @@ -398,26 +416,30 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { notifsKeyguardRepository.setNotificationsFullyHidden(true) runCurrent() - assertThat(isVisible).isEqualTo(AnimatedValue(true, isAnimating = true)) + assertThat(isVisible?.value).isTrue() + assertThat(isVisible?.isAnimating).isTrue() } @Test fun isVisible_notifsFullyHidden_bypassDisabled_aodDisabled() = scope.runTest { val isVisible by collectLastValue(underTest.isVisible) + runCurrent() notifsKeyguardRepository.setPulseExpanding(false) deviceEntryRepository.setBypassEnabled(false) whenever(dozeParams.alwaysOn).thenReturn(false) notifsKeyguardRepository.setNotificationsFullyHidden(true) runCurrent() - assertThat(isVisible).isEqualTo(AnimatedValue(true, isAnimating = false)) + assertThat(isVisible?.value).isTrue() + assertThat(isVisible?.isAnimating).isFalse() } @Test fun isVisible_notifsFullyHidden_bypassDisabled_displayNeedsBlanking() = scope.runTest { val isVisible by collectLastValue(underTest.isVisible) + runCurrent() notifsKeyguardRepository.setPulseExpanding(false) deviceEntryRepository.setBypassEnabled(false) whenever(dozeParams.alwaysOn).thenReturn(true) @@ -425,7 +447,8 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { notifsKeyguardRepository.setNotificationsFullyHidden(true) runCurrent() - assertThat(isVisible).isEqualTo(AnimatedValue(true, isAnimating = false)) + assertThat(isVisible?.value).isTrue() + assertThat(isVisible?.isAnimating).isFalse() } @Test @@ -440,13 +463,15 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { notifsKeyguardRepository.setNotificationsFullyHidden(true) runCurrent() - assertThat(isVisible).isEqualTo(AnimatedValue(true, isAnimating = true)) + assertThat(isVisible?.value).isTrue() + assertThat(isVisible?.isAnimating).isTrue() } @Test fun isVisible_stopAnimation() = scope.runTest { val isVisible by collectLastValue(underTest.isVisible) + runCurrent() notifsKeyguardRepository.setPulseExpanding(false) deviceEntryRepository.setBypassEnabled(false) whenever(dozeParams.alwaysOn).thenReturn(true) @@ -454,7 +479,8 @@ class NotificationIconContainerAlwaysOnDisplayViewModelTest : SysuiTestCase() { notifsKeyguardRepository.setNotificationsFullyHidden(true) runCurrent() - underTest.completeVisibilityAnimation() + assertThat(isVisible?.isAnimating).isEqualTo(true) + isVisible?.stopAnimating() runCurrent() assertThat(isVisible?.isAnimating).isEqualTo(false) diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/ui/AnimatedValueTest.kt b/packages/SystemUI/tests/src/com/android/systemui/util/ui/AnimatedValueTest.kt index 6e3a732aa8ec..94100fe7f4c4 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/util/ui/AnimatedValueTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/util/ui/AnimatedValueTest.kt @@ -24,8 +24,6 @@ import com.android.systemui.coroutines.collectLastValue import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test @@ -38,64 +36,193 @@ class AnimatedValueTest : SysuiTestCase() { @Test fun animatableEvent_updatesValue() = runTest { val events = MutableSharedFlow<AnimatableEvent<Int>>() - val values = events.toAnimatedValueFlow(completionEvents = emptyFlow()) + val values = events.toAnimatedValueFlow() val value by collectLastValue(values) runCurrent() events.emit(AnimatableEvent(value = 1, startAnimating = false)) - assertThat(value).isEqualTo(AnimatedValue(value = 1, isAnimating = false)) + assertThat(value?.value).isEqualTo(1) + assertThat(value?.isAnimating).isFalse() } @Test fun animatableEvent_startAnimation() = runTest { val events = MutableSharedFlow<AnimatableEvent<Int>>() - val values = events.toAnimatedValueFlow(completionEvents = emptyFlow()) + val values = events.toAnimatedValueFlow() val value by collectLastValue(values) runCurrent() events.emit(AnimatableEvent(value = 1, startAnimating = true)) - assertThat(value).isEqualTo(AnimatedValue(value = 1, isAnimating = true)) + assertThat(value?.value).isEqualTo(1) + assertThat(value?.isAnimating).isTrue() } @Test fun animatableEvent_startAnimation_alreadyAnimating() = runTest { val events = MutableSharedFlow<AnimatableEvent<Int>>() - val values = events.toAnimatedValueFlow(completionEvents = emptyFlow()) + val values = events.toAnimatedValueFlow() val value by collectLastValue(values) runCurrent() events.emit(AnimatableEvent(value = 1, startAnimating = true)) events.emit(AnimatableEvent(value = 2, startAnimating = true)) - assertThat(value).isEqualTo(AnimatedValue(value = 2, isAnimating = true)) + assertThat(value?.value).isEqualTo(2) + assertThat(value?.isAnimating).isTrue() } @Test fun animatedValue_stopAnimating() = runTest { val events = MutableSharedFlow<AnimatableEvent<Int>>() - val stopEvent = MutableSharedFlow<Unit>() - val values = events.toAnimatedValueFlow(completionEvents = stopEvent) + val values = events.toAnimatedValueFlow() val value by collectLastValue(values) runCurrent() events.emit(AnimatableEvent(value = 1, startAnimating = true)) - stopEvent.emit(Unit) + assertThat(value?.isAnimating).isTrue() + value?.stopAnimating() - assertThat(value).isEqualTo(AnimatedValue(value = 1, isAnimating = false)) + assertThat(value?.value).isEqualTo(1) + assertThat(value?.isAnimating).isFalse() } @Test - fun animatedValue_stopAnimating_notAnimating() = runTest { + fun animatedValue_stopAnimatingPrevValue_doesNothing() = runTest { val events = MutableSharedFlow<AnimatableEvent<Int>>() - val stopEvent = MutableSharedFlow<Unit>() - val values = events.toAnimatedValueFlow(completionEvents = stopEvent) - values.launchIn(backgroundScope) + val values = events.toAnimatedValueFlow() + val value by collectLastValue(values) runCurrent() - events.emit(AnimatableEvent(value = 1, startAnimating = false)) + events.emit(AnimatableEvent(value = 1, startAnimating = true)) + val prevValue = value + assertThat(prevValue?.isAnimating).isTrue() + + events.emit(AnimatableEvent(value = 2, startAnimating = true)) + assertThat(value?.isAnimating).isTrue() + prevValue?.stopAnimating() + + assertThat(value?.value).isEqualTo(2) + assertThat(value?.isAnimating).isTrue() + } + + @Test + fun zipValues_applyTransform() { + val animating = AnimatedValue.Animating(1) {} + val notAnimating = AnimatedValue.NotAnimating(2) + val sum = zip(animating, notAnimating) { a, b -> a + b } + assertThat(sum.value).isEqualTo(3) + } + + @Test + fun zipValues_firstIsAnimating_resultIsAnimating() { + var stopped = false + val animating = AnimatedValue.Animating(1) { stopped = true } + val notAnimating = AnimatedValue.NotAnimating(2) + val sum = zip(animating, notAnimating) { a, b -> a + b } + assertThat(sum.isAnimating).isTrue() + + sum.stopAnimating() + assertThat(stopped).isTrue() + } + + @Test + fun zipValues_secondIsAnimating_resultIsAnimating() { + var stopped = false + val animating = AnimatedValue.Animating(1) { stopped = true } + val notAnimating = AnimatedValue.NotAnimating(2) + val sum = zip(notAnimating, animating) { a, b -> a + b } + assertThat(sum.isAnimating).isTrue() + + sum.stopAnimating() + assertThat(stopped).isTrue() + } + + @Test + fun zipValues_bothAnimating_resultIsAnimating() { + var firstStopped = false + var secondStopped = false + val first = AnimatedValue.Animating(1) { firstStopped = true } + val second = AnimatedValue.Animating(2) { secondStopped = true } + val sum = zip(first, second) { a, b -> a + b } + assertThat(sum.isAnimating).isTrue() + + sum.stopAnimating() + assertThat(firstStopped).isTrue() + assertThat(secondStopped).isTrue() + } - assertThat(stopEvent.subscriptionCount.value).isEqualTo(0) + @Test + fun zipValues_neitherAnimating_resultIsNotAnimating() { + val first = AnimatedValue.NotAnimating(1) + val second = AnimatedValue.NotAnimating(2) + val sum = zip(first, second) { a, b -> a + b } + assertThat(sum.isAnimating).isFalse() + } + + @Test + fun mapAnimatedValue_isAnimating() { + var stopped = false + val animating = AnimatedValue.Animating(3) { stopped = true } + val squared = animating.map { it * it } + assertThat(squared.value).isEqualTo(9) + assertThat(squared.isAnimating).isTrue() + squared.stopAnimating() + assertThat(stopped).isTrue() + } + + @Test + fun mapAnimatedValue_notAnimating() { + val notAnimating = AnimatedValue.NotAnimating(3) + val squared = notAnimating.map { it * it } + assertThat(squared.value).isEqualTo(9) + assertThat(squared.isAnimating).isFalse() + } + + @Test + fun flattenAnimatingValue_neitherAnimating() { + val nested = AnimatedValue.NotAnimating(AnimatedValue.NotAnimating(10)) + val flattened = nested.flatten() + assertThat(flattened.value).isEqualTo(10) + assertThat(flattened.isAnimating).isFalse() + } + + @Test + fun flattenAnimatingValue_outerAnimating() { + var stopped = false + val inner = AnimatedValue.NotAnimating(10) + val nested = AnimatedValue.Animating(inner) { stopped = true } + val flattened = nested.flatten() + assertThat(flattened.value).isEqualTo(10) + assertThat(flattened.isAnimating).isTrue() + flattened.stopAnimating() + assertThat(stopped).isTrue() + } + + @Test + fun flattenAnimatingValue_innerAnimating() { + var stopped = false + val inner = AnimatedValue.Animating(10) { stopped = true } + val nested = AnimatedValue.NotAnimating(inner) + val flattened = nested.flatten() + assertThat(flattened.value).isEqualTo(10) + assertThat(flattened.isAnimating).isTrue() + flattened.stopAnimating() + assertThat(stopped).isTrue() + } + + @Test + fun flattenAnimatingValue_bothAnimating() { + var innerStopped = false + var outerStopped = false + val inner = AnimatedValue.Animating(10) { innerStopped = true } + val nested = AnimatedValue.Animating(inner) { outerStopped = true } + val flattened = nested.flatten() + assertThat(flattened.value).isEqualTo(10) + assertThat(flattened.isAnimating).isTrue() + flattened.stopAnimating() + assertThat(innerStopped).isTrue() + assertThat(outerStopped).isTrue() } } |