Create a ViewBoundAnimator to animate view layout updates.

Includes the ability of animating a single upcoming layout or all
following layouts until the animator is detached.

Bug: 221432937
Test: unit tests included, also manual
Change-Id: Ie15ab83b1b06ef4d0b388177682ed51479f37dc1
This commit is contained in:
Luca Zuccarini
2022-03-03 10:17:43 +00:00
parent af4514e549
commit cb5b5a93ee
3 changed files with 614 additions and 0 deletions

View File

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

View File

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

View File

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