Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 44120df1 authored by Automerger Merge Worker's avatar Automerger Merge Worker Committed by Android (Google) Code Review
Browse files

Merge "Merge "Create a ViewBoundAnimator to animate view layout updates." into...

Merge "Merge "Create a ViewBoundAnimator to animate view layout updates." into tm-dev am: bc4b6762"
parents 90bd92c6 b3d41885
Loading
Loading
Loading
Loading
+9 −0
Original line number Diff line number Diff line
@@ -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
+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()
        }
    }
}
+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))
            }
        }
    }
}