blob: 4b0c62bff60d6f92a4214622820855a59945d71d [file] [log] [blame]
/*
* 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.animation.ValueAnimator
import android.util.IntProperty
import android.view.View
import android.view.ViewGroup
import android.view.animation.Interpolator
import kotlin.math.max
import kotlin.math.min
/**
* A class that allows changes in bounds within a view hierarchy to animate seamlessly between the
* start and end state.
*/
class ViewHierarchyAnimator {
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.STANDARD
private val DEFAULT_ADDITION_INTERPOLATOR = Interpolators.STANDARD_DECELERATE
private val DEFAULT_REMOVAL_INTERPOLATOR = Interpolators.STANDARD_ACCELERATE
private val DEFAULT_FADE_IN_INTERPOLATOR = Interpolators.ALPHA_IN
/** 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. 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.
*
* Returns true if the [rootView] is already visible and will be animated, false otherwise.
* To animate the addition of a view, see [animateAddition].
*/
@JvmOverloads
fun animate(
rootView: View,
interpolator: Interpolator = DEFAULT_INTERPOLATOR,
duration: Long = DEFAULT_DURATION
): Boolean {
return animate(rootView, interpolator, duration, ephemeral = false)
}
/**
* Like [animate], but only takes effect on the next layout update, then unregisters itself
* once the first animation is complete.
*/
@JvmOverloads
fun animateNextUpdate(
rootView: View,
interpolator: Interpolator = DEFAULT_INTERPOLATOR,
duration: Long = DEFAULT_DURATION
): Boolean {
return animate(rootView, interpolator, duration, ephemeral = true)
}
private fun animate(
rootView: View,
interpolator: Interpolator,
duration: Long,
ephemeral: Boolean
): Boolean {
if (
!isVisible(
rootView.visibility,
rootView.left,
rootView.top,
rootView.right,
rootView.bottom
)
) {
return false
}
val listener = createUpdateListener(interpolator, duration, ephemeral)
addListener(rootView, listener, recursive = true)
return true
}
/**
* Returns a new [View.OnLayoutChangeListener] that when called triggers a layout animation
* using [interpolator] and [duration].
*
* If [ephemeral] is true, the listener is unregistered after the first animation. Otherwise
* it keeps listening for further updates.
*/
private fun createUpdateListener(
interpolator: Interpolator,
duration: Long,
ephemeral: Boolean
): View.OnLayoutChangeListener {
return createListener(interpolator, duration, ephemeral)
}
/**
* 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) {
recursivelyRemoveListener(rootView)
}
/**
* Instruct the animator to watch for changes to the layout of [rootView] and its children,
* and animate the next time the hierarchy appears after not being visible. It uses the
* given [interpolator] and [duration].
*
* The start state of the animation is controlled by [origin]. This value can be any of the
* four corners, any of the four edges, or the center of the view. If any margins are added
* on the side(s) of the origin, the translation of those margins can be included by
* specifying [includeMargins].
*
* Returns true if the [rootView] is invisible and will be animated, false otherwise. To
* animate an already visible view, see [animate] and [animateNextUpdate].
*
* Then animator unregisters itself once the first addition animation is complete.
*
* @param includeFadeIn true if the animator should also fade in the view and child views.
* @param fadeInInterpolator the interpolator to use when fading in the view. Unused if
* [includeFadeIn] is false.
*/
@JvmOverloads
fun animateAddition(
rootView: View,
origin: Hotspot = Hotspot.CENTER,
interpolator: Interpolator = DEFAULT_ADDITION_INTERPOLATOR,
duration: Long = DEFAULT_DURATION,
includeMargins: Boolean = false,
includeFadeIn: Boolean = false,
fadeInInterpolator: Interpolator = DEFAULT_FADE_IN_INTERPOLATOR
): Boolean {
if (
isVisible(
rootView.visibility,
rootView.left,
rootView.top,
rootView.right,
rootView.bottom
)
) {
return false
}
val listener =
createAdditionListener(
origin,
interpolator,
duration,
ignorePreviousValues = !includeMargins
)
addListener(rootView, listener, recursive = true)
if (!includeFadeIn) {
return true
}
if (rootView is ViewGroup) {
// First, fade in the container view
val containerDuration = duration / 6
createAndStartFadeInAnimator(
rootView, containerDuration, startDelay = 0, interpolator = fadeInInterpolator
)
// Then, fade in the child views
val childDuration = duration / 3
for (i in 0 until rootView.childCount) {
val view = rootView.getChildAt(i)
createAndStartFadeInAnimator(
view,
childDuration,
// Wait until the container fades in before fading in the children
startDelay = containerDuration,
interpolator = fadeInInterpolator
)
}
// For now, we don't recursively fade in additional sub views (e.g. grandchild
// views) since it hasn't been necessary, but we could add that functionality.
} else {
// Fade in the view during the first half of the addition
createAndStartFadeInAnimator(
rootView,
duration / 2,
startDelay = 0,
interpolator = fadeInInterpolator
)
}
return true
}
/**
* Returns a new [View.OnLayoutChangeListener] that on the next call triggers a layout
* addition animation from the given [origin], using [interpolator] and [duration].
*
* If [ignorePreviousValues] is true, the animation will only span the area covered by the
* new bounds. Otherwise it will include the margins between the previous and new bounds.
*/
private fun createAdditionListener(
origin: Hotspot,
interpolator: Interpolator,
duration: Long,
ignorePreviousValues: Boolean
): View.OnLayoutChangeListener {
return createListener(
interpolator,
duration,
ephemeral = true,
origin = origin,
ignorePreviousValues = ignorePreviousValues
)
}
/**
* Returns a new [View.OnLayoutChangeListener] that when called triggers a layout animation
* using [interpolator] and [duration].
*
* If [ephemeral] is true, the listener is unregistered after the first animation. Otherwise
* it keeps listening for further updates.
*
* [origin] specifies whether the start values should be determined by a hotspot, and
* [ignorePreviousValues] controls whether the previous values should be taken into account.
*/
private fun createListener(
interpolator: Interpolator,
duration: Long,
ephemeral: Boolean,
origin: Hotspot? = null,
ignorePreviousValues: Boolean = false
): View.OnLayoutChangeListener {
return object : View.OnLayoutChangeListener {
override fun onLayoutChange(
view: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
previousLeft: Int,
previousTop: Int,
previousRight: Int,
previousBottom: Int
) {
if (view == null) return
val startLeft = getBound(view, Bound.LEFT) ?: previousLeft
val startTop = getBound(view, Bound.TOP) ?: previousTop
val startRight = getBound(view, Bound.RIGHT) ?: previousRight
val startBottom = getBound(view, Bound.BOTTOM) ?: previousBottom
(view.getTag(R.id.tag_animator) as? ObjectAnimator)?.cancel()
if (!isVisible(view.visibility, left, top, right, bottom)) {
setBound(view, Bound.LEFT, left)
setBound(view, Bound.TOP, top)
setBound(view, Bound.RIGHT, right)
setBound(view, Bound.BOTTOM, bottom)
return
}
val startValues =
processStartValues(
origin,
left,
top,
right,
bottom,
startLeft,
startTop,
startRight,
startBottom,
ignorePreviousValues
)
val endValues =
mapOf(
Bound.LEFT to left,
Bound.TOP to top,
Bound.RIGHT to right,
Bound.BOTTOM to bottom
)
val boundsToAnimate = mutableSetOf<Bound>()
if (startValues.getValue(Bound.LEFT) != left) boundsToAnimate.add(Bound.LEFT)
if (startValues.getValue(Bound.TOP) != top) boundsToAnimate.add(Bound.TOP)
if (startValues.getValue(Bound.RIGHT) != right) boundsToAnimate.add(Bound.RIGHT)
if (startValues.getValue(Bound.BOTTOM) != bottom) {
boundsToAnimate.add(Bound.BOTTOM)
}
if (boundsToAnimate.isNotEmpty()) {
startAnimation(
view,
boundsToAnimate,
startValues,
endValues,
interpolator,
duration,
ephemeral
)
}
}
}
}
/**
* 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<Bound>()
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<Bound, Int>,
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<Bound>()
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.
*/
private fun isVisible(
visibility: Int,
left: Int,
top: Int,
right: Int,
bottom: Int
): Boolean {
return visibility == View.VISIBLE && left != right && top != bottom
}
/**
* 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
* the same as the new values if [ignorePreviousValues] is true. If [origin] is not null,
* the start values are resolved based on it, and [ignorePreviousValues] controls whether or
* not newly introduced margins are included.
*
* Base case
* ```
* 1) origin=TOP
* x---------x x---------x x---------x x---------x x---------x
* x---------x | | | | | |
* -> -> x---------x -> | | -> | |
* x---------x | |
* x---------x
* 2) origin=BOTTOM_LEFT
* x---------x
* x-------x | |
* -> -> x----x -> | | -> | |
* x--x | | | | | |
* x x--x x----x x-------x x---------x
* 3) origin=CENTER
* x---------x
* x-----x x-------x | |
* x -> x---x -> | | -> | | -> | |
* x-----x x-------x | |
* x---------x
* ```
* In case the start and end values differ in the direction of the origin, and
* [ignorePreviousValues] is false, the previous values are used and a translation is
* included in addition to the view expansion.
* ```
* origin=TOP_LEFT - (0,0,0,0) -> (30,30,70,70)
* x
* x--x
* x--x x----x
* -> -> | | -> x------x
* x----x | |
* | |
* x------x
* ```
*/
private fun processStartValues(
origin: Hotspot?,
newLeft: Int,
newTop: Int,
newRight: Int,
newBottom: Int,
previousLeft: Int,
previousTop: Int,
previousRight: Int,
previousBottom: Int,
ignorePreviousValues: Boolean
): Map<Bound, Int> {
val startLeft = if (ignorePreviousValues) newLeft else previousLeft
val startTop = if (ignorePreviousValues) newTop else previousTop
val startRight = if (ignorePreviousValues) newRight else previousRight
val startBottom = if (ignorePreviousValues) newBottom else previousBottom
var left = startLeft
var top = startTop
var right = startRight
var bottom = startBottom
if (origin != null) {
left =
when (origin) {
Hotspot.CENTER -> (newLeft + newRight) / 2
Hotspot.BOTTOM_LEFT,
Hotspot.LEFT,
Hotspot.TOP_LEFT -> min(startLeft, newLeft)
Hotspot.TOP,
Hotspot.BOTTOM -> newLeft
Hotspot.TOP_RIGHT,
Hotspot.RIGHT,
Hotspot.BOTTOM_RIGHT -> max(startRight, newRight)
}
top =
when (origin) {
Hotspot.CENTER -> (newTop + newBottom) / 2
Hotspot.TOP_LEFT,
Hotspot.TOP,
Hotspot.TOP_RIGHT -> min(startTop, newTop)
Hotspot.LEFT,
Hotspot.RIGHT -> newTop
Hotspot.BOTTOM_RIGHT,
Hotspot.BOTTOM,
Hotspot.BOTTOM_LEFT -> max(startBottom, newBottom)
}
right =
when (origin) {
Hotspot.CENTER -> (newLeft + newRight) / 2
Hotspot.TOP_RIGHT,
Hotspot.RIGHT,
Hotspot.BOTTOM_RIGHT -> max(startRight, newRight)
Hotspot.TOP,
Hotspot.BOTTOM -> newRight
Hotspot.BOTTOM_LEFT,
Hotspot.LEFT,
Hotspot.TOP_LEFT -> min(startLeft, newLeft)
}
bottom =
when (origin) {
Hotspot.CENTER -> (newTop + newBottom) / 2
Hotspot.BOTTOM_RIGHT,
Hotspot.BOTTOM,
Hotspot.BOTTOM_LEFT -> max(startBottom, newBottom)
Hotspot.LEFT,
Hotspot.RIGHT -> newBottom
Hotspot.TOP_LEFT,
Hotspot.TOP,
Hotspot.TOP_RIGHT -> min(startTop, newTop)
}
}
return mapOf(
Bound.LEFT to left,
Bound.TOP to top,
Bound.RIGHT to right,
Bound.BOTTOM to bottom
)
}
/**
* 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<Bound, Int> {
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<Bound, Int> {
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) {
view.removeOnLayoutChangeListener(previousListener)
}
view.addOnLayoutChangeListener(listener)
view.setTag(R.id.tag_layout_listener, listener)
if (view is ViewGroup && recursive) {
for (i in 0 until view.childCount) {
addListener(view.getChildAt(i), listener, recursive = true)
}
}
}
private fun recursivelyRemoveListener(view: View) {
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)
}
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
recursivelyRemoveListener(view.getChildAt(i))
}
}
}
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 the requested [bounds] between [startValues] and [endValues]
* by creating the animator, registering it with the [view], and starting it using
* [interpolator] and [duration].
*
* If [ephemeral] is true, 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()
(view.getTag(R.id.tag_animator) as? ObjectAnimator)?.cancel()
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) {
// The duration is the same for the whole hierarchy, so it's safe to
// remove the listener recursively. We do this because some descendant
// views might not change bounds, and therefore not animate and leak the
// listener.
recursivelyRemoveListener(view)
}
}
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()
}
private fun createAndStartFadeInAnimator(
view: View,
duration: Long,
startDelay: Long,
interpolator: Interpolator
) {
val animator = ObjectAnimator.ofFloat(view, "alpha", 1f)
animator.startDelay = startDelay
animator.duration = duration
animator.interpolator = interpolator
animator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
view.setTag(R.id.tag_alpha_animator, null /* tag */)
}
})
(view.getTag(R.id.tag_alpha_animator) as? ObjectAnimator)?.cancel()
view.setTag(R.id.tag_alpha_animator, animator)
animator.start()
}
}
/** An enum used to determine the origin of addition animations. */
enum class Hotspot {
CENTER,
LEFT,
TOP_LEFT,
TOP,
TOP_RIGHT,
RIGHT,
BOTTOM_RIGHT,
BOTTOM,
BOTTOM_LEFT
}
private 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
}
}