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

Commit 7d9b0e62 authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[Media TTT] Animate the sender chip out.

This also updates ViewHierarchyAnimator to have different behavior
depending on whether the view in question has siblings or not.

Bug: 203800644
Test: manual: See video attached to bug.
Test: media.taptotransfer tests
Test: ViewHierarchyAnimatorTest
Change-Id: I61bfb975de5a46e0ab490a5eec468ec141937e75
parent f6c00ca9
Loading
Loading
Loading
Loading
+29 −11
Original line number Original line Diff line number Diff line
@@ -361,13 +361,17 @@ class ViewHierarchyAnimator {
         *
         *
         * The end state of the animation is controlled by [destination]. This value can be any of
         * The end state of the animation is controlled by [destination]. This value can be any of
         * the four corners, any of the four edges, or the center of the view.
         * the four corners, any of the four edges, or the center of the view.
         *
         * @param onAnimationEnd an optional runnable that will be run once the animation finishes
         *    successfully. Will not be run if the animation is cancelled.
         */
         */
        @JvmOverloads
        @JvmOverloads
        fun animateRemoval(
        fun animateRemoval(
            rootView: View,
            rootView: View,
            destination: Hotspot = Hotspot.CENTER,
            destination: Hotspot = Hotspot.CENTER,
            interpolator: Interpolator = DEFAULT_REMOVAL_INTERPOLATOR,
            interpolator: Interpolator = DEFAULT_REMOVAL_INTERPOLATOR,
            duration: Long = DEFAULT_DURATION
            duration: Long = DEFAULT_DURATION,
            onAnimationEnd: Runnable? = null,
        ): Boolean {
        ): Boolean {
            if (
            if (
                !occupiesSpace(
                !occupiesSpace(
@@ -391,13 +395,28 @@ class ViewHierarchyAnimator {
                addListener(child, listener, recursive = false)
                addListener(child, listener, recursive = false)
            }
            }


            val viewHasSiblings = parent.childCount > 1
            if (viewHasSiblings) {
                // Remove the view so that a layout update is triggered for the siblings and they
                // Remove the view so that a layout update is triggered for the siblings and they
                // animate to their next position while the view's removal is also animating.
                // animate to their next position while the view's removal is also animating.
                parent.removeView(rootView)
                parent.removeView(rootView)
            // By adding the view to the overlay, we can animate it while it isn't part of the view
                // By adding the view to the overlay, we can animate it while it isn't part of the
            // hierarchy. It is correctly positioned because we have its previous bounds, and we set
                // view hierarchy. It is correctly positioned because we have its previous bounds,
            // them manually during the animation.
                // and we set them manually during the animation.
                parent.overlay.add(rootView)
                parent.overlay.add(rootView)
            }
            // If this view has no siblings, the parent view may shrink to (0,0) size and mess
            // up the animation if we immediately remove the view. So instead, we just leave the
            // view in the real hierarchy until the animation finishes.

            val endRunnable = Runnable {
                if (viewHasSiblings) {
                    parent.overlay.remove(rootView)
                } else {
                    parent.removeView(rootView)
                }
                onAnimationEnd?.run()
            }


            val startValues =
            val startValues =
                mapOf(
                mapOf(
@@ -430,7 +449,8 @@ class ViewHierarchyAnimator {
                endValues,
                endValues,
                interpolator,
                interpolator,
                duration,
                duration,
                ephemeral = true
                ephemeral = true,
                endRunnable,
            )
            )


            if (rootView is ViewGroup) {
            if (rootView is ViewGroup) {
@@ -463,7 +483,6 @@ class ViewHierarchyAnimator {
                                .alpha(0f)
                                .alpha(0f)
                                .setInterpolator(Interpolators.ALPHA_OUT)
                                .setInterpolator(Interpolators.ALPHA_OUT)
                                .setDuration(duration / 2)
                                .setDuration(duration / 2)
                                .withEndAction { parent.overlay.remove(rootView) }
                                .start()
                                .start()
                        }
                        }
                    }
                    }
@@ -477,7 +496,6 @@ class ViewHierarchyAnimator {
                    .setInterpolator(Interpolators.ALPHA_OUT)
                    .setInterpolator(Interpolators.ALPHA_OUT)
                    .setDuration(duration / 2)
                    .setDuration(duration / 2)
                    .setStartDelay(duration / 2)
                    .setStartDelay(duration / 2)
                    .withEndAction { parent.overlay.remove(rootView) }
                    .start()
                    .start()
            }
            }


+13 −1
Original line number Original line Diff line number Diff line
@@ -54,7 +54,7 @@ import javax.inject.Inject
 * chip is shown when a user is transferring media to/from this device and a receiver device.
 * chip is shown when a user is transferring media to/from this device and a receiver device.
 */
 */
@SysUISingleton
@SysUISingleton
class MediaTttChipControllerSender @Inject constructor(
open class MediaTttChipControllerSender @Inject constructor(
        commandQueue: CommandQueue,
        commandQueue: CommandQueue,
        context: Context,
        context: Context,
        @MediaTttSenderLogger logger: MediaTttLogger,
        @MediaTttSenderLogger logger: MediaTttLogger,
@@ -195,6 +195,18 @@ class MediaTttChipControllerSender @Inject constructor(
        )
        )
    }
    }


    override fun animateViewOut(view: ViewGroup, onAnimationEnd: Runnable) {
        ViewHierarchyAnimator.animateRemoval(
            view.requireViewById<ViewGroup>(R.id.media_ttt_sender_chip_inner),
            ViewHierarchyAnimator.Hotspot.TOP,
            Interpolators.EMPHASIZED_ACCELERATE,
            ANIMATION_DURATION,
            onAnimationEnd,
        )
        // TODO(b/203800644): Add includeMargins as an option to ViewHierarchyAnimator so that the
        //   animateChipOut matches the animateChipIn.
    }

    override fun shouldIgnoreViewRemoval(removalReason: String): Boolean {
    override fun shouldIgnoreViewRemoval(removalReason: String): Boolean {
        // Don't remove the chip if we're in progress or succeeded, since the user should still be
        // Don't remove the chip if we're in progress or succeeded, since the user should still be
        // able to see the status of the transfer. (But do remove it if it's finally timed out.)
        // able to see the status of the transfer. (But do remove it if it's finally timed out.)
+17 −3
Original line number Original line Diff line number Diff line
@@ -171,11 +171,15 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
        if (shouldIgnoreViewRemoval(removalReason)) {
        if (shouldIgnoreViewRemoval(removalReason)) {
            return
            return
        }
        }
        val currentView = view ?: return

        animateViewOut(currentView) { windowManager.removeView(currentView) }


        if (view == null) { return }
        logger.logChipRemoval(removalReason)
        logger.logChipRemoval(removalReason)
        configurationController.removeCallback(displayScaleListener)
        configurationController.removeCallback(displayScaleListener)
        windowManager.removeView(view)
        // Re-set the view to null immediately (instead as part of the animation end runnable) so
        // that if a new view event comes in while this view is animating out, we still display the
        // new view appropriately.
        view = null
        view = null
        info = null
        info = null
        // No need to time the view out since it's already gone
        // No need to time the view out since it's already gone
@@ -201,7 +205,17 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
     * A method that can be implemented by subclasses to do custom animations for when the view
     * A method that can be implemented by subclasses to do custom animations for when the view
     * appears.
     * appears.
     */
     */
    open fun animateViewIn(view: ViewGroup) {}
    internal open fun animateViewIn(view: ViewGroup) {}

    /**
     * A method that can be implemented by subclasses to do custom animations for when the view
     * disappears.
     *
     * @param onAnimationEnd an action that *must* be run once the animation finishes successfully.
     */
    internal open fun animateViewOut(view: ViewGroup, onAnimationEnd: Runnable) {
        onAnimationEnd.run()
    }
}
}


object TemporaryDisplayRemovalReason {
object TemporaryDisplayRemovalReason {
+84 −1
Original line number Original line Diff line number Diff line
@@ -718,7 +718,7 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
    }
    }


    @Test
    @Test
    fun animatesViewRemovalFromStartToEnd() {
    fun animatesViewRemovalFromStartToEnd_viewHasSiblings() {
        setUpRootWithChildren()
        setUpRootWithChildren()


        val child = rootView.getChildAt(0)
        val child = rootView.getChildAt(0)
@@ -741,6 +741,35 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
        assertFalse(child in rootView.children)
        assertFalse(child in rootView.children)
    }
    }


    @Test
    fun animatesViewRemovalFromStartToEnd_viewHasNoSiblings() {
        rootView = LinearLayout(mContext)
        (rootView as LinearLayout).orientation = LinearLayout.HORIZONTAL
        (rootView as LinearLayout).weightSum = 1f

        val onlyChild = View(mContext)
        rootView.addView(onlyChild)
        forceLayout()

        val success = ViewHierarchyAnimator.animateRemoval(
            onlyChild,
            destination = ViewHierarchyAnimator.Hotspot.LEFT,
            interpolator = Interpolators.LINEAR
        )

        assertTrue(success)
        assertNotNull(onlyChild.getTag(R.id.tag_animator))
        checkBounds(onlyChild, l = 0, t = 0, r = 200, b = 100)
        advanceAnimation(onlyChild, 0.5f)
        checkBounds(onlyChild, l = 0, t = 0, r = 100, b = 100)
        advanceAnimation(onlyChild, 1.0f)
        checkBounds(onlyChild, l = 0, t = 0, r = 0, b = 100)
        endAnimation(rootView)
        endAnimation(onlyChild)
        assertEquals(0, rootView.childCount)
        assertFalse(onlyChild in rootView.children)
    }

    @Test
    @Test
    fun animatesViewRemovalRespectingDestination() {
    fun animatesViewRemovalRespectingDestination() {
        // CENTER
        // CENTER
@@ -963,6 +992,60 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
        assertNull(remainingChild.getTag(R.id.tag_animator))
        assertNull(remainingChild.getTag(R.id.tag_animator))
    }
    }


    @Test
    fun animateRemoval_runnableRunsWhenAnimationEnds() {
        var runnableRun = false
        val onAnimationEndRunnable = { runnableRun = true }

        setUpRootWithChildren()
        forceLayout()
        val removedView = rootView.getChildAt(0)

        ViewHierarchyAnimator.animateRemoval(
            removedView,
            onAnimationEnd = onAnimationEndRunnable
        )
        endAnimation(removedView)

        assertEquals(true, runnableRun)
    }

    @Test
    fun animateRemoval_runnableDoesNotRunWhenAnimationCancelled() {
        var runnableRun = false
        val onAnimationEndRunnable = { runnableRun = true }

        setUpRootWithChildren()
        forceLayout()
        val removedView = rootView.getChildAt(0)

        ViewHierarchyAnimator.animateRemoval(
            removedView,
            onAnimationEnd = onAnimationEndRunnable
        )
        cancelAnimation(removedView)

        assertEquals(false, runnableRun)
    }

    @Test
    fun animationRemoval_runnableDoesNotRunWhenOnlyPartwayThroughAnimation() {
        var runnableRun = false
        val onAnimationEndRunnable = { runnableRun = true }

        setUpRootWithChildren()
        forceLayout()
        val removedView = rootView.getChildAt(0)

        ViewHierarchyAnimator.animateRemoval(
            removedView,
            onAnimationEnd = onAnimationEndRunnable
        )
        advanceAnimation(removedView, 0.5f)

        assertEquals(false, runnableRun)
    }

    @Test
    @Test
    fun cleansUpListenersCorrectly() {
    fun cleansUpListenersCorrectly() {
        val firstChild = View(mContext)
        val firstChild = View(mContext)
+36 −2
Original line number Original line Diff line number Diff line
@@ -17,6 +17,7 @@
package com.android.systemui.media.taptotransfer.sender
package com.android.systemui.media.taptotransfer.sender


import android.app.StatusBarManager
import android.app.StatusBarManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.graphics.drawable.Drawable
@@ -37,9 +38,11 @@ import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.SysuiTestCase
import com.android.systemui.classifier.FalsingCollector
import com.android.systemui.classifier.FalsingCollector
import com.android.systemui.media.taptotransfer.common.MediaTttLogger
import com.android.systemui.media.taptotransfer.common.MediaTttLogger
import com.android.systemui.media.taptotransfer.receiver.MediaTttReceiverLogger
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.util.concurrency.DelayableExecutor
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.eq
@@ -61,7 +64,7 @@ import org.mockito.MockitoAnnotations
@RunWith(AndroidTestingRunner::class)
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
@TestableLooper.RunWithLooper
class MediaTttChipControllerSenderTest : SysuiTestCase() {
class MediaTttChipControllerSenderTest : SysuiTestCase() {
    private lateinit var controllerSender: MediaTttChipControllerSender
    private lateinit var controllerSender: TestMediaTttChipControllerSender


    @Mock
    @Mock
    private lateinit var packageManager: PackageManager
    private lateinit var packageManager: PackageManager
@@ -116,7 +119,7 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
        whenever(lazyFalsingManager.get()).thenReturn(falsingManager)
        whenever(lazyFalsingManager.get()).thenReturn(falsingManager)
        whenever(lazyFalsingCollector.get()).thenReturn(falsingCollector)
        whenever(lazyFalsingCollector.get()).thenReturn(falsingCollector)


        controllerSender = MediaTttChipControllerSender(
        controllerSender = TestMediaTttChipControllerSender(
            commandQueue,
            commandQueue,
            context,
            context,
            logger,
            logger,
@@ -821,6 +824,37 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
    /** Helper method providing default parameters to not clutter up the tests. */
    /** Helper method providing default parameters to not clutter up the tests. */
    private fun transferToThisDeviceFailed() =
    private fun transferToThisDeviceFailed() =
        ChipSenderInfo(ChipStateSender.TRANSFER_TO_RECEIVER_FAILED, routeInfo)
        ChipSenderInfo(ChipStateSender.TRANSFER_TO_RECEIVER_FAILED, routeInfo)

    private class TestMediaTttChipControllerSender(
        commandQueue: CommandQueue,
        context: Context,
        @MediaTttReceiverLogger logger: MediaTttLogger,
        windowManager: WindowManager,
        mainExecutor: DelayableExecutor,
        accessibilityManager: AccessibilityManager,
        configurationController: ConfigurationController,
        powerManager: PowerManager,
        uiEventLogger: MediaTttSenderUiEventLogger,
        falsingManager: Lazy<FalsingManager>,
        falsingCollector: Lazy<FalsingCollector>,
    ) : MediaTttChipControllerSender(
        commandQueue,
        context,
        logger,
        windowManager,
        mainExecutor,
        accessibilityManager,
        configurationController,
        powerManager,
        uiEventLogger,
        falsingManager,
        falsingCollector,
    ) {
        override fun animateViewOut(view: ViewGroup, onAnimationEnd: Runnable) {
            // Just bypass the animation in tests
            onAnimationEnd.run()
        }
    }
}
}


private const val APP_NAME = "Fake app name"
private const val APP_NAME = "Fake app name"