diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/ViewHierarchyAnimator.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/ViewHierarchyAnimator.kt index ca557796462ff..093589f8c636a 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/ViewHierarchyAnimator.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/ViewHierarchyAnimator.kt @@ -20,6 +20,7 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.animation.PropertyValuesHolder +import android.animation.ValueAnimator import android.util.IntProperty import android.view.View import android.view.ViewGroup @@ -37,6 +38,7 @@ class ViewHierarchyAnimator { private const val DEFAULT_DURATION = 500L private val DEFAULT_INTERPOLATOR = Interpolators.STANDARD private val DEFAULT_ADDITION_INTERPOLATOR = Interpolators.STANDARD_DECELERATE + private val DEFAULT_REMOVAL_INTERPOLATOR = Interpolators.STANDARD_ACCELERATE /** The properties used to animate the view bounds. */ private val PROPERTIES = mapOf( @@ -113,7 +115,7 @@ class ViewHierarchyAnimator { } val listener = createUpdateListener(interpolator, duration, ephemeral) - recursivelyAddListener(rootView, listener) + addListener(rootView, listener, recursive = true) return true } @@ -183,7 +185,7 @@ class ViewHierarchyAnimator { val listener = createAdditionListener( origin, interpolator, duration, ignorePreviousValues = !includeMargins ) - recursivelyAddListener(rootView, listener) + addListener(rootView, listener, recursive = true) return true } @@ -297,6 +299,183 @@ class ViewHierarchyAnimator { } } + /** + * Animates the removal of [rootView] and its children from the hierarchy. It uses the given + * [interpolator] and [duration]. + * + * The end state of the animation is controlled by [destination]. This value can be any of + * the four corners, any of the four edges, or the center of the view. + */ + @JvmOverloads + fun animateRemoval( + rootView: View, + destination: Hotspot = Hotspot.CENTER, + interpolator: Interpolator = DEFAULT_REMOVAL_INTERPOLATOR, + duration: Long = DEFAULT_DURATION + ): Boolean { + if (!isVisible( + rootView.visibility, + rootView.left, + rootView.top, + rootView.right, + rootView.bottom + ) + ) { + return false + } + + val parent = rootView.parent as ViewGroup + + // Ensure that rootView's siblings animate nicely around the removal. + val listener = createUpdateListener( + interpolator, + duration, + ephemeral = true + ) + for (i in 0 until parent.childCount) { + val child = parent.getChildAt(i) + if (child == rootView) continue + addListener(child, listener, recursive = false) + } + + // Remove the view so that a layout update is triggered for the siblings and they + // animate to their next position while the view's removal is also animating. + parent.removeView(rootView) + // By adding the view to the overlay, we can animate it while it isn't part of the view + // hierarchy. It is correctly positioned because we have its previous bounds, and we set + // them manually during the animation. + parent.overlay.add(rootView) + + val startValues = mapOf( + Bound.LEFT to rootView.left, + Bound.TOP to rootView.top, + Bound.RIGHT to rootView.right, + Bound.BOTTOM to rootView.bottom + ) + val endValues = processEndValuesForRemoval( + destination, + rootView.left, + rootView.top, + rootView.right, + rootView.bottom + ) + + val boundsToAnimate = mutableSetOf() + if (rootView.left != endValues.getValue(Bound.LEFT)) boundsToAnimate.add(Bound.LEFT) + if (rootView.top != endValues.getValue(Bound.TOP)) boundsToAnimate.add(Bound.TOP) + if (rootView.right != endValues.getValue(Bound.RIGHT)) boundsToAnimate.add(Bound.RIGHT) + if (rootView.bottom != endValues.getValue(Bound.BOTTOM)) { + boundsToAnimate.add(Bound.BOTTOM) + } + + startAnimation( + rootView, + boundsToAnimate, + startValues, + endValues, + interpolator, + duration, + ephemeral = true + ) + + if (rootView is ViewGroup) { + // Shift the children so they maintain a consistent position within the shrinking + // view. + shiftChildrenForRemoval(rootView, destination, endValues, interpolator, duration) + + // Fade out the children during the first half of the removal, so they don't clutter + // too much once the view becomes very small. Then we fade out the view itself, in + // case it has its own content and/or background. + val startAlphas = FloatArray(rootView.childCount) + for (i in 0 until rootView.childCount) { + startAlphas[i] = rootView.getChildAt(i).alpha + } + + val animator = ValueAnimator.ofFloat(1f, 0f) + animator.interpolator = Interpolators.ALPHA_OUT + animator.duration = duration / 2 + animator.addUpdateListener { animation -> + for (i in 0 until rootView.childCount) { + rootView.getChildAt(i).alpha = + (animation.animatedValue as Float) * startAlphas[i] + } + } + animator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + rootView.animate() + .alpha(0f) + .setInterpolator(Interpolators.ALPHA_OUT) + .setDuration(duration / 2) + .withEndAction { parent.overlay.remove(rootView) } + .start() + } + }) + animator.start() + } else { + // Fade out the view during the second half of the removal. + rootView.animate() + .alpha(0f) + .setInterpolator(Interpolators.ALPHA_OUT) + .setDuration(duration / 2) + .setStartDelay(duration / 2) + .withEndAction { parent.overlay.remove(rootView) } + .start() + } + + return true + } + + /** + * Animates the children of [rootView] so that its layout remains internally consistent as + * it shrinks towards [destination] and changes its bounds to [endValues]. + * + * Uses [interpolator] and [duration], which should match those of the removal animation. + */ + private fun shiftChildrenForRemoval( + rootView: ViewGroup, + destination: Hotspot, + endValues: Map, + interpolator: Interpolator, + duration: Long + ) { + for (i in 0 until rootView.childCount) { + val child = rootView.getChildAt(i) + val childStartValues = mapOf( + Bound.LEFT to child.left, + Bound.TOP to child.top, + Bound.RIGHT to child.right, + Bound.BOTTOM to child.bottom + ) + val childEndValues = processChildEndValuesForRemoval( + destination, + child.left, + child.top, + child.right, + child.bottom, + endValues.getValue(Bound.RIGHT) - endValues.getValue(Bound.LEFT), + endValues.getValue(Bound.BOTTOM) - endValues.getValue(Bound.TOP) + ) + + val boundsToAnimate = mutableSetOf() + if (child.left != endValues.getValue(Bound.LEFT)) boundsToAnimate.add(Bound.LEFT) + if (child.top != endValues.getValue(Bound.TOP)) boundsToAnimate.add(Bound.TOP) + if (child.right != endValues.getValue(Bound.RIGHT)) boundsToAnimate.add(Bound.RIGHT) + if (child.bottom != endValues.getValue(Bound.BOTTOM)) { + boundsToAnimate.add(Bound.BOTTOM) + } + + startAnimation( + child, + boundsToAnimate, + childStartValues, + childEndValues, + interpolator, + duration, + ephemeral = true + ) + } + } + /** * Returns whether the given [visibility] and bounds are consistent with a view being * currently visible on screen. @@ -312,7 +491,7 @@ class ViewHierarchyAnimator { } /** - * Compute the actual starting values based on the requested [origin] and on + * Computes the actual starting values based on the requested [origin] and on * [ignorePreviousValues]. * * If [origin] is null, the resolved start values will be the same as those passed in, or @@ -422,7 +601,140 @@ class ViewHierarchyAnimator { ) } - private fun recursivelyAddListener(view: View, listener: View.OnLayoutChangeListener) { + /** + * Computes a removal animation's end values based on the requested [destination] and the + * view's starting bounds. + * + * Examples: + * 1) destination=TOP + * x---------x x---------x x---------x x---------x x---------x + * | | | | | | x---------x + * | | -> | | -> x---------x -> -> + * | | x---------x + * x---------x + * 2) destination=BOTTOM_LEFT + * x---------x + * | | x-------x + * | | -> | | -> x----x -> -> + * | | | | | | x--x + * x---------x x-------x x----x x--x x + * 3) destination=CENTER + * x---------x + * | | x-------x x-----x + * | | -> | | -> | | -> x---x -> x + * | | x-------x x-----x + * x---------x + */ + private fun processEndValuesForRemoval( + destination: Hotspot, + left: Int, + top: Int, + right: Int, + bottom: Int + ): Map { + val endLeft = when (destination) { + Hotspot.CENTER -> (left + right) / 2 + Hotspot.BOTTOM, Hotspot.BOTTOM_LEFT, Hotspot.LEFT, Hotspot.TOP_LEFT, Hotspot.TOP -> + left + Hotspot.TOP_RIGHT, Hotspot.RIGHT, Hotspot.BOTTOM_RIGHT -> right + } + val endTop = when (destination) { + Hotspot.CENTER -> (top + bottom) / 2 + Hotspot.LEFT, Hotspot.TOP_LEFT, Hotspot.TOP, Hotspot.TOP_RIGHT, Hotspot.RIGHT -> + top + Hotspot.BOTTOM_RIGHT, Hotspot.BOTTOM, Hotspot.BOTTOM_LEFT -> bottom + } + val endRight = when (destination) { + Hotspot.CENTER -> (left + right) / 2 + Hotspot.TOP, Hotspot.TOP_RIGHT, Hotspot.RIGHT, + Hotspot.BOTTOM_RIGHT, Hotspot.BOTTOM -> + right + Hotspot.BOTTOM_LEFT, Hotspot.LEFT, Hotspot.TOP_LEFT -> left + } + val endBottom = when (destination) { + Hotspot.CENTER -> (top + bottom) / 2 + Hotspot.RIGHT, Hotspot.BOTTOM_RIGHT, Hotspot.BOTTOM, + Hotspot.BOTTOM_LEFT, Hotspot.LEFT -> + bottom + Hotspot.TOP_LEFT, Hotspot.TOP, Hotspot.TOP_RIGHT -> top + } + + return mapOf( + Bound.LEFT to endLeft, + Bound.TOP to endTop, + Bound.RIGHT to endRight, + Bound.BOTTOM to endBottom + ) + } + + /** + * Computes the end values for the child of a view being removed, based on the child's + * starting bounds, the removal's [destination], and the [parentWidth] and [parentHeight]. + * + * The end values always represent the child's position after it has been translated so that + * its center is at the [destination]. + * + * Examples: + * 1) destination=TOP + * The child maintains its left and right positions, but is shifted up so that its + * center is on the parent's end top edge. + * 2) destination=BOTTOM_LEFT + * The child shifts so that its center is on the parent's end bottom left corner. + * 3) destination=CENTER + * The child shifts so that its own center is on the parent's end center. + */ + private fun processChildEndValuesForRemoval( + destination: Hotspot, + left: Int, + top: Int, + right: Int, + bottom: Int, + parentWidth: Int, + parentHeight: Int + ): Map { + val halfWidth = (right - left) / 2 + val halfHeight = (bottom - top) / 2 + + val endLeft = when (destination) { + Hotspot.CENTER -> (parentWidth / 2) - halfWidth + Hotspot.BOTTOM_LEFT, Hotspot.LEFT, Hotspot.TOP_LEFT -> -halfWidth + Hotspot.TOP_RIGHT, Hotspot.RIGHT, Hotspot.BOTTOM_RIGHT -> parentWidth - halfWidth + Hotspot.TOP, Hotspot.BOTTOM -> left + } + val endTop = when (destination) { + Hotspot.CENTER -> (parentHeight / 2) - halfHeight + Hotspot.TOP_LEFT, Hotspot.TOP, Hotspot.TOP_RIGHT -> -halfHeight + Hotspot.BOTTOM_RIGHT, Hotspot.BOTTOM, Hotspot.BOTTOM_LEFT -> + parentHeight - halfHeight + Hotspot.LEFT, Hotspot.RIGHT -> top + } + val endRight = when (destination) { + Hotspot.CENTER -> (parentWidth / 2) + halfWidth + Hotspot.TOP_RIGHT, Hotspot.RIGHT, Hotspot.BOTTOM_RIGHT -> parentWidth + halfWidth + Hotspot.BOTTOM_LEFT, Hotspot.LEFT, Hotspot.TOP_LEFT -> halfWidth + Hotspot.TOP, Hotspot.BOTTOM -> right + } + val endBottom = when (destination) { + Hotspot.CENTER -> (parentHeight / 2) + halfHeight + Hotspot.BOTTOM_RIGHT, Hotspot.BOTTOM, Hotspot.BOTTOM_LEFT -> + parentHeight + halfHeight + Hotspot.TOP_LEFT, Hotspot.TOP, Hotspot.TOP_RIGHT -> halfHeight + Hotspot.LEFT, Hotspot.RIGHT -> bottom + } + + return mapOf( + Bound.LEFT to endLeft, + Bound.TOP to endTop, + Bound.RIGHT to endRight, + Bound.BOTTOM to endBottom + ) + } + + private fun addListener( + view: View, + listener: View.OnLayoutChangeListener, + recursive: Boolean = false + ) { // Make sure that only one listener is active at a time. val previousListener = view.getTag(R.id.tag_layout_listener) if (previousListener != null && previousListener is View.OnLayoutChangeListener) { @@ -431,9 +743,9 @@ class ViewHierarchyAnimator { view.addOnLayoutChangeListener(listener) view.setTag(R.id.tag_layout_listener, listener) - if (view is ViewGroup) { + if (view is ViewGroup && recursive) { for (i in 0 until view.childCount) { - recursivelyAddListener(view.getChildAt(i), listener) + addListener(view.getChildAt(i), listener, recursive = true) } } } @@ -490,6 +802,8 @@ class ViewHierarchyAnimator { } }.toTypedArray() + (view.getTag(R.id.tag_animator) as? ObjectAnimator)?.cancel() + val animator = ObjectAnimator.ofPropertyValuesHolder(view, *propertyValuesHolders) animator.interpolator = interpolator animator.duration = duration diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt index 23129d247ad59..6a9bb3e343bee 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt @@ -6,8 +6,10 @@ import android.testing.TestableLooper import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import android.widget.RelativeLayout import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase +import com.android.systemui.util.children import junit.framework.Assert.assertEquals import junit.framework.Assert.assertFalse import junit.framework.Assert.assertNotNull @@ -28,18 +30,11 @@ ViewHierarchyAnimatorTest : SysuiTestCase() { private val TEST_INTERPOLATOR = Interpolators.LINEAR } - private val childParams = LinearLayout.LayoutParams( - 0 /* width */, - LinearLayout.LayoutParams.MATCH_PARENT - ) - private lateinit var rootView: LinearLayout + private lateinit var rootView: ViewGroup @Before fun setUp() { rootView = LinearLayout(mContext) - rootView.orientation = LinearLayout.HORIZONTAL - rootView.weightSum = 1f - childParams.weight = 0.5f } @After @@ -93,6 +88,19 @@ ViewHierarchyAnimatorTest : SysuiTestCase() { animator = rootView.getTag(R.id.tag_animator) as ObjectAnimator assertEquals(animator.interpolator, TEST_INTERPOLATOR) assertEquals(animator.duration, TEST_DURATION) + + // animateRemoval() + setUpRootWithChildren() + val child = rootView.getChildAt(0) + success = ViewHierarchyAnimator.animateRemoval( + child, interpolator = TEST_INTERPOLATOR, duration = TEST_DURATION + ) + + assertTrue(success) + assertNotNull(child.getTag(R.id.tag_animator)) + animator = child.getTag(R.id.tag_animator) as ObjectAnimator + assertEquals(animator.interpolator, TEST_INTERPOLATOR) + assertEquals(animator.duration, TEST_DURATION) } @Test @@ -170,17 +178,7 @@ ViewHierarchyAnimatorTest : SysuiTestCase() { @Test fun animatesRootAndChildren() { - val firstChild = View(mContext) - firstChild.layoutParams = childParams - rootView.addView(firstChild) - val secondChild = View(mContext) - secondChild.layoutParams = childParams - rootView.addView(secondChild) - rootView.measure( - View.MeasureSpec.makeMeasureSpec(150, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY) - ) - rootView.layout(0 /* l */, 0 /* t */, 150 /* r */, 100 /* b */) + setUpRootWithChildren() val success = ViewHierarchyAnimator.animate(rootView) // Change all bounds. @@ -192,20 +190,20 @@ ViewHierarchyAnimatorTest : SysuiTestCase() { assertTrue(success) assertNotNull(rootView.getTag(R.id.tag_animator)) - assertNotNull(firstChild.getTag(R.id.tag_animator)) - assertNotNull(secondChild.getTag(R.id.tag_animator)) + assertNotNull(rootView.getChildAt(0).getTag(R.id.tag_animator)) + assertNotNull(rootView.getChildAt(1).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 = 75, b = 100) - checkBounds(secondChild, l = 75, t = 0, r = 150, b = 100) + checkBounds(rootView, l = 0, t = 0, r = 200, b = 100) + checkBounds(rootView.getChildAt(0), l = 0, t = 0, r = 100, b = 100) + checkBounds(rootView.getChildAt(1), l = 100, t = 0, r = 200, b = 100) endAnimation(rootView) assertNull(rootView.getTag(R.id.tag_animator)) - assertNull(firstChild.getTag(R.id.tag_animator)) - assertNull(secondChild.getTag(R.id.tag_animator)) + assertNull(rootView.getChildAt(0).getTag(R.id.tag_animator)) + assertNull(rootView.getChildAt(1).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 = 0, t = 0, r = 95, b = 100) - checkBounds(secondChild, l = 95, t = 0, r = 190, b = 100) + checkBounds(rootView.getChildAt(0), l = 0, t = 0, r = 95, b = 100) + checkBounds(rootView.getChildAt(1), l = 95, t = 0, r = 190, b = 100) } @Test @@ -522,6 +520,251 @@ ViewHierarchyAnimatorTest : SysuiTestCase() { endAnimation(rootView) } + fun animatesViewRemovalFromStartToEnd() { + setUpRootWithChildren() + + val child = rootView.getChildAt(0) + val success = ViewHierarchyAnimator.animateRemoval( + child, + destination = ViewHierarchyAnimator.Hotspot.LEFT, + interpolator = Interpolators.LINEAR + ) + + assertTrue(success) + assertNotNull(child.getTag(R.id.tag_animator)) + checkBounds(child, l = 0, t = 0, r = 100, b = 100) + advanceAnimation(child, 0.5f) + checkBounds(child, l = 0, t = 0, r = 50, b = 100) + advanceAnimation(child, 1.0f) + checkBounds(child, l = 0, t = 0, r = 0, b = 100) + endAnimation(rootView) + endAnimation(child) + assertEquals(1, rootView.childCount) + assertFalse(child in rootView.children) + } + + @Test + fun animatesViewRemovalRespectingDestination() { + // CENTER + setUpRootWithChildren() + var removedChild = rootView.getChildAt(0) + var remainingChild = rootView.getChildAt(1) + var success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.CENTER + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 50, t = 50, r = 50, b = 50) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // LEFT + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.LEFT + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 0, t = 0, r = 0, b = 100) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // TOP_LEFT + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.TOP_LEFT + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 0, t = 0, r = 0, b = 0) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // TOP + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.TOP + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 0, t = 0, r = 100, b = 0) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // TOP_RIGHT + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.TOP_RIGHT + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 100, t = 0, r = 100, b = 0) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // RIGHT + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.RIGHT + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 100, t = 0, r = 100, b = 100) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // BOTTOM_RIGHT + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.BOTTOM_RIGHT + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 100, t = 100, r = 100, b = 100) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // BOTTOM + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.BOTTOM + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 0, t = 100, r = 100, b = 100) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + + // BOTTOM_LEFT + setUpRootWithChildren() + removedChild = rootView.getChildAt(0) + remainingChild = rootView.getChildAt(1) + success = ViewHierarchyAnimator.animateRemoval( + removedChild, destination = ViewHierarchyAnimator.Hotspot.BOTTOM_LEFT + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(removedChild.getTag(R.id.tag_animator)) + advanceAnimation(removedChild, 1.0f) + checkBounds(removedChild, l = 0, t = 100, r = 0, b = 100) + endAnimation(rootView) + endAnimation(removedChild) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + } + + @Test + fun animatesChildrenDuringViewRemoval() { + setUpRootWithChildren() + + val child = rootView.getChildAt(0) as ViewGroup + val firstGrandChild = child.getChildAt(0) + val secondGrandChild = child.getChildAt(1) + val success = ViewHierarchyAnimator.animateRemoval( + child, interpolator = Interpolators.LINEAR + ) + + assertTrue(success) + assertNotNull(child.getTag(R.id.tag_animator)) + assertNotNull(firstGrandChild.getTag(R.id.tag_animator)) + assertNotNull(secondGrandChild.getTag(R.id.tag_animator)) + checkBounds(child, l = 0, t = 0, r = 100, b = 100) + checkBounds(firstGrandChild, l = 0, t = 0, r = 40, b = 40) + checkBounds(secondGrandChild, l = 60, t = 60, r = 100, b = 100) + + advanceAnimation(child, 0.5f) + checkBounds(child, l = 25, t = 25, r = 75, b = 75) + checkBounds(firstGrandChild, l = -10, t = -10, r = 30, b = 30) + checkBounds(secondGrandChild, l = 20, t = 20, r = 60, b = 60) + + advanceAnimation(child, 1.0f) + checkBounds(child, l = 50, t = 50, r = 50, b = 50) + checkBounds(firstGrandChild, l = -20, t = -20, r = 20, b = 20) + checkBounds(secondGrandChild, l = -20, t = -20, r = 20, b = 20) + + endAnimation(rootView) + endAnimation(child) + } + + @Test + fun animatesSiblingsDuringViewRemoval() { + setUpRootWithChildren() + + val removedChild = rootView.getChildAt(0) + val remainingChild = rootView.getChildAt(1) + val success = ViewHierarchyAnimator.animateRemoval( + removedChild, interpolator = Interpolators.LINEAR + ) + // Ensure that the layout happens before the checks. + forceLayout() + + assertTrue(success) + assertNotNull(remainingChild.getTag(R.id.tag_animator)) + checkBounds(remainingChild, l = 100, t = 0, r = 200, b = 100) + advanceAnimation(rootView, 0.5f) + checkBounds(remainingChild, l = 50, t = 0, r = 150, b = 100) + advanceAnimation(rootView, 1.0f) + checkBounds(remainingChild, l = 0, t = 0, r = 100, b = 100) + endAnimation(rootView) + endAnimation(removedChild) + assertNull(remainingChild.getTag(R.id.tag_animator)) + } + @Test fun cleansUpListenersCorrectly() { val firstChild = View(mContext) @@ -700,6 +943,49 @@ ViewHierarchyAnimatorTest : SysuiTestCase() { checkBounds(rootView, l = 10, t = 10, r = 50, b = 50) } + private fun setUpRootWithChildren() { + rootView = LinearLayout(mContext) + (rootView as LinearLayout).orientation = LinearLayout.HORIZONTAL + (rootView as LinearLayout).weightSum = 1f + + val firstChild = RelativeLayout(mContext) + rootView.addView(firstChild) + val firstGrandChild = View(mContext) + firstChild.addView(firstGrandChild) + val secondGrandChild = View(mContext) + firstChild.addView(secondGrandChild) + val secondChild = View(mContext) + rootView.addView(secondChild) + + val childParams = LinearLayout.LayoutParams( + 0 /* width */, + LinearLayout.LayoutParams.MATCH_PARENT + ) + childParams.weight = 0.5f + firstChild.layoutParams = childParams + secondChild.layoutParams = childParams + firstGrandChild.layoutParams = RelativeLayout.LayoutParams(40 /* width */, 40 /* height */) + (firstGrandChild.layoutParams as RelativeLayout.LayoutParams) + .addRule(RelativeLayout.ALIGN_PARENT_START) + (firstGrandChild.layoutParams as RelativeLayout.LayoutParams) + .addRule(RelativeLayout.ALIGN_PARENT_TOP) + secondGrandChild.layoutParams = RelativeLayout.LayoutParams(40 /* width */, 40 /* height */) + (secondGrandChild.layoutParams as RelativeLayout.LayoutParams) + .addRule(RelativeLayout.ALIGN_PARENT_END) + (secondGrandChild.layoutParams as RelativeLayout.LayoutParams) + .addRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + + forceLayout() + } + + private fun forceLayout() { + rootView.measure( + View.MeasureSpec.makeMeasureSpec(200 /* width */, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(100 /* height */, View.MeasureSpec.AT_MOST) + ) + rootView.layout(0 /* l */, 0 /* t */, 200 /* r */, 100 /* b */) + } + private fun checkBounds(v: View, l: Int, t: Int, r: Int, b: Int) { assertEquals(l, v.left) assertEquals(t, v.top)