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

Commit 3955e224 authored by Liran Binyamin's avatar Liran Binyamin Committed by Android (Google) Code Review
Browse files

Merge "Handle new bubble notification during animation" into main

parents 9486f5b4 0242d124
Loading
Loading
Loading
Loading
+172 −28
Original line number Diff line number Diff line
@@ -55,9 +55,11 @@ constructor(
            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 @@ constructor(
            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,9 +178,10 @@ constructor(
     * 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
        // prepare the bubble bar for the animation if we're starting fresh
        if (initialVelocity == null) {
            bubbleBarView.visibility = VISIBLE
            bubbleBarView.alpha = 0f
            bubbleBarView.translationY = 0f
@@ -176,6 +190,7 @@ constructor(
            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 @@ constructor(
        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 @@ constructor(
                }
            }
        }
        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 @@ constructor(
        val flyout = bubble?.flyoutMessage
        if (flyout != null) {
            bubbleBarFlyoutController.collapseFlyout {
                onFlyoutRemoved(bubble.view)
                onFlyoutRemoved()
                animator.start()
            }
        } else {
@@ -336,8 +363,6 @@ constructor(

    /** 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 @@ constructor(
                buildBubbleBarToHandleAnimation()
            } else {
                Runnable {
                    bubbleBarFlyoutController.collapseFlyout { onFlyoutRemoved(bubbleView) }
                    moveToState(AnimatingBubble.State.ANIMATING_OUT)
                    bubbleBarFlyoutController.collapseFlyout {
                        onFlyoutRemoved()
                        animatingBubble = null
                    }
                    bubbleStashController.showBubbleBarImmediate()
                    bubbleStashController.updateTaskbarTouchRegion()
                }
@@ -394,16 +422,23 @@ constructor(
    }

    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) }
            moveToState(AnimatingBubble.State.ANIMATING_OUT)
            bubbleBarFlyoutController.collapseFlyout {
                onFlyoutRemoved()
                animatingBubble = null
            }
            bubbleStashController.showBubbleBarImmediate()
            bubbleStashController.updateTaskbarTouchRegion()
        }
@@ -462,12 +497,11 @@ constructor(
    }

    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 @@ constructor(
        }
    }

    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)
+30 −8
Original line number Diff line number Diff line
@@ -39,11 +39,14 @@ constructor(
    }

    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 @@ constructor(
        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 @@ constructor(
        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 @@ constructor(
    }

    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 @@ constructor(
    }

    fun hasFlyout() = flyout != null

    private fun getCurrentAnimatedValueIfRunning(): Float? {
        val animator = animator ?: return null
        return if (animator.isRunning) animator.animatedValue as Float else null
    }
}
+374 −14

File changed.

Preview size limit exceeded, changes collapsed.

+34 −0
Original line number Diff line number Diff line
@@ -246,6 +246,40 @@ class BubbleBarFlyoutControllerTest {
        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