/*
 * 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 com.android.app.animation.Interpolators
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.
         *
         * By default, child views whole layout changes are animated as well. However, this can be
         * controlled by [animateChildren]. If children are included, a set of [excludedViews] can
         * be passed. If any dependent view from [rootView] matches an entry in this set, changes to
         * that view will not be animated.
         *
         * 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,
            animateChildren: Boolean = true,
            excludedViews: Set<View> = emptySet()
        ): Boolean {
            return animate(
                rootView,
                interpolator,
                duration,
                ephemeral = false,
                animateChildren = animateChildren,
                excludedViews = excludedViews
            )
        }

        /**
         * 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,
            animateChildren: Boolean = true,
            excludedViews: Set<View> = emptySet()
        ): Boolean {
            return animate(
                rootView,
                interpolator,
                duration,
                ephemeral = true,
                animateChildren = animateChildren,
                excludedViews = excludedViews
            )
        }

        private fun animate(
            rootView: View,
            interpolator: Interpolator,
            duration: Long,
            ephemeral: Boolean,
            animateChildren: Boolean,
            excludedViews: Set<View> = emptySet()
        ): Boolean {
            if (
                !occupiesSpace(
                    rootView.visibility,
                    rootView.left,
                    rootView.top,
                    rootView.right,
                    rootView.bottom
                )
            ) {
                return false
            }

            val listener = createUpdateListener(interpolator, duration, ephemeral)
            addListener(
                rootView,
                listener,
                recursive = true,
                animateChildren = animateChildren,
                excludedViews = excludedViews
            )
            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.
         * @param onAnimationEnd an optional runnable that will be run once the animation
         *    finishes successfully. Will not be run if the animation is cancelled.
         */
        @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,
            onAnimationEnd: Runnable? = null,
        ): Boolean {
            if (
                occupiesSpace(
                    rootView.visibility,
                    rootView.left,
                    rootView.top,
                    rootView.right,
                    rootView.bottom
                )
            ) {
                return false
            }

            val listener =
                createAdditionListener(
                    origin,
                    interpolator,
                    duration,
                    ignorePreviousValues = !includeMargins,
                    onAnimationEnd,
                )
            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,
            onAnimationEnd: Runnable? = null,
        ): View.OnLayoutChangeListener {
            return createListener(
                interpolator,
                duration,
                ephemeral = true,
                origin = origin,
                ignorePreviousValues = ignorePreviousValues,
                onAnimationEnd,
            )
        }

        /**
         * 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,
            onAnimationEnd: Runnable? = null,
        ): 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 (!occupiesSpace(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,
                            onAnimationEnd,
                        )
                    }
                }
            }
        }

        /**
         * 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. If any margins are
         * added on the side(s) of the [destination], the translation of those margins can be
         * included by specifying [includeMargins].
         *
         * @param onAnimationEnd an optional runnable that will be run once the animation finishes
         *    successfully. Will not be run if the animation is cancelled.
         */
        @JvmOverloads
        fun animateRemoval(
            rootView: View,
            destination: Hotspot = Hotspot.CENTER,
            interpolator: Interpolator = DEFAULT_REMOVAL_INTERPOLATOR,
            duration: Long = DEFAULT_DURATION,
            includeMargins: Boolean = false,
            onAnimationEnd: Runnable? = null,
        ): Boolean {
            if (
                !occupiesSpace(
                    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)
            }

            val viewHasSiblings = parent.childCount > 1
            if (viewHasSiblings) {
                // 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)
            }
            // If this view has no siblings, the parent view may shrink to (0,0) size and mess
            // up the animation if we immediately remove the view. So instead, we just leave the
            // view in the real hierarchy until the animation finishes.

            val endRunnable = Runnable {
                if (viewHasSiblings) {
                    parent.overlay.remove(rootView)
                } else {
                    parent.removeView(rootView)
                }
                onAnimationEnd?.run()
            }

            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,
                    rootView.left,
                    rootView.top,
                    rootView.right,
                    rootView.bottom,
                    includeMargins,
                )

            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,
                endRunnable,
            )

            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)
                                .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)
                    .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 a
         * contributing part of the hierarchy.
         */
        private fun occupiesSpace(
            visibility: Int,
            left: Int,
            top: Int,
            right: Int,
            bottom: Int
        ): Boolean {
            return visibility != View.GONE && 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
         *     4) destination=TOP, includeMargins=true (and view has large top margin)
         *                                                                     x---------x
         *                                                      x---------x
         *                                       x---------x    x---------x
         *                        x---------x    |         |
         *         x---------x    |         |    x---------x
         *         |         |    |         |
         *         |         | -> x---------x ->             ->             ->
         *         |         |
         *         x---------x
         * ```
         */
        private fun processEndValuesForRemoval(
            destination: Hotspot,
            rootView: View,
            left: Int,
            top: Int,
            right: Int,
            bottom: Int,
            includeMargins: Boolean = false,
        ): Map<Bound, Int> {
            val marginAdjustment =
                if (includeMargins &&
                    (rootView.layoutParams is ViewGroup.MarginLayoutParams)) {
                    val marginLp = rootView.layoutParams as ViewGroup.MarginLayoutParams
                    DimenHolder(
                        left = marginLp.leftMargin,
                        top = marginLp.topMargin,
                        right = marginLp.rightMargin,
                        bottom = marginLp.bottomMargin
                    )
            } else {
                DimenHolder(0, 0, 0, 0)
            }

            // These are the end values to use *if* this bound is part of the destination.
            val endLeft = left - marginAdjustment.left
            val endTop = top - marginAdjustment.top
            val endRight = right + marginAdjustment.right
            val endBottom = bottom + marginAdjustment.bottom

            // For the below calculations: We need to ensure that the destination bound and the
            // bound *opposite* to the destination bound end at the same value, to ensure that the
            // view has size 0 for that dimension.
            // For example,
            //  - If destination=TOP, then endTop == endBottom. Left and right stay the same.
            //  - If destination=RIGHT, then endRight == endLeft. Top and bottom stay the same.
            //  - If destination=BOTTOM_LEFT, then endBottom == endTop AND endLeft == endRight.

            return when (destination) {
                Hotspot.TOP -> mapOf(
                    Bound.TOP to endTop,
                    Bound.BOTTOM to endTop,
                    Bound.LEFT to left,
                    Bound.RIGHT to right,
                )
                Hotspot.TOP_RIGHT -> mapOf(
                    Bound.TOP to endTop,
                    Bound.BOTTOM to endTop,
                    Bound.RIGHT to endRight,
                    Bound.LEFT to endRight,
                )
                Hotspot.RIGHT -> mapOf(
                    Bound.RIGHT to endRight,
                    Bound.LEFT to endRight,
                    Bound.TOP to top,
                    Bound.BOTTOM to bottom,
                )
                Hotspot.BOTTOM_RIGHT -> mapOf(
                    Bound.BOTTOM to endBottom,
                    Bound.TOP to endBottom,
                    Bound.RIGHT to endRight,
                    Bound.LEFT to endRight,
                )
                Hotspot.BOTTOM -> mapOf(
                    Bound.BOTTOM to endBottom,
                    Bound.TOP to endBottom,
                    Bound.LEFT to left,
                    Bound.RIGHT to right,
                )
                Hotspot.BOTTOM_LEFT -> mapOf(
                    Bound.BOTTOM to endBottom,
                    Bound.TOP to endBottom,
                    Bound.LEFT to endLeft,
                    Bound.RIGHT to endLeft,
                )
                Hotspot.LEFT -> mapOf(
                    Bound.LEFT to endLeft,
                    Bound.RIGHT to endLeft,
                    Bound.TOP to top,
                    Bound.BOTTOM to bottom,
                )
                Hotspot.TOP_LEFT -> mapOf(
                    Bound.TOP to endTop,
                    Bound.BOTTOM to endTop,
                    Bound.LEFT to endLeft,
                    Bound.RIGHT to endLeft,
                )
                Hotspot.CENTER -> mapOf(
                    Bound.LEFT to (endLeft + endRight) / 2,
                    Bound.RIGHT to (endLeft + endRight) / 2,
                    Bound.TOP to (endTop + endBottom) / 2,
                    Bound.BOTTOM to (endTop + endBottom) / 2,
                )
            }
        }

        /**
         * 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,
            animateChildren: Boolean = true,
            excludedViews: Set<View> = emptySet()
        ) {
            if (excludedViews.contains(view)) return

            // 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 (animateChildren && view is ViewGroup && recursive) {
                for (i in 0 until view.childCount) {
                    addListener(
                        view.getChildAt(i),
                        listener,
                        recursive = true,
                        animateChildren = animateChildren,
                        excludedViews = excludedViews
                    )
                }
            }
        }

        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,
            onAnimationEnd: Runnable? = null,
        ) {
            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)
                        }
                        if (!cancelled) {
                            onAnimationEnd?.run()
                        }
                    }

                    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
    }

    /** Simple data class to hold a set of dimens for left, top, right, bottom. */
    private data class DimenHolder(
        val left: Int,
        val top: Int,
        val right: Int,
        val bottom: Int,
    )
}
