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)) + } + } + } +}