Merge changes I61bfb975,I2cb907c5,I13c60b3b into tm-qpr-dev

* changes:
  [Media TTT] Animate the sender chip out.
  [Chipbar] Define #shouldIgnoreViewRemoval as a specific API on the TemporaryViewDisplayController, instead of having subclasses just override #removeView.
  [Media TTT] Add the background back to the receiver chip.
This commit is contained in:
Caitlin Shkuratov
2022-10-07 13:49:59 +00:00
committed by Android (Google) Code Review
12 changed files with 249 additions and 130 deletions

View File

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

View File

@@ -29,6 +29,7 @@
<com.android.internal.widget.CachingIconView
android:id="@+id/app_icon"
android:background="@drawable/media_ttt_chip_background_receiver"
android:layout_width="@dimen/media_ttt_icon_size_receiver"
android:layout_height="@dimen/media_ttt_icon_size_receiver"
android:layout_gravity="center|bottom"

View File

@@ -1056,9 +1056,8 @@
<!-- Media tap-to-transfer chip for receiver device -->
<dimen name="media_ttt_chip_size_receiver">100dp</dimen>
<dimen name="media_ttt_icon_size_receiver">95dp</dimen>
<!-- Since the generic icon isn't circular, we need to scale it down so it still fits within
the circular chip. -->
<dimen name="media_ttt_generic_icon_size_receiver">70dp</dimen>
<!-- Add some padding for the generic icon so it doesn't go all the way to the border. -->
<dimen name="media_ttt_generic_icon_padding">12dp</dimen>
<dimen name="media_ttt_receiver_vert_translation">20dp</dimen>
<!-- Window magnification -->

View File

@@ -19,7 +19,6 @@ package com.android.systemui.media.taptotransfer.common
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import com.android.internal.widget.CachingIconView
import com.android.settingslib.Utils
import com.android.systemui.R
@@ -76,29 +75,6 @@ class MediaTttUtils {
isAppIcon = false
)
}
/**
* Sets an icon to be displayed by the given view.
*
* @param iconSize the size in pixels that the icon should be. If null, the size of
* [appIconView] will not be adjusted.
*/
fun setIcon(
appIconView: CachingIconView,
icon: Drawable,
iconContentDescription: CharSequence,
iconSize: Int? = null,
) {
iconSize?.let { size ->
val lp = appIconView.layoutParams
lp.width = size
lp.height = size
appIconView.layoutParams = lp
}
appIconView.contentDescription = iconContentDescription
appIconView.setImageDrawable(icon)
}
}
}

View File

@@ -30,6 +30,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import com.android.internal.widget.CachingIconView
import com.android.settingslib.Utils
import com.android.systemui.R
import com.android.systemui.dagger.SysUISingleton
@@ -146,20 +147,17 @@ class MediaTttChipControllerReceiver @Inject constructor(
)
val iconDrawable = newInfo.appIconDrawableOverride ?: iconInfo.drawable
val iconContentDescription = newInfo.appNameOverride ?: iconInfo.contentDescription
val iconSize = context.resources.getDimensionPixelSize(
val iconPadding =
if (iconInfo.isAppIcon) {
R.dimen.media_ttt_icon_size_receiver
0
} else {
R.dimen.media_ttt_generic_icon_size_receiver
context.resources.getDimensionPixelSize(R.dimen.media_ttt_generic_icon_padding)
}
)
MediaTttUtils.setIcon(
currentView.requireViewById(R.id.app_icon),
iconDrawable,
iconContentDescription,
iconSize,
)
val iconView = currentView.requireViewById<CachingIconView>(R.id.app_icon)
iconView.setPadding(iconPadding, iconPadding, iconPadding, iconPadding)
iconView.setImageDrawable(iconDrawable)
iconView.contentDescription = iconContentDescription
}
override fun animateViewIn(view: ViewGroup) {

View File

@@ -29,6 +29,7 @@ import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.TextView
import com.android.internal.statusbar.IUndoMediaTransferCallback
import com.android.internal.widget.CachingIconView
import com.android.systemui.Gefingerpoken
import com.android.systemui.R
import com.android.systemui.animation.Interpolators
@@ -53,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,
@@ -145,11 +146,9 @@ class MediaTttChipControllerSender @Inject constructor(
val iconInfo = MediaTttUtils.getIconInfoFromPackageName(
context, newInfo.routeInfo.clientPackageName, logger
)
MediaTttUtils.setIcon(
currentView.requireViewById(R.id.app_icon),
iconInfo.drawable,
iconInfo.contentDescription
)
val iconView = currentView.requireViewById<CachingIconView>(R.id.app_icon)
iconView.setImageDrawable(iconInfo.drawable)
iconView.contentDescription = iconInfo.contentDescription
// Text
val otherDeviceName = newInfo.routeInfo.name.toString()
@@ -196,7 +195,19 @@ class MediaTttChipControllerSender @Inject constructor(
)
}
override fun removeView(removalReason: String) {
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.)
val transferStatus = info?.state?.transferStatus
@@ -208,9 +219,9 @@ class MediaTttChipControllerSender @Inject constructor(
logger.logRemovalBypass(
removalReason, bypassReason = "transferStatus=${transferStatus.name}"
)
return
return true
}
super.removeView(removalReason)
return false
}
private fun Boolean.visibleIfTrue(): Int {

View File

@@ -167,17 +167,32 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
* @param removalReason a short string describing why the view was removed (timeout, state
* change, etc.)
*/
open fun removeView(removalReason: String) {
if (view == null) { return }
fun removeView(removalReason: String) {
if (shouldIgnoreViewRemoval(removalReason)) {
return
}
val currentView = view ?: return
animateViewOut(currentView) { windowManager.removeView(currentView) }
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
cancelViewTimeout?.run()
}
/**
* Returns true if a view removal request should be ignored and false otherwise.
*
* Allows subclasses to keep the view visible for longer in certain circumstances.
*/
open fun shouldIgnoreViewRemoval(removalReason: String): Boolean = false
/**
* A method implemented by subclasses to update [currentView] based on [newInfo].
*/
@@ -190,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 {

View File

@@ -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)

View File

@@ -19,9 +19,7 @@ package com.android.systemui.media.taptotransfer.common
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.widget.FrameLayout
import androidx.test.filters.SmallTest
import com.android.internal.widget.CachingIconView
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.mockito.any
@@ -90,48 +88,6 @@ class MediaTttUtilsTest : SysuiTestCase() {
assertThat(iconInfo.drawable).isEqualTo(appIconFromPackageName)
assertThat(iconInfo.contentDescription).isEqualTo(APP_NAME)
}
@Test
fun setIcon_viewHasIconAndContentDescription() {
val view = CachingIconView(context)
val icon = context.getDrawable(R.drawable.ic_celebration)!!
val contentDescription = "Happy birthday!"
MediaTttUtils.setIcon(view, icon, contentDescription)
assertThat(view.drawable).isEqualTo(icon)
assertThat(view.contentDescription).isEqualTo(contentDescription)
}
@Test
fun setIcon_iconSizeNull_viewSizeDoesNotChange() {
val view = CachingIconView(context)
val size = 456
view.layoutParams = FrameLayout.LayoutParams(size, size)
MediaTttUtils.setIcon(view, context.getDrawable(R.drawable.ic_cake)!!, "desc")
assertThat(view.layoutParams.width).isEqualTo(size)
assertThat(view.layoutParams.height).isEqualTo(size)
}
@Test
fun setIcon_iconSizeProvided_viewSizeUpdates() {
val view = CachingIconView(context)
val size = 456
view.layoutParams = FrameLayout.LayoutParams(size, size)
val newSize = 40
MediaTttUtils.setIcon(
view,
context.getDrawable(R.drawable.ic_cake)!!,
"desc",
iconSize = newSize
)
assertThat(view.layoutParams.width).isEqualTo(newSize)
assertThat(view.layoutParams.height).isEqualTo(newSize)
}
}
private const val PACKAGE_NAME = "com.android.systemui"

View File

@@ -212,35 +212,27 @@ class MediaTttChipControllerReceiverTest : SysuiTestCase() {
}
@Test
fun updateView_isAppIcon_usesAppIconSize() {
fun updateView_isAppIcon_usesAppIconPadding() {
controllerReceiver.displayView(getChipReceiverInfo(packageName = PACKAGE_NAME))
val chipView = getChipView()
chipView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val expectedSize =
context.resources.getDimensionPixelSize(R.dimen.media_ttt_icon_size_receiver)
assertThat(chipView.getAppIconView().measuredWidth).isEqualTo(expectedSize)
assertThat(chipView.getAppIconView().measuredHeight).isEqualTo(expectedSize)
assertThat(chipView.getAppIconView().paddingLeft).isEqualTo(0)
assertThat(chipView.getAppIconView().paddingRight).isEqualTo(0)
assertThat(chipView.getAppIconView().paddingTop).isEqualTo(0)
assertThat(chipView.getAppIconView().paddingBottom).isEqualTo(0)
}
@Test
fun updateView_notAppIcon_usesGenericIconSize() {
fun updateView_notAppIcon_usesGenericIconPadding() {
controllerReceiver.displayView(getChipReceiverInfo(packageName = null))
val chipView = getChipView()
chipView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val expectedSize =
context.resources.getDimensionPixelSize(R.dimen.media_ttt_generic_icon_size_receiver)
assertThat(chipView.getAppIconView().measuredWidth).isEqualTo(expectedSize)
assertThat(chipView.getAppIconView().measuredHeight).isEqualTo(expectedSize)
val expectedPadding =
context.resources.getDimensionPixelSize(R.dimen.media_ttt_generic_icon_padding)
assertThat(chipView.getAppIconView().paddingLeft).isEqualTo(expectedPadding)
assertThat(chipView.getAppIconView().paddingRight).isEqualTo(expectedPadding)
assertThat(chipView.getAppIconView().paddingTop).isEqualTo(expectedPadding)
assertThat(chipView.getAppIconView().paddingBottom).isEqualTo(expectedPadding)
}
@Test

View File

@@ -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"

View File

@@ -61,6 +61,8 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() {
@Mock
private lateinit var powerManager: PowerManager
private var shouldIgnoreViewRemoval: Boolean = false
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
@@ -205,6 +207,26 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() {
verify(windowManager, never()).removeView(any())
}
@Test
fun removeView_shouldIgnoreRemovalFalse_viewRemoved() {
shouldIgnoreViewRemoval = false
underTest.displayView(getState())
underTest.removeView("reason")
verify(windowManager).removeView(any())
}
@Test
fun removeView_shouldIgnoreRemovalTrue_viewNotRemoved() {
shouldIgnoreViewRemoval = true
underTest.displayView(getState())
underTest.removeView("reason")
verify(windowManager, never()).removeView(any())
}
private fun getState(name: String = "name") = ViewInfo(name)
private fun getConfigurationListener(): ConfigurationListener {
@@ -240,6 +262,10 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() {
super.updateView(newInfo, currentView)
mostRecentViewInfo = newInfo
}
override fun shouldIgnoreViewRemoval(removalReason: String): Boolean {
return shouldIgnoreViewRemoval
}
}
inner class ViewInfo(val name: String) : TemporaryViewInfo {