Handle new bubble notification during animation
When a new bubble notification is pushed during an ongoing animation
we now update it to reflect the new bubble.
- If the current animation is in the middle of animating in,
we only update the animating bubble without interrupting the
current animation. The flyout content will show the data for the
new bubble.
- If the current animation is in the IN state, then the flyout is
showing and we're just waiting for it to be hidden. In this case
we update the flyout and reschedule the hide animation to run after
a delay.
- If the current animation is animating out,
- If we're in the middle of collapsing the flyout, we reverse it
so it shows with the new content and reschedule the hide animation
- If the flyout is gone already, then we reverse the handle animation
and show the flyout. If the handle animation is not running, then
the animation is already over.
Not really related to this change, but while testing one of the tests
was flaky, so rewrote it to make it deterministic.
25x runs: https://android-build.corp.google.com/test_investigate/invocation/I56000010330987902/test/TR44729496826317635/
Demo of interrupting the animation while animating out: http://recall/-/bJtug1HhvXkkeA4MQvIaiP/cu4fLeFlnDkg7mDVaaDTB
Flag: com.android.wm.shell.enable_bubble_bar
Fixes: 277815200
Fixes: 346400677
Test: atest BubbleBarFlyoutControllerTest
Test: atest BubbleBarViewAnimatorTest
Change-Id: I2b4af36d39bedbdfc4a08a988967ccbc33c06522
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
index 78e5dbd..eca2bc8 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
@@ -55,9 +55,11 @@
return animatingBubble.state != AnimatingBubble.State.CREATED
}
+ private var interceptedHandleAnimator = false
+
private companion object {
/** The time to show the flyout. */
- const val FLYOUT_DELAY_MS: Long = 3000
+ const val FLYOUT_DELAY_MS: Long = 10000
/** The initial scale Y value that the new bubble is set to before the animation starts. */
const val BUBBLE_ANIMATION_INITIAL_SCALE_Y = 0.3f
/** The minimum alpha value to make the bubble bar touchable. */
@@ -133,10 +135,21 @@
dampingRatio = SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY,
)
+ private fun cancelAnimationIfPending() {
+ val animatingBubble = animatingBubble ?: return
+ if (animatingBubble.state != AnimatingBubble.State.CREATED) return
+ scheduler.cancel(animatingBubble.showAnimation)
+ scheduler.cancel(animatingBubble.hideAnimation)
+ }
+
/** Animates a bubble for the state where the bubble bar is stashed. */
fun animateBubbleInForStashed(b: BubbleBarBubble, isExpanding: Boolean) {
- // TODO b/346400677: handle animations for the same bubble interrupting each other
- if (animatingBubble?.bubbleView?.bubble?.key == b.key) return
+ if (isAnimating) {
+ interruptAndUpdateAnimatingBubble(b.view, isExpanding)
+ return
+ }
+ cancelAnimationIfPending()
+
val bubbleView = b.view
val animator = PhysicsAnimator.getInstance(bubbleView)
if (animator.isRunning()) animator.cancel()
@@ -165,17 +178,19 @@
* 3. The third part is the overshoot of the spring animation, where we make the bubble fully
* visible which helps avoiding further updates when we re-enter the second part.
*/
- private fun buildHandleToBubbleBarAnimation() = Runnable {
+ private fun buildHandleToBubbleBarAnimation(initialVelocity: Float? = null) = Runnable {
moveToState(AnimatingBubble.State.ANIMATING_IN)
- // prepare the bubble bar for the animation
- bubbleBarView.visibility = VISIBLE
- bubbleBarView.alpha = 0f
- bubbleBarView.translationY = 0f
- bubbleBarView.scaleX = 1f
- bubbleBarView.scaleY = BUBBLE_ANIMATION_INITIAL_SCALE_Y
- bubbleBarView.setBackgroundScaleX(1f)
- bubbleBarView.setBackgroundScaleY(1f)
- bubbleBarView.relativePivotY = 0.5f
+ // prepare the bubble bar for the animation if we're starting fresh
+ if (initialVelocity == null) {
+ bubbleBarView.visibility = VISIBLE
+ bubbleBarView.alpha = 0f
+ bubbleBarView.translationY = 0f
+ bubbleBarView.scaleX = 1f
+ bubbleBarView.scaleY = BUBBLE_ANIMATION_INITIAL_SCALE_Y
+ bubbleBarView.setBackgroundScaleX(1f)
+ bubbleBarView.setBackgroundScaleY(1f)
+ bubbleBarView.relativePivotY = 0.5f
+ }
// this is the offset between the center of the bubble bar and the center of the stash
// handle. when the handle becomes invisible and we start animating in the bubble bar,
@@ -194,7 +209,7 @@
val totalTranslationY = bubbleStashController.bubbleBarTranslationYForTaskbar + offset
val animator = bubbleStashController.getStashedHandlePhysicsAnimator() ?: return@Runnable
animator.setDefaultSpringConfig(springConfig)
- animator.spring(DynamicAnimation.TRANSLATION_Y, totalTranslationY)
+ animator.spring(DynamicAnimation.TRANSLATION_Y, totalTranslationY, initialVelocity ?: 0f)
animator.addUpdateListener { handle, values ->
val ty = values[DynamicAnimation.TRANSLATION_Y]?.value ?: return@addUpdateListener
when {
@@ -314,7 +329,19 @@
}
}
}
- animator.addEndListener { _, _, _, canceled, _, _, _ ->
+ animator.addEndListener { _, _, _, canceled, _, finalVelocity, _ ->
+ // PhysicsAnimator calls the end listeners when the animation is replaced with a new one
+ // if we're not in ANIMATING_OUT state, then this animation never started and we should
+ // return
+ if (animatingBubble?.state != AnimatingBubble.State.ANIMATING_OUT) return@addEndListener
+ if (interceptedHandleAnimator) {
+ interceptedHandleAnimator = false
+ // post this to give a PhysicsAnimator a chance to clean up its internal listeners.
+ // otherwise this end listener will be called as soon as we create a new spring
+ // animation
+ scheduler.post(buildHandleToBubbleBarAnimation(initialVelocity = finalVelocity))
+ return@addEndListener
+ }
animatingBubble = null
if (!canceled) bubbleStashController.stashBubbleBarImmediate()
bubbleBarView.relativePivotY = 1f
@@ -326,7 +353,7 @@
val flyout = bubble?.flyoutMessage
if (flyout != null) {
bubbleBarFlyoutController.collapseFlyout {
- onFlyoutRemoved(bubble.view)
+ onFlyoutRemoved()
animator.start()
}
} else {
@@ -336,8 +363,6 @@
/** Animates to the initial state of the bubble bar, when there are no previous bubbles. */
fun animateToInitialState(b: BubbleBarBubble, isInApp: Boolean, isExpanding: Boolean) {
- // TODO b/346400677: handle animations for the same bubble interrupting each other
- if (animatingBubble?.bubbleView?.bubble?.key == b.key) return
val bubbleView = b.view
val animator = PhysicsAnimator.getInstance(bubbleView)
if (animator.isRunning()) animator.cancel()
@@ -350,8 +375,11 @@
buildBubbleBarToHandleAnimation()
} else {
Runnable {
- bubbleBarFlyoutController.collapseFlyout { onFlyoutRemoved(bubbleView) }
- animatingBubble = null
+ moveToState(AnimatingBubble.State.ANIMATING_OUT)
+ bubbleBarFlyoutController.collapseFlyout {
+ onFlyoutRemoved()
+ animatingBubble = null
+ }
bubbleStashController.showBubbleBarImmediate()
bubbleStashController.updateTaskbarTouchRegion()
}
@@ -394,16 +422,23 @@
}
fun animateBubbleBarForCollapsed(b: BubbleBarBubble, isExpanding: Boolean) {
- // TODO b/346400677: handle animations for the same bubble interrupting each other
- if (animatingBubble?.bubbleView?.bubble?.key == b.key) return
+ if (isAnimating) {
+ interruptAndUpdateAnimatingBubble(b.view, isExpanding)
+ return
+ }
+ cancelAnimationIfPending()
+
val bubbleView = b.view
val animator = PhysicsAnimator.getInstance(bubbleView)
if (animator.isRunning()) animator.cancel()
// first bounce the bubble bar and show the flyout. Then hide the flyout.
val showAnimation = buildBubbleBarBounceAnimation()
val hideAnimation = Runnable {
- bubbleBarFlyoutController.collapseFlyout { onFlyoutRemoved(bubbleView) }
- animatingBubble = null
+ moveToState(AnimatingBubble.State.ANIMATING_OUT)
+ bubbleBarFlyoutController.collapseFlyout {
+ onFlyoutRemoved()
+ animatingBubble = null
+ }
bubbleStashController.showBubbleBarImmediate()
bubbleStashController.updateTaskbarTouchRegion()
}
@@ -462,12 +497,11 @@
}
private fun cancelFlyout() {
- val bubbleView = animatingBubble?.bubbleView
- bubbleBarFlyoutController.cancelFlyout { onFlyoutRemoved(bubbleView) }
+ bubbleBarFlyoutController.cancelFlyout { onFlyoutRemoved() }
}
- private fun onFlyoutRemoved(bubbleView: BubbleView?) {
- bubbleView?.suppressDotForBubbleUpdate(false)
+ private fun onFlyoutRemoved() {
+ animatingBubble?.bubbleView?.suppressDotForBubbleUpdate(false)
bubbleStashController.updateTaskbarTouchRegion()
}
@@ -507,6 +541,116 @@
}
}
+ private fun interruptAndUpdateAnimatingBubble(bubbleView: BubbleView, isExpanding: Boolean) {
+ val animatingBubble = animatingBubble ?: return
+ when (animatingBubble.state) {
+ AnimatingBubble.State.CREATED -> {} // nothing to do since the animation hasn't started
+ AnimatingBubble.State.ANIMATING_IN ->
+ updateAnimationWhileAnimatingIn(animatingBubble, bubbleView, isExpanding)
+ AnimatingBubble.State.IN ->
+ updateAnimationWhileIn(animatingBubble, bubbleView, isExpanding)
+ AnimatingBubble.State.ANIMATING_OUT ->
+ updateAnimationWhileAnimatingOut(animatingBubble, bubbleView, isExpanding)
+ }
+ }
+
+ private fun updateAnimationWhileAnimatingIn(
+ animatingBubble: AnimatingBubble,
+ bubbleView: BubbleView,
+ isExpanding: Boolean,
+ ) {
+ this.animatingBubble = animatingBubble.copy(bubbleView = bubbleView, expand = isExpanding)
+ if (!bubbleBarFlyoutController.hasFlyout()) {
+ // if the flyout does not yet exist, then we're only animating the bubble bar.
+ // the animating bubble has been updated, so the when the flyout expands it will
+ // show the right message.
+ return
+ }
+
+ val bubble = bubbleView.bubble as? BubbleBarBubble
+ val flyout = bubble?.flyoutMessage
+ if (flyout != null) {
+ // the flyout is currently expanding and we need to update it with new data
+ bubbleView.suppressDotForBubbleUpdate(true)
+ bubbleBarFlyoutController.updateFlyoutWhileExpanding(flyout)
+ } else {
+ // the flyout is expanding but we don't have new flyout data to update it with,
+ // so cancel the expanding flyout.
+ cancelFlyout()
+ }
+ }
+
+ private fun updateAnimationWhileIn(
+ animatingBubble: AnimatingBubble,
+ bubbleView: BubbleView,
+ isExpanding: Boolean,
+ ) {
+ // unsuppress the current bubble because we are about to hide its flyout
+ animatingBubble.bubbleView.suppressDotForBubbleUpdate(false)
+ this.animatingBubble = animatingBubble.copy(bubbleView = bubbleView, expand = isExpanding)
+
+ // we're currently idle, waiting for the hide animation to start. update the flyout
+ // data and reschedule the hide animation to run later to give the user a chance to
+ // see the new flyout.
+ val hideAnimation = animatingBubble.hideAnimation
+ scheduler.cancel(hideAnimation)
+ scheduler.postDelayed(FLYOUT_DELAY_MS, hideAnimation)
+
+ val bubble = bubbleView.bubble as? BubbleBarBubble
+ val flyout = bubble?.flyoutMessage
+ if (flyout != null) {
+ bubbleView.suppressDotForBubbleUpdate(true)
+ bubbleBarFlyoutController.updateFlyoutFullyExpanded(flyout) {
+ bubbleStashController.updateTaskbarTouchRegion()
+ }
+ } else {
+ cancelFlyout()
+ }
+ }
+
+ private fun updateAnimationWhileAnimatingOut(
+ animatingBubble: AnimatingBubble,
+ bubbleView: BubbleView,
+ isExpanding: Boolean,
+ ) {
+ // unsuppress the current bubble because we are about to hide its flyout
+ animatingBubble.bubbleView.suppressDotForBubbleUpdate(false)
+ this.animatingBubble = animatingBubble.copy(bubbleView = bubbleView, expand = isExpanding)
+
+ // the hide animation already started so it can't be canceled, just post it again
+ val hideAnimation = animatingBubble.hideAnimation
+ scheduler.postDelayed(FLYOUT_DELAY_MS, hideAnimation)
+
+ val bubble = bubbleView.bubble as? BubbleBarBubble
+ val flyout = bubble?.flyoutMessage
+ if (bubbleBarFlyoutController.hasFlyout()) {
+ // the flyout is collapsing. update it with the new flyout
+ if (flyout != null) {
+ moveToState(AnimatingBubble.State.ANIMATING_IN)
+ bubbleView.suppressDotForBubbleUpdate(true)
+ bubbleBarFlyoutController.updateFlyoutWhileCollapsing(flyout) {
+ moveToState(AnimatingBubble.State.IN)
+ bubbleStashController.updateTaskbarTouchRegion()
+ }
+ } else {
+ cancelFlyout()
+ moveToState(AnimatingBubble.State.IN)
+ }
+ } else {
+ // the flyout is already gone. if we're animating the handle cancel it. the
+ // animation itself can handle morphing back into the bubble bar and restarting
+ // and show the flyout.
+ val handleAnimator = bubbleStashController.getStashedHandlePhysicsAnimator()
+ if (handleAnimator != null && handleAnimator.isRunning()) {
+ interceptedHandleAnimator = true
+ handleAnimator.cancel()
+ }
+
+ // if we're not animating the handle, then the hide animation simply hides the
+ // flyout, but if the flyout is gone then the animation has ended.
+ }
+ }
+
private fun cancelHideAnimation() {
val hideAnimation = animatingBubble?.hideAnimation ?: return
scheduler.cancel(hideAnimation)
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutController.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutController.kt
index f389d7e..1452cf6 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutController.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutController.kt
@@ -39,11 +39,14 @@
}
private var flyout: BubbleBarFlyoutView? = null
+ private var animator: ValueAnimator? = null
private val horizontalMargin =
container.context.resources.getDimensionPixelSize(R.dimen.transient_taskbar_bottom_margin)
private enum class AnimationType {
- COLLAPSE,
+ /** Morphs the flyout between a dot and a rounded rectangle. */
+ MORPH,
+ /** Fades the flyout in or out. */
FADE,
}
@@ -73,16 +76,20 @@
container.addView(flyout, lp)
this.flyout = flyout
- flyout.showFromCollapsed(message) { showFlyout(AnimationType.COLLAPSE, onEnd) }
+ flyout.showFromCollapsed(message) { showFlyout(AnimationType.MORPH, onEnd) }
}
private fun showFlyout(animationType: AnimationType, endAction: () -> Unit) {
val flyout = this.flyout ?: return
- val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(ANIMATION_DURATION_MS)
+ val startValue = getCurrentAnimatedValueIfRunning() ?: 0f
+ val duration = (ANIMATION_DURATION_MS * (1f - startValue)).toLong()
+ animator?.cancel()
+ val animator = ValueAnimator.ofFloat(startValue, 1f).setDuration(duration)
+ this.animator = animator
when (animationType) {
AnimationType.FADE ->
animator.addUpdateListener { _ -> flyout.alpha = animator.animatedValue as Float }
- AnimationType.COLLAPSE ->
+ AnimationType.MORPH ->
animator.addUpdateListener { _ ->
flyout.updateExpansionProgress(animator.animatedValue as Float)
}
@@ -109,6 +116,13 @@
flyout.updateData(message) { extendTopBoundary() }
}
+ fun updateFlyoutWhileCollapsing(message: BubbleBarFlyoutMessage, onEnd: () -> Unit) {
+ val flyout = flyout ?: return
+ animator?.pause()
+ animator?.removeAllListeners()
+ flyout.updateData(message) { showFlyout(AnimationType.MORPH, onEnd) }
+ }
+
private fun extendTopBoundary() {
val flyout = flyout ?: return
val flyoutTop = flyout.top + flyout.translationY
@@ -125,20 +139,23 @@
}
fun collapseFlyout(endAction: () -> Unit) {
- hideFlyout(AnimationType.COLLAPSE) {
+ hideFlyout(AnimationType.MORPH) {
cleanupFlyoutView()
endAction()
}
}
private fun hideFlyout(animationType: AnimationType, endAction: () -> Unit) {
- // TODO: b/277815200 - stop the current animation if it's running
val flyout = this.flyout ?: return
- val animator = ValueAnimator.ofFloat(1f, 0f).setDuration(ANIMATION_DURATION_MS)
+ val startValue = getCurrentAnimatedValueIfRunning() ?: 1f
+ val duration = (ANIMATION_DURATION_MS * startValue).toLong()
+ animator?.cancel()
+ val animator = ValueAnimator.ofFloat(startValue, 0f).setDuration(duration)
+ this.animator = animator
when (animationType) {
AnimationType.FADE ->
animator.addUpdateListener { _ -> flyout.alpha = animator.animatedValue as Float }
- AnimationType.COLLAPSE ->
+ AnimationType.MORPH ->
animator.addUpdateListener { _ ->
flyout.updateExpansionProgress(animator.animatedValue as Float)
}
@@ -154,4 +171,9 @@
}
fun hasFlyout() = flyout != null
+
+ private fun getCurrentAnimatedValueIfRunning(): Float? {
+ val animator = animator ?: return null
+ return if (animator.isRunning) animator.animatedValue as Float else null
+ }
}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt
index b0d01d3..48f3fc2 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimatorTest.kt
@@ -25,6 +25,7 @@
import android.view.View
import android.view.View.VISIBLE
import android.widget.FrameLayout
+import android.widget.TextView
import androidx.core.animation.AnimatorTestRule
import androidx.core.graphics.drawable.toBitmap
import androidx.dynamicanimation.animation.DynamicAnimation
@@ -834,23 +835,27 @@
// advance the animation handler by the duration of the initial lift
InstrumentationRegistry.getInstrumentation().runOnMainSync {
- animatorTestRule.advanceTimeBy(250)
+ animatorTestRule.advanceTimeBy(100)
}
- // the lift animation is complete; the spring back animation should start now
- InstrumentationRegistry.getInstrumentation().runOnMainSync {}
- barAnimator.assertIsRunning()
- PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue(barAnimator) { true }
+ // send the expand signal in the middle of the lift animation
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animator.expandedWhileAnimating()
+ }
+
+ // let the lift animation complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animatorTestRule.advanceTimeBy(150)
+ }
// verify there is a pending hide animation
assertThat(animatorScheduler.delayedBlock).isNotNull()
assertThat(animator.isAnimating).isTrue()
- InstrumentationRegistry.getInstrumentation().runOnMainSync {
- animator.expandedWhileAnimating()
- }
-
- // let the animation finish
+ // the lift animation is complete; the spring back animation should start now. wait for it
+ // to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ barAnimator.assertIsRunning()
PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
// verify that the hide animation was canceled
@@ -923,6 +928,344 @@
assertThat(notifiedExpanded).isTrue()
}
+ @Test
+ fun interruptAnimation_whileAnimatingIn() {
+ setUpBubbleBar()
+ setUpBubbleStashController()
+
+ val handle = View(context)
+ val handleAnimator = PhysicsAnimator.getInstance(handle)
+ whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+
+ val animator =
+ BubbleBarViewAnimator(
+ bubbleBarView,
+ bubbleStashController,
+ flyoutController,
+ onExpandedNoOp,
+ animatorScheduler,
+ )
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animator.animateBubbleInForStashed(bubble, isExpanding = false)
+ }
+
+ // let the animation start and wait until the first frame
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue(handleAnimator) { true }
+
+ handleAnimator.assertIsRunning()
+ assertThat(animator.isAnimating).isTrue()
+
+ val updatedBubble =
+ bubble.copy(flyoutMessage = bubble.flyoutMessage!!.copy(message = "updated message"))
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ bubbleView.setBubble(updatedBubble)
+ animator.animateBubbleInForStashed(updatedBubble, isExpanding = false)
+ }
+
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(0)
+ assertThat(handle.translationY)
+ .isEqualTo(DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS + BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
+ assertThat(bubbleBarView.scaleX).isEqualTo(1)
+ assertThat(bubbleBarView.scaleY).isEqualTo(1)
+ assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(animator.isAnimating).isTrue()
+
+ waitForFlyoutToShow()
+ assertThat(flyoutView!!.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("updated message")
+
+ // run the hide animation
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.delayedBlock!!)
+
+ waitForFlyoutToHide()
+
+ // let the animation start and wait for it to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(1)
+ assertThat(handle.translationY).isEqualTo(0)
+ assertThat(bubbleBarView.alpha).isEqualTo(0)
+ assertThat(animator.isAnimating).isFalse()
+ verify(bubbleStashController).stashBubbleBarImmediate()
+ }
+
+ @Test
+ fun interruptAnimation_whileIn() {
+ setUpBubbleBar()
+ setUpBubbleStashController()
+
+ val handle = View(context)
+ val handleAnimator = PhysicsAnimator.getInstance(handle)
+ whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+
+ val animator =
+ BubbleBarViewAnimator(
+ bubbleBarView,
+ bubbleStashController,
+ flyoutController,
+ onExpandedNoOp,
+ animatorScheduler,
+ )
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animator.animateBubbleInForStashed(bubble, isExpanding = false)
+ }
+
+ // let the animation start and wait for it to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(0)
+ assertThat(handle.translationY)
+ .isEqualTo(DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS + BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
+ assertThat(bubbleBarView.scaleX).isEqualTo(1)
+ assertThat(bubbleBarView.scaleY).isEqualTo(1)
+ assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(animator.isAnimating).isTrue()
+
+ waitForFlyoutToShow()
+
+ assertThat(flyoutView!!.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("message")
+
+ // verify the hide animation is pending
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+
+ val updatedBubble =
+ bubble.copy(flyoutMessage = bubble.flyoutMessage!!.copy(message = "updated message"))
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ bubbleView.setBubble(updatedBubble)
+ animator.animateBubbleInForStashed(updatedBubble, isExpanding = false)
+ }
+
+ // verify the hide animation was rescheduled
+ assertThat(animatorScheduler.canceledBlock).isNotNull()
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+
+ waitForFlyoutToFadeOutAndBackIn()
+
+ assertThat(flyoutView!!.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("updated message")
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.delayedBlock!!)
+
+ waitForFlyoutToHide()
+
+ // let the animation start and wait for it to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(1)
+ assertThat(handle.translationY).isEqualTo(0)
+ assertThat(bubbleBarView.alpha).isEqualTo(0)
+ assertThat(animator.isAnimating).isFalse()
+ verify(bubbleStashController).stashBubbleBarImmediate()
+ }
+
+ @Test
+ fun interruptAnimation_whileAnimatingOut_whileCollapsingFlyout() {
+ setUpBubbleBar()
+ setUpBubbleStashController()
+
+ val handle = View(context)
+ val handleAnimator = PhysicsAnimator.getInstance(handle)
+ whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+
+ val animator =
+ BubbleBarViewAnimator(
+ bubbleBarView,
+ bubbleStashController,
+ flyoutController,
+ onExpandedNoOp,
+ animatorScheduler,
+ )
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animator.animateBubbleInForStashed(bubble, isExpanding = false)
+ }
+
+ // let the animation start and wait for it to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(0)
+ assertThat(handle.translationY)
+ .isEqualTo(DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS + BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
+ assertThat(bubbleBarView.scaleX).isEqualTo(1)
+ assertThat(bubbleBarView.scaleY).isEqualTo(1)
+ assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(animator.isAnimating).isTrue()
+
+ waitForFlyoutToShow()
+
+ assertThat(flyoutView!!.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("message")
+
+ // run the hide animation
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.delayedBlock!!)
+
+ // interrupt the animation while the flyout is collapsing
+ val updatedBubble =
+ bubble.copy(flyoutMessage = bubble.flyoutMessage!!.copy(message = "updated message"))
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animatorTestRule.advanceTimeBy(100)
+ bubbleView.setBubble(updatedBubble)
+ animator.animateBubbleInForStashed(updatedBubble, isExpanding = false)
+
+ // the flyout should now reverse and expand
+ animatorTestRule.advanceTimeBy(100)
+ }
+
+ assertThat(flyoutView!!.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("updated message")
+
+ assertThat(handle.alpha).isEqualTo(0)
+ assertThat(handle.translationY)
+ .isEqualTo(DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS + BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
+ assertThat(bubbleBarView.scaleX).isEqualTo(1)
+ assertThat(bubbleBarView.scaleY).isEqualTo(1)
+ assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
+
+ // verify the hide animation was rescheduled and run it
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.delayedBlock!!)
+
+ waitForFlyoutToHide()
+
+ // let the animation start and wait for it to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(1)
+ assertThat(handle.translationY).isEqualTo(0)
+ assertThat(bubbleBarView.alpha).isEqualTo(0)
+ assertThat(animator.isAnimating).isFalse()
+ verify(bubbleStashController).stashBubbleBarImmediate()
+ }
+
+ @Test
+ fun interruptAnimation_whileAnimatingOut_barToHandle() {
+ setUpBubbleBar()
+ setUpBubbleStashController()
+
+ val handle = View(context)
+ val handleAnimator = PhysicsAnimator.getInstance(handle)
+ whenever(bubbleStashController.getStashedHandlePhysicsAnimator()).thenReturn(handleAnimator)
+
+ val animator =
+ BubbleBarViewAnimator(
+ bubbleBarView,
+ bubbleStashController,
+ flyoutController,
+ onExpandedNoOp,
+ animatorScheduler,
+ )
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animator.animateBubbleInForStashed(bubble, isExpanding = false)
+ }
+
+ // let the animation start and wait for it to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(0)
+ assertThat(handle.translationY)
+ .isEqualTo(DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS + BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
+ assertThat(bubbleBarView.scaleX).isEqualTo(1)
+ assertThat(bubbleBarView.scaleY).isEqualTo(1)
+ assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(animator.isAnimating).isTrue()
+
+ waitForFlyoutToShow()
+
+ assertThat(flyoutView!!.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("message")
+
+ // run the hide animation
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.delayedBlock!!)
+
+ waitForFlyoutToHide()
+
+ // interrupt the animation while the bar is animating to the handle
+ PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue(handleAnimator) {
+ bubbleBarView.alpha < 0.5
+ }
+
+ // we're about to interrupt the animation which will cancel the current animation and start
+ // a new one. pause the scheduler to delay starting the new animation. this allows us to run
+ // the test deterministically
+ animatorScheduler.pauseScheduler = true
+
+ val updatedBubble =
+ bubble.copy(flyoutMessage = bubble.flyoutMessage!!.copy(message = "updated message"))
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ bubbleView.setBubble(updatedBubble)
+ animator.animateBubbleInForStashed(updatedBubble, isExpanding = false)
+ }
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ // verify there's a new job scheduled and start it. this is starting the animation from the
+ // handle back to the bar
+ assertThat(animatorScheduler.pausedBlock).isNotNull()
+ animatorScheduler.pauseScheduler = false
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.pausedBlock!!)
+
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ waitForFlyoutToShow()
+
+ assertThat(flyoutView!!.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("updated message")
+ assertThat(handle.alpha).isEqualTo(0)
+ assertThat(handle.translationY)
+ .isEqualTo(DIFF_BETWEEN_HANDLE_AND_BAR_CENTERS + BAR_TRANSLATION_Y_FOR_TASKBAR)
+ assertThat(bubbleBarView.visibility).isEqualTo(VISIBLE)
+ assertThat(bubbleBarView.scaleX).isEqualTo(1)
+ assertThat(bubbleBarView.scaleY).isEqualTo(1)
+ assertThat(bubbleBarView.translationY).isEqualTo(BAR_TRANSLATION_Y_FOR_TASKBAR)
+
+ // run the hide animation
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.delayedBlock!!)
+
+ waitForFlyoutToHide()
+
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ // verify the hide animation was rescheduled and run it
+ assertThat(animatorScheduler.delayedBlock).isNotNull()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(animatorScheduler.delayedBlock!!)
+
+ waitForFlyoutToHide()
+
+ // let the animation start and wait for it to complete
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {}
+ PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Y)
+
+ assertThat(handle.alpha).isEqualTo(1)
+ assertThat(handle.translationY).isEqualTo(0)
+ assertThat(bubbleBarView.alpha).isEqualTo(0)
+ assertThat(animator.isAnimating).isFalse()
+ verify(bubbleStashController).stashBubbleBarImmediate()
+ }
+
private fun setUpBubbleBar() {
bubbleBarView = BubbleBarView(context)
InstrumentationRegistry.getInstrumentation().runOnMainSync {
@@ -1019,18 +1362,25 @@
private fun waitForFlyoutToShow() {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
- animatorTestRule.advanceTimeBy(300)
+ animatorTestRule.advanceTimeBy(250)
}
assertThat(flyoutView).isNotNull()
}
private fun waitForFlyoutToHide() {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
- animatorTestRule.advanceTimeBy(300)
+ animatorTestRule.advanceTimeBy(250)
}
assertThat(flyoutView).isNull()
}
+ private fun waitForFlyoutToFadeOutAndBackIn() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ animatorTestRule.advanceTimeBy(500)
+ }
+ assertThat(flyoutView).isNotNull()
+ }
+
private fun <T> PhysicsAnimator<T>.assertIsRunning() {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
assertThat(isRunning()).isTrue()
@@ -1045,20 +1395,30 @@
private class TestBubbleBarViewAnimatorScheduler : BubbleBarViewAnimator.Scheduler {
+ var pauseScheduler = false
+ var pausedBlock: Runnable? = null
+ private set
+
var delayedBlock: Runnable? = null
private set
+ var canceledBlock: Runnable? = null
+ private set
+
override fun post(block: Runnable) {
+ if (pauseScheduler) {
+ pausedBlock = block
+ return
+ }
block.run()
}
override fun postDelayed(delayMillis: Long, block: Runnable) {
- check(delayedBlock == null) { "there is already a pending block waiting to run" }
delayedBlock = block
}
override fun cancel(block: Runnable) {
- check(delayedBlock == block) { "the pending block does not match the canceled block" }
+ canceledBlock = delayedBlock
delayedBlock = null
}
}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutControllerTest.kt
index 50bb9bc..fef82c1 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/flyout/BubbleBarFlyoutControllerTest.kt
@@ -246,6 +246,40 @@
assertThat(flyoutCallbacks.topBoundaryExtendedSpace).isEqualTo(50)
}
+ @Test
+ fun updateFlyoutWhileCollapsing() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ flyoutController.setUpAndShowFlyout(flyoutMessage) {}
+ animatorTestRule.advanceTimeBy(300)
+ }
+ assertThat(flyoutController.hasFlyout()).isTrue()
+
+ val newFlyoutMessage = flyoutMessage.copy(message = "new message")
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ var flyoutCollapsed = false
+ flyoutController.collapseFlyout { flyoutCollapsed = true }
+ // advance the fake timer so that the collapse animation runs for 125ms
+ animatorTestRule.advanceTimeBy(125)
+
+ // update the flyout in the middle of collapsing, which should start expanding it.
+ var flyoutReversed = false
+ flyoutController.updateFlyoutWhileCollapsing(newFlyoutMessage) { flyoutReversed = true }
+
+ // the collapse animation ran for 125ms when it was updated, so reversing it should only
+ // run for the same amount of time
+ animatorTestRule.advanceTimeBy(125)
+ val flyout = flyoutContainer.findViewById<View>(R.id.bubble_bar_flyout_view)
+ assertThat(flyout.alpha).isEqualTo(1)
+ assertThat(flyout.findViewById<TextView>(R.id.bubble_flyout_text).text)
+ .isEqualTo("new message")
+ // verify that we never called the end action on the collapse animation
+ assertThat(flyoutCollapsed).isFalse()
+ // verify that we called the end action on the reverse animation
+ assertThat(flyoutReversed).isTrue()
+ }
+ assertThat(flyoutController.hasFlyout()).isTrue()
+ }
+
class FakeFlyoutCallbacks : FlyoutCallbacks {
var topBoundaryExtendedSpace = 0