[Media TTT] Animate the chip in.

This includes updating ViewHierarchyAnimator to also handle fading in
during #animateAddition (if requested).

Bug: 203800644
Test: manual: See fast + slow animations of the chip attached to the
bug.
Test: ViewHierarchyAnimatorTest

Change-Id: Iedf29695b187eaeddc8734d326ecf08a0ad63dcb
This commit is contained in:
Caitlin Cassidy
2022-05-16 20:41:46 +00:00
parent 38615a5ac2
commit 9f1fc82e18
7 changed files with 323 additions and 66 deletions

View File

@@ -21,6 +21,7 @@
<!-- ViewBoundsAnimator -->
<item type="id" name="tag_animator"/>
<item type="id" name="tag_alpha_animator"/>
<item type="id" name="tag_layout_listener"/>
<item type="id" name="tag_override_bottom"/>
<item type="id" name="tag_override_left"/>

View File

@@ -39,6 +39,7 @@ class ViewHierarchyAnimator {
private val DEFAULT_INTERPOLATOR = Interpolators.STANDARD
private val DEFAULT_ADDITION_INTERPOLATOR = Interpolators.STANDARD_DECELERATE
private val DEFAULT_REMOVAL_INTERPOLATOR = Interpolators.STANDARD_ACCELERATE
private val DEFAULT_FADE_IN_INTERPOLATOR = Interpolators.ALPHA_IN
/** The properties used to animate the view bounds. */
private val PROPERTIES = mapOf(
@@ -162,6 +163,10 @@ class ViewHierarchyAnimator {
* animate an already visible view, see [animate] and [animateNextUpdate].
*
* Then animator unregisters itself once the first addition animation is complete.
*
* @param includeFadeIn true if the animator should also fade in the view and child views.
* @param fadeInInterpolator the interpolator to use when fading in the view. Unused if
* [includeFadeIn] is false.
*/
@JvmOverloads
fun animateAddition(
@@ -169,7 +174,9 @@ class ViewHierarchyAnimator {
origin: Hotspot = Hotspot.CENTER,
interpolator: Interpolator = DEFAULT_ADDITION_INTERPOLATOR,
duration: Long = DEFAULT_DURATION,
includeMargins: Boolean = false
includeMargins: Boolean = false,
includeFadeIn: Boolean = false,
fadeInInterpolator: Interpolator = DEFAULT_FADE_IN_INTERPOLATOR
): Boolean {
if (isVisible(
rootView.visibility,
@@ -186,6 +193,42 @@ class ViewHierarchyAnimator {
origin, interpolator, duration, ignorePreviousValues = !includeMargins
)
addListener(rootView, listener, recursive = true)
if (!includeFadeIn) {
return true
}
if (rootView is ViewGroup) {
// First, fade in the container view
val containerDuration = duration / 6
createAndStartFadeInAnimator(
rootView, containerDuration, startDelay = 0, interpolator = fadeInInterpolator
)
// Then, fade in the child views
val childDuration = duration / 3
for (i in 0 until rootView.childCount) {
val view = rootView.getChildAt(i)
createAndStartFadeInAnimator(
view,
childDuration,
// Wait until the container fades in before fading in the children
startDelay = containerDuration,
interpolator = fadeInInterpolator
)
}
// For now, we don't recursively fade in additional sub views (e.g. grandchild
// views) since it hasn't been necessary, but we could add that functionality.
} else {
// Fade in the view during the first half of the addition
createAndStartFadeInAnimator(
rootView,
duration / 2,
startDelay = 0,
interpolator = fadeInInterpolator
)
}
return true
}
@@ -834,6 +877,27 @@ class ViewHierarchyAnimator {
view.setTag(R.id.tag_animator, animator)
animator.start()
}
private fun createAndStartFadeInAnimator(
view: View,
duration: Long,
startDelay: Long,
interpolator: Interpolator
) {
val animator = ObjectAnimator.ofFloat(view, "alpha", 1f)
animator.startDelay = startDelay
animator.duration = duration
animator.interpolator = interpolator
animator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
view.setTag(R.id.tag_alpha_animator, null /* tag */)
}
})
(view.getTag(R.id.tag_alpha_animator) as? ObjectAnimator)?.cancel()
view.setTag(R.id.tag_alpha_animator, animator)
animator.start()
}
}
/** An enum used to determine the origin of addition animations. */

View File

@@ -13,71 +13,85 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<LinearLayout
<!-- Wrap in a frame layout so that we can update the margins on the inner layout. (Since this view
is the root view of a window, we cannot change the root view's margins.) -->
<!-- Alphas start as 0 because the view will be animated in. -->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:id="@+id/media_ttt_sender_chip"
android:orientation="horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="@dimen/media_ttt_chip_outer_padding"
android:background="@drawable/media_ttt_chip_background"
android:layout_marginTop="50dp"
android:clipToPadding="false"
android:gravity="center_vertical"
>
android:layout_height="wrap_content">
<com.android.internal.widget.CachingIconView
android:id="@+id/app_icon"
android:layout_width="@dimen/media_ttt_app_icon_size"
android:layout_height="@dimen/media_ttt_app_icon_size"
android:layout_marginEnd="12dp"
/>
<TextView
android:id="@+id/text"
<LinearLayout
android:id="@+id/media_ttt_sender_chip_inner"
android:orientation="horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="@dimen/media_ttt_text_size"
android:textColor="?android:attr/textColorPrimary"
/>
android:padding="@dimen/media_ttt_chip_outer_padding"
android:background="@drawable/media_ttt_chip_background"
android:layout_marginTop="20dp"
android:clipToPadding="false"
android:gravity="center_vertical"
android:alpha="0.0"
>
<!-- At most one of [loading, failure_icon, undo] will be visible at a time. -->
<com.android.internal.widget.CachingIconView
android:id="@+id/app_icon"
android:layout_width="@dimen/media_ttt_app_icon_size"
android:layout_height="@dimen/media_ttt_app_icon_size"
android:layout_marginEnd="12dp"
android:alpha="0.0"
/>
<ProgressBar
android:id="@+id/loading"
android:indeterminate="true"
android:layout_width="@dimen/media_ttt_status_icon_size"
android:layout_height="@dimen/media_ttt_status_icon_size"
android:layout_marginStart="@dimen/media_ttt_last_item_start_margin"
android:indeterminateTint="?androidprv:attr/colorAccentPrimaryVariant"
style="?android:attr/progressBarStyleSmall"
/>
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="@dimen/media_ttt_text_size"
android:textColor="?android:attr/textColorPrimary"
android:alpha="0.0"
/>
<ImageView
android:id="@+id/failure_icon"
android:layout_width="@dimen/media_ttt_status_icon_size"
android:layout_height="@dimen/media_ttt_status_icon_size"
android:layout_marginStart="@dimen/media_ttt_last_item_start_margin"
android:src="@drawable/ic_warning"
android:tint="@color/GM2_red_500"
/>
<!-- At most one of [loading, failure_icon, undo] will be visible at a time. -->
<ProgressBar
android:id="@+id/loading"
android:indeterminate="true"
android:layout_width="@dimen/media_ttt_status_icon_size"
android:layout_height="@dimen/media_ttt_status_icon_size"
android:layout_marginStart="@dimen/media_ttt_last_item_start_margin"
android:indeterminateTint="?androidprv:attr/colorAccentPrimaryVariant"
style="?android:attr/progressBarStyleSmall"
android:alpha="0.0"
/>
<TextView
android:id="@+id/undo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/media_transfer_undo"
android:textColor="?androidprv:attr/textColorOnAccent"
android:layout_marginStart="@dimen/media_ttt_last_item_start_margin"
android:textSize="@dimen/media_ttt_text_size"
android:paddingStart="@dimen/media_ttt_chip_outer_padding"
android:paddingEnd="@dimen/media_ttt_chip_outer_padding"
android:paddingTop="@dimen/media_ttt_undo_button_vertical_padding"
android:paddingBottom="@dimen/media_ttt_undo_button_vertical_padding"
android:layout_marginTop="@dimen/media_ttt_undo_button_vertical_negative_margin"
android:layout_marginBottom="@dimen/media_ttt_undo_button_vertical_negative_margin"
android:background="@drawable/media_ttt_undo_background"
/>
<ImageView
android:id="@+id/failure_icon"
android:layout_width="@dimen/media_ttt_status_icon_size"
android:layout_height="@dimen/media_ttt_status_icon_size"
android:layout_marginStart="@dimen/media_ttt_last_item_start_margin"
android:src="@drawable/ic_warning"
android:tint="@color/GM2_red_500"
android:alpha="0.0"
/>
</LinearLayout>
<TextView
android:id="@+id/undo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/media_transfer_undo"
android:textColor="?androidprv:attr/textColorOnAccent"
android:layout_marginStart="@dimen/media_ttt_last_item_start_margin"
android:textSize="@dimen/media_ttt_text_size"
android:paddingStart="@dimen/media_ttt_chip_outer_padding"
android:paddingEnd="@dimen/media_ttt_chip_outer_padding"
android:paddingTop="@dimen/media_ttt_undo_button_vertical_padding"
android:paddingBottom="@dimen/media_ttt_undo_button_vertical_padding"
android:layout_marginTop="@dimen/media_ttt_undo_button_vertical_negative_margin"
android:layout_marginBottom="@dimen/media_ttt_undo_button_vertical_negative_margin"
android:background="@drawable/media_ttt_undo_background"
android:alpha="0.0"
/>
</LinearLayout>
</FrameLayout>

View File

@@ -106,6 +106,7 @@ abstract class MediaTttChipControllerCommon<T : ChipInfoCommon>(
PowerManager.WAKE_REASON_APPLICATION,
"com.android.systemui:media_tap_to_transfer_activated"
)
animateChipIn(currentChipView)
}
// Cancel and re-set the chip timeout each time we get a new state.
@@ -137,6 +138,12 @@ abstract class MediaTttChipControllerCommon<T : ChipInfoCommon>(
*/
abstract fun updateChipView(chipInfo: T, currentChipView: ViewGroup)
/**
* A method that can be implemented by subclcasses to do custom animations for when the chip
* appears.
*/
open fun animateChipIn(chipView: ViewGroup) {}
/**
* Returns the size that the icon should be, or null if no size override is needed.
*/

View File

@@ -27,6 +27,8 @@ import android.view.WindowManager
import android.widget.TextView
import com.android.internal.statusbar.IUndoMediaTransferCallback
import com.android.systemui.R
import com.android.systemui.animation.Interpolators
import com.android.systemui.animation.ViewHierarchyAnimator
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.media.taptotransfer.common.ChipInfoCommon
@@ -124,7 +126,6 @@ class MediaTttChipControllerSender @Inject constructor(
currentChipView.requireViewById<View>(R.id.loading).visibility =
chipState.isMidTransfer.visibleIfTrue()
// Undo
val undoView = currentChipView.requireViewById<View>(R.id.undo)
val undoClickListener = chipState.undoClickListener(
@@ -138,6 +139,17 @@ class MediaTttChipControllerSender @Inject constructor(
chipState.isTransferFailure.visibleIfTrue()
}
override fun animateChipIn(chipView: ViewGroup) {
ViewHierarchyAnimator.animateAddition(
chipView.requireViewById<ViewGroup>(R.id.media_ttt_sender_chip_inner),
ViewHierarchyAnimator.Hotspot.TOP,
Interpolators.EMPHASIZED_DECELERATE,
duration = 500L,
includeMargins = true,
includeFadeIn = true,
)
}
override fun removeChip(removalReason: String) {
// Don't remove the chip if we're mid-transfer since the user should still be able to
// see the status of the transfer. (But do remove it if it's finally timed out.)

View File

@@ -520,6 +520,145 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
endAnimation(rootView)
}
@Test
fun animatesAppearingViewsFadeIn_alphaStartsAtZero_endsAtOne() {
rootView.alpha = 0f
ViewHierarchyAnimator.animateAddition(rootView, includeFadeIn = true)
rootView.layout(50 /* l */, 50 /* t */, 100 /* r */, 100 /* b */)
advanceFadeInAnimation(rootView, fraction = 1f)
endFadeInAnimation(rootView)
assertNull(rootView.getTag(R.id.tag_alpha_animator))
assertEquals(1f, rootView.alpha)
}
@Test
fun animatesAppearingViewsFadeIn_alphaStartsAboveZero_endsAtOne() {
rootView.alpha = 0.2f
ViewHierarchyAnimator.animateAddition(rootView, includeFadeIn = true)
rootView.layout(50 /* l */, 50 /* t */, 100 /* r */, 100 /* b */)
advanceFadeInAnimation(rootView, fraction = 1f)
endFadeInAnimation(rootView)
assertNull(rootView.getTag(R.id.tag_alpha_animator))
assertEquals(1f, rootView.alpha)
}
@Test
fun animatesAppearingViewsFadeIn_alphaStartsAsZero_alphaUpdatedMidAnimation() {
rootView.alpha = 0f
ViewHierarchyAnimator.animateAddition(
rootView,
includeFadeIn = true,
fadeInInterpolator = Interpolators.LINEAR
)
rootView.layout(50 /* l */, 50 /* t */, 100 /* r */, 100 /* b */)
advanceFadeInAnimation(rootView, fraction = 0.42f)
assertEquals(0.42f, rootView.alpha)
}
@Test
fun animatesAppearingViewsFadeIn_alphaStartsAboveZero_alphaUpdatedMidAnimation() {
rootView.alpha = 0.6f
ViewHierarchyAnimator.animateAddition(
rootView,
includeFadeIn = true,
fadeInInterpolator = Interpolators.LINEAR
)
rootView.layout(50 /* l */, 50 /* t */, 100 /* r */, 100 /* b */)
advanceFadeInAnimation(rootView, fraction = 0.5f)
assertEquals(0.8f, rootView.alpha)
}
@Test
fun animatesAppearingViewsFadeIn_childViewAlphasAlsoAnimated() {
rootView.alpha = 0f
val firstChild = View(context)
firstChild.alpha = 0f
val secondChild = View(context)
secondChild.alpha = 0f
rootView.addView(firstChild)
rootView.addView(secondChild)
ViewHierarchyAnimator.animateAddition(
rootView,
includeFadeIn = true,
fadeInInterpolator = Interpolators.LINEAR
)
rootView.layout(50 /* l */, 50 /* t */, 100 /* r */, 100 /* b */)
advanceFadeInAnimation(rootView, fraction = 0.5f)
assertEquals(0.5f, rootView.alpha)
assertEquals(0.5f, firstChild.alpha)
assertEquals(0.5f, secondChild.alpha)
}
@Test
fun animatesAppearingViewsFadeIn_animatesFromPreviousAnimationProgress() {
rootView.alpha = 0f
ViewHierarchyAnimator.animateAddition(
rootView,
includeFadeIn = true,
fadeInInterpolator = Interpolators.LINEAR
)
rootView.layout(50 /* l */, 50 /* t */, 100 /* r */, 100 /* b */)
advanceFadeInAnimation(rootView, fraction = 0.5f)
assertEquals(0.5f, rootView.alpha)
assertNotNull(rootView.getTag(R.id.tag_alpha_animator))
// IF we request animation again
ViewHierarchyAnimator.animateAddition(
rootView,
includeFadeIn = true,
fadeInInterpolator = Interpolators.LINEAR
)
// THEN the alpha remains at its current value (it doesn't get reset to 0)
assertNotNull(rootView.getTag(R.id.tag_alpha_animator))
assertEquals(0.5f, rootView.alpha)
// IF we advance the new animation to the end
advanceFadeInAnimation(rootView, fraction = 1f)
endFadeInAnimation(rootView)
// THEN we still end at the correct value
assertNull(rootView.getTag(R.id.tag_alpha_animator))
assertEquals(1f, rootView.alpha)
}
@Test
fun animatesAppearingViews_fadeInFalse_alphasNotUpdated() {
rootView.alpha = 0.3f
val firstChild = View(context)
firstChild.alpha = 0.4f
val secondChild = View(context)
secondChild.alpha = 0.5f
rootView.addView(firstChild)
rootView.addView(secondChild)
ViewHierarchyAnimator.animateAddition(
rootView,
includeFadeIn = false,
fadeInInterpolator = Interpolators.LINEAR
)
rootView.layout(50 /* l */, 50 /* t */, 100 /* r */, 100 /* b */)
advanceFadeInAnimation(rootView, fraction = 1f)
assertEquals(0.3f, rootView.alpha)
assertEquals(0.4f, firstChild.alpha)
assertEquals(0.5f, secondChild.alpha)
}
@Test
fun animatesViewRemovalFromStartToEnd() {
setUpRootWithChildren()
@@ -1003,6 +1142,16 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
}
}
private fun advanceFadeInAnimation(rootView: View, fraction: Float) {
(rootView.getTag(R.id.tag_alpha_animator) as? ObjectAnimator)?.setCurrentFraction(fraction)
if (rootView is ViewGroup) {
for (i in 0 until rootView.childCount) {
advanceFadeInAnimation(rootView.getChildAt(i), fraction)
}
}
}
private fun endAnimation(rootView: View) {
(rootView.getTag(R.id.tag_animator) as? ObjectAnimator)?.end()
@@ -1012,4 +1161,14 @@ ViewHierarchyAnimatorTest : SysuiTestCase() {
}
}
}
private fun endFadeInAnimation(rootView: View) {
(rootView.getTag(R.id.tag_alpha_animator) as? ObjectAnimator)?.end()
if (rootView is ViewGroup) {
for (i in 0 until rootView.childCount) {
endFadeInAnimation(rootView.getChildAt(i))
}
}
}
}

View File

@@ -25,9 +25,9 @@ import android.os.PowerManager
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.test.filters.SmallTest
import com.android.internal.logging.testing.UiEventLoggerFake
@@ -620,22 +620,22 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
verify(windowManager).removeView(any())
}
private fun LinearLayout.getAppIconView() = this.requireViewById<ImageView>(R.id.app_icon)
private fun ViewGroup.getAppIconView() = this.requireViewById<ImageView>(R.id.app_icon)
private fun LinearLayout.getChipText(): String =
private fun ViewGroup.getChipText(): String =
(this.requireViewById<TextView>(R.id.text)).text as String
private fun LinearLayout.getLoadingIconVisibility(): Int =
private fun ViewGroup.getLoadingIconVisibility(): Int =
this.requireViewById<View>(R.id.loading).visibility
private fun LinearLayout.getUndoButton(): View = this.requireViewById(R.id.undo)
private fun ViewGroup.getUndoButton(): View = this.requireViewById(R.id.undo)
private fun LinearLayout.getFailureIcon(): View = this.requireViewById(R.id.failure_icon)
private fun ViewGroup.getFailureIcon(): View = this.requireViewById(R.id.failure_icon)
private fun getChipView(): LinearLayout {
private fun getChipView(): ViewGroup {
val viewCaptor = ArgumentCaptor.forClass(View::class.java)
verify(windowManager).addView(viewCaptor.capture(), any())
return viewCaptor.value as LinearLayout
return viewCaptor.value as ViewGroup
}
/** Helper method providing default parameters to not clutter up the tests. */