diff --git a/packages/SystemUI/animation/res/values/ids.xml b/packages/SystemUI/animation/res/values/ids.xml
index ef60a248f79ad..4e9a4be0060ea 100644
--- a/packages/SystemUI/animation/res/values/ids.xml
+++ b/packages/SystemUI/animation/res/values/ids.xml
@@ -15,5 +15,14 @@
limitations under the License.
-->
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/ViewBoundAnimator.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/ViewBoundAnimator.kt
new file mode 100644
index 0000000000000..5593fdfe5f51d
--- /dev/null
+++ b/packages/SystemUI/animation/src/com/android/systemui/animation/ViewBoundAnimator.kt
@@ -0,0 +1,328 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.animation
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.ObjectAnimator
+import android.animation.PropertyValuesHolder
+import android.util.IntProperty
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.Interpolator
+
+/**
+ * A class that allows changes in bounds within a view hierarchy to animate seamlessly between the
+ * start and end state.
+ */
+class ViewBoundAnimator {
+ // TODO(b/221418522): make this private once it can't be passed as an arg anymore.
+ enum class Bound(val label: String, val overrideTag: Int) {
+ LEFT("left", R.id.tag_override_left) {
+ override fun setValue(view: View, value: Int) {
+ view.left = value
+ }
+
+ override fun getValue(view: View): Int {
+ return view.left
+ }
+ },
+ TOP("top", R.id.tag_override_top) {
+ override fun setValue(view: View, value: Int) {
+ view.top = value
+ }
+
+ override fun getValue(view: View): Int {
+ return view.top
+ }
+ },
+ RIGHT("right", R.id.tag_override_right) {
+ override fun setValue(view: View, value: Int) {
+ view.right = value
+ }
+
+ override fun getValue(view: View): Int {
+ return view.right
+ }
+ },
+ BOTTOM("bottom", R.id.tag_override_bottom) {
+ override fun setValue(view: View, value: Int) {
+ view.bottom = value
+ }
+
+ override fun getValue(view: View): Int {
+ return view.bottom
+ }
+ };
+
+ abstract fun setValue(view: View, value: Int)
+ abstract fun getValue(view: View): Int
+ }
+
+ companion object {
+ /** Default values for the animation. These can all be overridden at call time. */
+ private const val DEFAULT_DURATION = 500L
+ private val DEFAULT_INTERPOLATOR = Interpolators.EMPHASIZED
+ private val DEFAULT_BOUNDS = setOf(Bound.LEFT, Bound.TOP, Bound.RIGHT, Bound.BOTTOM)
+
+ /** The properties used to animate the view bounds. */
+ private val PROPERTIES = mapOf(
+ Bound.LEFT to createViewProperty(Bound.LEFT),
+ Bound.TOP to createViewProperty(Bound.TOP),
+ Bound.RIGHT to createViewProperty(Bound.RIGHT),
+ Bound.BOTTOM to createViewProperty(Bound.BOTTOM)
+ )
+
+ private fun createViewProperty(bound: Bound): IntProperty {
+ return object : IntProperty(bound.label) {
+ override fun setValue(view: View, value: Int) {
+ setBound(view, bound, value)
+ }
+
+ override fun get(view: View): Int {
+ return getBound(view, bound) ?: bound.getValue(view)
+ }
+ }
+ }
+
+ /**
+ * Instruct the animator to watch for changes to the layout of [rootView] and its children
+ * and animate them. The animation can be limited to a subset of [bounds]. It uses the
+ * given [interpolator] and [duration].
+ *
+ * If a new layout change happens while an animation is already in progress, the animation
+ * is updated to continue from the current values to the new end state.
+ *
+ * The animator continues to respond to layout changes until [stopAnimating] is called.
+ *
+ * Successive calls to this method override the previous settings ([interpolator] and
+ * [duration]). The changes take effect on the next animation.
+ *
+ * TODO(b/221418522): remove the ability to select which bounds to animate and always
+ * animate all of them.
+ */
+ @JvmOverloads
+ fun animate(
+ rootView: View,
+ bounds: Set = DEFAULT_BOUNDS,
+ interpolator: Interpolator = DEFAULT_INTERPOLATOR,
+ duration: Long = DEFAULT_DURATION
+ ) {
+ animate(rootView, bounds, interpolator, duration, false /* ephemeral */)
+ }
+
+ /**
+ * Like [animate], but only takes effect on the next layout update, then unregisters itself
+ * once the first animation is complete.
+ *
+ * TODO(b/221418522): remove the ability to select which bounds to animate and always
+ * animate all of them.
+ */
+ @JvmOverloads
+ fun animateNextUpdate(
+ rootView: View,
+ bounds: Set = DEFAULT_BOUNDS,
+ interpolator: Interpolator = DEFAULT_INTERPOLATOR,
+ duration: Long = DEFAULT_DURATION
+ ) {
+ animate(rootView, bounds, interpolator, duration, true /* ephemeral */)
+ }
+
+ private fun animate(
+ rootView: View,
+ bounds: Set,
+ interpolator: Interpolator,
+ duration: Long,
+ ephemeral: Boolean
+ ) {
+ val listener = createListener(bounds, interpolator, duration, ephemeral)
+ recursivelyAddListener(rootView, listener)
+ }
+
+ /**
+ * Instruct the animator to stop watching for changes to the layout of [rootView] and its
+ * children.
+ *
+ * Any animations already in progress continue until their natural conclusion.
+ */
+ fun stopAnimating(rootView: View) {
+ val listener = rootView.getTag(R.id.tag_layout_listener)
+ if (listener != null && listener is View.OnLayoutChangeListener) {
+ rootView.setTag(R.id.tag_layout_listener, null /* tag */)
+ rootView.removeOnLayoutChangeListener(listener)
+ }
+
+ if (rootView is ViewGroup) {
+ for (i in 0 until rootView.childCount) {
+ stopAnimating(rootView.getChildAt(i))
+ }
+ }
+ }
+
+ private fun createListener(
+ bounds: Set,
+ interpolator: Interpolator,
+ duration: Long,
+ ephemeral: Boolean
+ ): View.OnLayoutChangeListener {
+ return object : View.OnLayoutChangeListener {
+ override fun onLayoutChange(
+ view: View?,
+ left: Int,
+ top: Int,
+ right: Int,
+ bottom: Int,
+ oldLeft: Int,
+ oldTop: Int,
+ oldRight: Int,
+ oldBottom: Int
+ ) {
+ if (view == null) return
+
+ val startLeft = getBound(view, Bound.LEFT) ?: oldLeft
+ val startTop = getBound(view, Bound.TOP) ?: oldTop
+ val startRight = getBound(view, Bound.RIGHT) ?: oldRight
+ val startBottom = getBound(view, Bound.BOTTOM) ?: oldBottom
+
+ (view.getTag(R.id.tag_animator) as? ObjectAnimator)?.cancel()
+
+ if (view.visibility == View.GONE || view.visibility == View.INVISIBLE) {
+ setBound(view, Bound.LEFT, left)
+ setBound(view, Bound.TOP, top)
+ setBound(view, Bound.RIGHT, right)
+ setBound(view, Bound.BOTTOM, bottom)
+ return
+ }
+
+ val startValues = mapOf(
+ Bound.LEFT to startLeft,
+ Bound.TOP to startTop,
+ Bound.RIGHT to startRight,
+ Bound.BOTTOM to startBottom
+ )
+ val endValues = mapOf(
+ Bound.LEFT to left,
+ Bound.TOP to top,
+ Bound.RIGHT to right,
+ Bound.BOTTOM to bottom
+ )
+
+ val boundsToAnimate = bounds.toMutableSet()
+ if (left == startLeft) boundsToAnimate.remove(Bound.LEFT)
+ if (top == startTop) boundsToAnimate.remove(Bound.TOP)
+ if (right == startRight) boundsToAnimate.remove(Bound.RIGHT)
+ if (bottom == startBottom) boundsToAnimate.remove(Bound.BOTTOM)
+
+ if (boundsToAnimate.isNotEmpty()) {
+ startAnimation(
+ view,
+ boundsToAnimate,
+ startValues,
+ endValues,
+ interpolator,
+ duration,
+ ephemeral
+ )
+ }
+ }
+ }
+ }
+
+ private fun recursivelyAddListener(view: View, listener: View.OnLayoutChangeListener) {
+ // Make sure that only one listener is active at a time.
+ val oldListener = view.getTag(R.id.tag_layout_listener)
+ if (oldListener != null && oldListener is View.OnLayoutChangeListener) {
+ view.removeOnLayoutChangeListener(oldListener)
+ }
+
+ view.addOnLayoutChangeListener(listener)
+ view.setTag(R.id.tag_layout_listener, listener)
+ if (view is ViewGroup) {
+ for (i in 0 until view.childCount) {
+ recursivelyAddListener(view.getChildAt(i), listener)
+ }
+ }
+ }
+
+ private fun getBound(view: View, bound: Bound): Int? {
+ return view.getTag(bound.overrideTag) as? Int
+ }
+
+ private fun setBound(view: View, bound: Bound, value: Int) {
+ view.setTag(bound.overrideTag, value)
+ bound.setValue(view, value)
+ }
+
+ /**
+ * Initiates the animation of a single bound by creating the animator, registering it with
+ * the [view], and starting it. If [ephemeral], the layout change listener is unregistered
+ * at the end of the animation, so no more animations happen.
+ */
+ private fun startAnimation(
+ view: View,
+ bounds: Set,
+ startValues: Map,
+ endValues: Map,
+ interpolator: Interpolator,
+ duration: Long,
+ ephemeral: Boolean
+ ) {
+ val propertyValuesHolders = buildList {
+ bounds.forEach { bound ->
+ add(
+ PropertyValuesHolder.ofInt(
+ PROPERTIES[bound],
+ startValues.getValue(bound),
+ endValues.getValue(bound)
+ )
+ )
+ }
+ }.toTypedArray()
+
+ val animator = ObjectAnimator.ofPropertyValuesHolder(view, *propertyValuesHolders)
+ animator.interpolator = interpolator
+ animator.duration = duration
+ animator.addListener(object : AnimatorListenerAdapter() {
+ var cancelled = false
+
+ override fun onAnimationEnd(animation: Animator) {
+ view.setTag(R.id.tag_animator, null /* tag */)
+ bounds.forEach { view.setTag(it.overrideTag, null /* tag */) }
+
+ // When an animation is cancelled, a new one might be taking over. We shouldn't
+ // unregister the listener yet.
+ if (ephemeral && !cancelled) {
+ val listener = view.getTag(R.id.tag_layout_listener)
+ if (listener != null && listener is View.OnLayoutChangeListener) {
+ view.setTag(R.id.tag_layout_listener, null /* tag */)
+ view.removeOnLayoutChangeListener(listener)
+ }
+ }
+ }
+
+ override fun onAnimationCancel(animation: Animator?) {
+ cancelled = true
+ }
+ })
+
+ bounds.forEach { bound -> setBound(view, bound, startValues.getValue(bound)) }
+
+ view.setTag(R.id.tag_animator, animator)
+ animator.start()
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/ViewBoundAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/ViewBoundAnimatorTest.kt
new file mode 100644
index 0000000000000..214fd4d283987
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/animation/ViewBoundAnimatorTest.kt
@@ -0,0 +1,277 @@
+package com.android.systemui.animation
+
+import android.animation.ObjectAnimator
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import junit.framework.Assert.assertEquals
+import junit.framework.Assert.assertNotNull
+import junit.framework.Assert.assertNull
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper
+class ViewBoundAnimatorTest : SysuiTestCase() {
+ companion object {
+ private const val TEST_DURATION = 1000L
+ private val TEST_INTERPOLATOR = Interpolators.LINEAR
+ }
+
+ private lateinit var rootView: LinearLayout
+
+ @Before
+ fun setUp() {
+ rootView = LinearLayout(mContext)
+ }
+
+ @After
+ fun tearDown() {
+ ViewBoundAnimator.stopAnimating(rootView)
+ }
+
+ @Test
+ fun respectsAnimationParameters() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animate(
+ rootView, interpolator = TEST_INTERPOLATOR, duration = TEST_DURATION
+ )
+ rootView.layout(0 /* l */, 0 /* t */, 100 /* r */, 100 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ val animator = rootView.getTag(R.id.tag_animator) as ObjectAnimator
+ assertEquals(animator.interpolator, TEST_INTERPOLATOR)
+ assertEquals(animator.duration, TEST_DURATION)
+ }
+
+ @Test
+ fun animatesFromStartToEnd() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animate(rootView)
+ // Change all bounds.
+ rootView.layout(0 /* l */, 15 /* t */, 70 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ // The initial values should be those of the previous layout.
+ checkBounds(rootView, l = 10, t = 10, r = 50, b = 50)
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ // The end values should be those of the latest layout.
+ checkBounds(rootView, l = 0, t = 15, r = 70, b = 80)
+ }
+
+ @Test
+ fun animatesSuccessiveLayoutChanges() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animate(rootView)
+ // Change all bounds.
+ rootView.layout(0 /* l */, 15 /* t */, 70 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 0, t = 15, r = 70, b = 80)
+
+ // Change only top and right.
+ rootView.layout(0 /* l */, 20 /* t */, 60 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 0, t = 20, r = 60, b = 80)
+
+ // Change all bounds again.
+ rootView.layout(5 /* l */, 25 /* t */, 55 /* r */, 95 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 5, t = 25, r = 55, b = 95)
+ }
+
+ @Test
+ fun animatesFromPreviousAnimationProgress() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animateNextUpdate(rootView, interpolator = TEST_INTERPOLATOR)
+ // Change all bounds.
+ rootView.layout(0 /* l */, 20 /* t */, 70 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ advanceAnimation(rootView, fraction = 0.5f)
+ checkBounds(rootView, l = 5, t = 15, r = 60, b = 65)
+
+ // Change all bounds again.
+ rootView.layout(25 /* l */, 25 /* t */, 55 /* r */, 60 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 5, t = 15, r = 60, b = 65)
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 25, t = 25, r = 55, b = 60)
+ }
+
+ @Test
+ fun animatesRootAndChildren() {
+ val firstChild = View(mContext)
+ rootView.addView(firstChild)
+ val secondChild = View(mContext)
+ rootView.addView(secondChild)
+ rootView.layout(0 /* l */, 0 /* t */, 150 /* r */, 100 /* b */)
+ firstChild.layout(0 /* l */, 0 /* t */, 100 /* r */, 100 /* b */)
+ secondChild.layout(100 /* l */, 0 /* t */, 150 /* r */, 100 /* b */)
+
+ ViewBoundAnimator.animate(rootView)
+ // Change all bounds.
+ rootView.layout(10 /* l */, 20 /* t */, 200 /* r */, 120 /* b */)
+ firstChild.layout(10 /* l */, 20 /* t */, 150 /* r */, 120 /* b */)
+ secondChild.layout(150 /* l */, 20 /* t */, 200 /* r */, 120 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ // The initial values should be those of the previous layout.
+ checkBounds(rootView, l = 0, t = 0, r = 150, b = 100)
+ checkBounds(firstChild, l = 0, t = 0, r = 100, b = 100)
+ checkBounds(secondChild, l = 100, t = 0, r = 150, b = 100)
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ // The end values should be those of the latest layout.
+ checkBounds(rootView, l = 10, t = 20, r = 200, b = 120)
+ checkBounds(firstChild, l = 10, t = 20, r = 150, b = 120)
+ checkBounds(secondChild, l = 150, t = 20, r = 200, b = 120)
+ }
+
+ @Test
+ fun doesNotAnimateInvisibleViews() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animate(rootView)
+ // GONE.
+ rootView.visibility = View.GONE
+ rootView.layout(0 /* l */, 15 /* t */, 55 /* r */, 80 /* b */)
+
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 0, t = 15, r = 55, b = 80)
+
+ // INVISIBLE.
+ rootView.visibility = View.INVISIBLE
+ rootView.layout(0 /* l */, 20 /* t */, 0 /* r */, 20 /* b */)
+ }
+
+ @Test
+ fun doesNotAnimateUnchangingBounds() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animate(rootView)
+ // No bounds are changed.
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 10, t = 10, r = 50, b = 50)
+
+ // Change only right and bottom.
+ rootView.layout(10 /* l */, 10 /* t */, 70 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 10, t = 10, r = 70, b = 80)
+ }
+
+ @Test
+ fun doesNotAnimateExcludedBounds() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animate(
+ rootView,
+ bounds = setOf(ViewBoundAnimator.Bound.LEFT, ViewBoundAnimator.Bound.TOP),
+ interpolator = TEST_INTERPOLATOR
+ )
+ // Change all bounds.
+ rootView.layout(0 /* l */, 20 /* t */, 70 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ advanceAnimation(rootView, 0.5f)
+ checkBounds(rootView, l = 5, t = 15, r = 70, b = 80)
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 0, t = 20, r = 70, b = 80)
+ }
+
+ @Test
+ fun stopsAnimatingAfterSingleLayout() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animateNextUpdate(rootView)
+ // Change all bounds.
+ rootView.layout(0 /* l */, 15 /* t */, 70 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 0, t = 15, r = 70, b = 80)
+
+ // Change all bounds again.
+ rootView.layout(10 /* l */, 10 /* t */, 50/* r */, 50 /* b */)
+
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 10, t = 10, r = 50, b = 50)
+ }
+
+ @Test
+ fun stopsAnimatingWhenInstructed() {
+ rootView.layout(10 /* l */, 10 /* t */, 50 /* r */, 50 /* b */)
+
+ ViewBoundAnimator.animate(rootView)
+ // Change all bounds.
+ rootView.layout(0 /* l */, 15 /* t */, 70 /* r */, 80 /* b */)
+
+ assertNotNull(rootView.getTag(R.id.tag_animator))
+ endAnimation(rootView)
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 0, t = 15, r = 70, b = 80)
+
+ ViewBoundAnimator.stopAnimating(rootView)
+ // Change all bounds again.
+ rootView.layout(10 /* l */, 10 /* t */, 50/* r */, 50 /* b */)
+
+ assertNull(rootView.getTag(R.id.tag_animator))
+ checkBounds(rootView, l = 10, t = 10, r = 50, b = 50)
+ }
+
+ private fun checkBounds(v: View, l: Int, t: Int, r: Int, b: Int) {
+ assertEquals(l, v.left)
+ assertEquals(t, v.top)
+ assertEquals(r, v.right)
+ assertEquals(b, v.bottom)
+ }
+
+ private fun advanceAnimation(rootView: View, fraction: Float) {
+ (rootView.getTag(R.id.tag_animator) as? ObjectAnimator)?.setCurrentFraction(fraction)
+
+ if (rootView is ViewGroup) {
+ for (i in 0 until rootView.childCount) {
+ advanceAnimation(rootView.getChildAt(i), fraction)
+ }
+ }
+ }
+
+ private fun endAnimation(rootView: View) {
+ (rootView.getTag(R.id.tag_animator) as? ObjectAnimator)?.end()
+
+ if (rootView is ViewGroup) {
+ for (i in 0 until rootView.childCount) {
+ endAnimation(rootView.getChildAt(i))
+ }
+ }
+ }
+}