Loading packages/SystemUI/animation/res/values/ids.xml +9 −0 Original line number Diff line number Diff line Loading @@ -15,5 +15,14 @@ limitations under the License. --> <resources> <!-- DialogLaunchAnimator --> <item type="id" name="launch_animation_running"/> <!-- ViewBoundsAnimator --> <item type="id" name="tag_animator"/> <item type="id" name="tag_layout_listener"/> <item type="id" name="tag_override_bottom"/> <item type="id" name="tag_override_left"/> <item type="id" name="tag_override_right"/> <item type="id" name="tag_override_top"/> </resources> No newline at end of file packages/SystemUI/animation/src/com/android/systemui/animation/ViewBoundAnimator.kt 0 → 100644 +328 −0 Original line number Diff line number Diff line /* * 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<View> { return object : IntProperty<View>(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<Bound> = 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<Bound> = DEFAULT_BOUNDS, interpolator: Interpolator = DEFAULT_INTERPOLATOR, duration: Long = DEFAULT_DURATION ) { animate(rootView, bounds, interpolator, duration, true /* ephemeral */) } private fun animate( rootView: View, bounds: Set<Bound>, 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<Bound>, 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<Bound>, startValues: Map<Bound, Int>, endValues: Map<Bound, Int>, 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() } } } packages/SystemUI/tests/src/com/android/systemui/animation/ViewBoundAnimatorTest.kt 0 → 100644 +277 −0 Original line number Diff line number Diff line 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)) } } } } Loading
packages/SystemUI/animation/res/values/ids.xml +9 −0 Original line number Diff line number Diff line Loading @@ -15,5 +15,14 @@ limitations under the License. --> <resources> <!-- DialogLaunchAnimator --> <item type="id" name="launch_animation_running"/> <!-- ViewBoundsAnimator --> <item type="id" name="tag_animator"/> <item type="id" name="tag_layout_listener"/> <item type="id" name="tag_override_bottom"/> <item type="id" name="tag_override_left"/> <item type="id" name="tag_override_right"/> <item type="id" name="tag_override_top"/> </resources> No newline at end of file
packages/SystemUI/animation/src/com/android/systemui/animation/ViewBoundAnimator.kt 0 → 100644 +328 −0 Original line number Diff line number Diff line /* * 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<View> { return object : IntProperty<View>(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<Bound> = 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<Bound> = DEFAULT_BOUNDS, interpolator: Interpolator = DEFAULT_INTERPOLATOR, duration: Long = DEFAULT_DURATION ) { animate(rootView, bounds, interpolator, duration, true /* ephemeral */) } private fun animate( rootView: View, bounds: Set<Bound>, 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<Bound>, 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<Bound>, startValues: Map<Bound, Int>, endValues: Map<Bound, Int>, 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() } } }
packages/SystemUI/tests/src/com/android/systemui/animation/ViewBoundAnimatorTest.kt 0 → 100644 +277 −0 Original line number Diff line number Diff line 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)) } } } }