[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
This commit is contained in:
@@ -361,13 +361,17 @@ class ViewHierarchyAnimator {
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @param onAnimationEnd an optional runnable that will be run once the animation finishes
|
||||
* successfully. Will not be run if the animation is cancelled.
|
||||
*/
|
||||
@JvmOverloads
|
||||
fun animateRemoval(
|
||||
rootView: View,
|
||||
destination: Hotspot = Hotspot.CENTER,
|
||||
interpolator: Interpolator = DEFAULT_REMOVAL_INTERPOLATOR,
|
||||
duration: Long = DEFAULT_DURATION
|
||||
duration: Long = DEFAULT_DURATION,
|
||||
onAnimationEnd: Runnable? = null,
|
||||
): Boolean {
|
||||
if (
|
||||
!occupiesSpace(
|
||||
@@ -391,13 +395,28 @@ class ViewHierarchyAnimator {
|
||||
addListener(child, listener, recursive = false)
|
||||
}
|
||||
|
||||
// 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.
|
||||
parent.removeView(rootView)
|
||||
// By adding the view to the overlay, we can animate it while it isn't part of the view
|
||||
// hierarchy. It is correctly positioned because we have its previous bounds, and we set
|
||||
// them manually during the animation.
|
||||
parent.overlay.add(rootView)
|
||||
val viewHasSiblings = parent.childCount > 1
|
||||
if (viewHasSiblings) {
|
||||
// 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.
|
||||
parent.removeView(rootView)
|
||||
// By adding the view to the overlay, we can animate it while it isn't part of the
|
||||
// view hierarchy. It is correctly positioned because we have its previous bounds,
|
||||
// and we set them manually during the animation.
|
||||
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 =
|
||||
mapOf(
|
||||
@@ -430,7 +449,8 @@ class ViewHierarchyAnimator {
|
||||
endValues,
|
||||
interpolator,
|
||||
duration,
|
||||
ephemeral = true
|
||||
ephemeral = true,
|
||||
endRunnable,
|
||||
)
|
||||
|
||||
if (rootView is ViewGroup) {
|
||||
@@ -463,7 +483,6 @@ class ViewHierarchyAnimator {
|
||||
.alpha(0f)
|
||||
.setInterpolator(Interpolators.ALPHA_OUT)
|
||||
.setDuration(duration / 2)
|
||||
.withEndAction { parent.overlay.remove(rootView) }
|
||||
.start()
|
||||
}
|
||||
}
|
||||
@@ -477,7 +496,6 @@ class ViewHierarchyAnimator {
|
||||
.setInterpolator(Interpolators.ALPHA_OUT)
|
||||
.setDuration(duration / 2)
|
||||
.setStartDelay(duration / 2)
|
||||
.withEndAction { parent.overlay.remove(rootView) }
|
||||
.start()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@SysUISingleton
|
||||
class MediaTttChipControllerSender @Inject constructor(
|
||||
open class MediaTttChipControllerSender @Inject constructor(
|
||||
commandQueue: CommandQueue,
|
||||
context: Context,
|
||||
@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 {
|
||||
// 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.)
|
||||
|
||||
@@ -171,11 +171,15 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
|
||||
if (shouldIgnoreViewRemoval(removalReason)) {
|
||||
return
|
||||
}
|
||||
val currentView = view ?: return
|
||||
|
||||
animateViewOut(currentView) { windowManager.removeView(currentView) }
|
||||
|
||||
if (view == null) { return }
|
||||
logger.logChipRemoval(removalReason)
|
||||
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
|
||||
info = null
|
||||
// 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
|
||||
* 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 {
|
||||
|
||||
@@ -718,7 +718,7 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun animatesViewRemovalFromStartToEnd() {
|
||||
fun animatesViewRemovalFromStartToEnd_viewHasSiblings() {
|
||||
setUpRootWithChildren()
|
||||
|
||||
val child = rootView.getChildAt(0)
|
||||
@@ -741,6 +741,35 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
|
||||
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
|
||||
fun animatesViewRemovalRespectingDestination() {
|
||||
// CENTER
|
||||
@@ -963,6 +992,60 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
|
||||
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
|
||||
fun cleansUpListenersCorrectly() {
|
||||
val firstChild = View(mContext)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package com.android.systemui.media.taptotransfer.sender
|
||||
|
||||
import android.app.StatusBarManager
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -37,9 +38,11 @@ import com.android.systemui.R
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.classifier.FalsingCollector
|
||||
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.statusbar.CommandQueue
|
||||
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.mockito.any
|
||||
import com.android.systemui.util.mockito.eq
|
||||
@@ -61,7 +64,7 @@ import org.mockito.MockitoAnnotations
|
||||
@RunWith(AndroidTestingRunner::class)
|
||||
@TestableLooper.RunWithLooper
|
||||
class MediaTttChipControllerSenderTest : SysuiTestCase() {
|
||||
private lateinit var controllerSender: MediaTttChipControllerSender
|
||||
private lateinit var controllerSender: TestMediaTttChipControllerSender
|
||||
|
||||
@Mock
|
||||
private lateinit var packageManager: PackageManager
|
||||
@@ -116,7 +119,7 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
|
||||
whenever(lazyFalsingManager.get()).thenReturn(falsingManager)
|
||||
whenever(lazyFalsingCollector.get()).thenReturn(falsingCollector)
|
||||
|
||||
controllerSender = MediaTttChipControllerSender(
|
||||
controllerSender = TestMediaTttChipControllerSender(
|
||||
commandQueue,
|
||||
context,
|
||||
logger,
|
||||
@@ -821,6 +824,37 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
|
||||
/** Helper method providing default parameters to not clutter up the tests. */
|
||||
private fun transferToThisDeviceFailed() =
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user