chromium/components/infobars/android/java/src/org/chromium/components/infobars/InfoBarContainerLayout.java

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.infobars;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Resources;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;

import org.chromium.ui.widget.OptimizedFrameLayout;

import java.util.ArrayList;

/**
 * Layout that displays infobars in a stack. Handles all the animations when adding or removing
 * infobars and when swapping infobar contents.
 *
 * The first infobar to be added is visible at the front of the stack. Later infobars peek up just
 * enough behind the front infobar to signal their existence; their contents aren't visible at all.
 * The stack has a max depth of three infobars. If additional infobars are added beyond this, they
 * won't be visible at all until infobars in front of them are dismissed.
 *
 * Animation details:
 *  - Newly added infobars slide up from the bottom and then their contents fade in.
 *  - Disappearing infobars slide down and away. The remaining infobars, if any, resize to the
 *    new front infobar's size, then the content of the new front infobar fades in.
 *  - When swapping the front infobar's content, the old content fades out, the infobar resizes to
 *    the new content's size, then the new content fades in.
 *  - Only a single animation happens at a time. If several infobars are added and/or removed in
 *    quick succession, the animations will be queued and run sequentially.
 *
 * Note: this class depends only on Android view code; it intentionally does not depend on any other
 * infobar code. This is an explicit design decision and should remain this way.
 *
 * TODO(newt): what happens when detached from window? Do animations run? Do animations jump to end
 *     values? Should they jump to end values? Does requestLayout() get called when detached
 *     from window? Probably not; it probably just gets called later when reattached.
 *
 * TODO(newt): use hardware acceleration? See
 *     http://blog.danlew.net/2015/10/20/using-hardware-layers-to-improve-animation-performance/
 *     and http://developer.android.com/guide/topics/graphics/hardware-accel.html#layers
 *
 * TODO(newt): handle tall infobars on small devices. Use a ScrollView inside the InfoBarWrapper?
 *     Make sure InfoBarContainerLayout doesn't extend into tabstrip on tablet.
 *
 * TODO(newt): Disable key events during animations, perhaps by overriding dispatchKeyEvent().
 *     Or can we just call setEnabled() false on the infobar wrapper? Will this cause the buttons
 *     visual state to change (i.e. to turn gray)?
 *
 * TODO(newt): finalize animation timings and interpolators.
 */
public class InfoBarContainerLayout extends OptimizedFrameLayout {
    /** Creates an empty InfoBarContainerLayout. */
    public InfoBarContainerLayout(
            Context context,
            Runnable makeContainerVisibleRunnable,
            InfoBarAnimationListener animationListener) {
        super(context, null);
        Resources res = context.getResources();
        mBackInfobarHeight = res.getDimensionPixelSize(R.dimen.infobar_peeking_height);
        mFloatingBehavior = new FloatingBehavior(this);
        mAnimationListener = animationListener;
        mMakeContainerVisibleRunnable = makeContainerVisibleRunnable;
    }

    /**
     * Adds an infobar to the container. The infobar appearing animation will happen after the
     * current animation, if any, finishes.
     */
    public void addInfoBar(InfoBarUiItem item) {
        mItems.add(findInsertIndex(item), item);
        processPendingAnimations();
    }

    /**
     * Finds the appropriate index in the infobar stack for inserting this item.
     * @param item The infobar to be inserted.
     */
    private int findInsertIndex(InfoBarUiItem item) {
        for (int i = 0; i < mItems.size(); ++i) {
            if (item.getPriority() < mItems.get(i).getPriority()) {
                return i;
            }
        }

        return mItems.size();
    }

    /**
     * Removes an infobar from the container. The infobar will be animated off the screen if it's
     * currently visible.
     */
    public void removeInfoBar(InfoBarUiItem item) {
        mItems.remove(item);
        processPendingAnimations();
    }

    /**
     * Notifies that an infobar's View ({@link InfoBarUiItem#getView}) has changed. If the infobar
     * is visible in the front of the stack, the infobar will fade out the old contents, resize,
     * then fade in the new contents.
     */
    public void notifyInfoBarViewChanged() {
        processPendingAnimations();
    }

    /** Returns true if any animations are pending or in progress. */
    public boolean isAnimating() {
        return mAnimation != null;
    }

    /////////////////////////////////////////
    // Implementation details
    /////////////////////////////////////////

    /** The maximum number of infobars visible at any time. */
    private static final int MAX_STACK_DEPTH = 3;

    // Animation durations.
    private static final int DURATION_SLIDE_UP_MS = 250;
    private static final int DURATION_SLIDE_DOWN_MS = 250;
    private static final int DURATION_FADE_MS = 100;
    private static final int DURATION_FADE_OUT_MS = 200;

    /**
     * Base class for animations inside the InfoBarContainerLayout.
     *
     * Provides a standardized way to prepare for, run, and clean up after animations. Each subclass
     * should implement prepareAnimation(), createAnimator(), and onAnimationEnd() as needed.
     */
    private abstract class InfoBarAnimation {
        private Animator mAnimator;

        final boolean isStarted() {
            return mAnimator != null;
        }

        final void start() {
            Animator.AnimatorListener listener =
                    new AnimatorListenerAdapter() {
                        @Override
                        public void onAnimationEnd(Animator animation) {
                            InfoBarAnimation.this.onAnimationEnd();
                            mAnimation = null;
                            mAnimationListener.notifyAnimationFinished(getAnimationType());
                            processPendingAnimations();
                        }
                    };

            mAnimator = createAnimator();
            mAnimator.addListener(listener);
            mAnimator.start();
        }

        /**
         * Returns an animator that animates an InfoBarWrapper's y-translation from its current
         * value to endValue and updates the side shadow positions on each frame.
         */
        ValueAnimator createTranslationYAnimator(final InfoBarWrapper wrapper, float endValue) {
            ValueAnimator animator = ValueAnimator.ofFloat(wrapper.getTranslationY(), endValue);
            animator.addUpdateListener(
                    new ValueAnimator.AnimatorUpdateListener() {
                        @Override
                        public void onAnimationUpdate(ValueAnimator animation) {
                            wrapper.setTranslationY((float) animation.getAnimatedValue());
                            mFloatingBehavior.updateShadowPosition();
                        }
                    });
            return animator;
        }

        /**
         * Called before the animation begins. This is the time to add views to the hierarchy and
         * adjust layout parameters.
         */
        void prepareAnimation() {}

        /**
         * Called to create an Animator which will control the animation. Called after
         * prepareAnimation() and after a subsequent layout has happened.
         */
        abstract Animator createAnimator();

        /**
         * Called after the animation completes. This is the time to do post-animation cleanup, such
         * as removing views from the hierarchy.
         */
        void onAnimationEnd() {}

        /**
         * Returns the InfoBarAnimationListener.ANIMATION_TYPE_* constant that corresponds to this
         * type of animation (showing, swapping, etc).
         */
        abstract int getAnimationType();
    }

    /**
     * The animation to show the first infobar. The infobar slides up from the bottom; then its
     * content fades in.
     */
    private class FirstInfoBarAppearingAnimation extends InfoBarAnimation {
        private InfoBarUiItem mFrontItem;
        private InfoBarWrapper mFrontWrapper;
        private View mFrontContents;

        FirstInfoBarAppearingAnimation(InfoBarUiItem frontItem) {
            mFrontItem = frontItem;
        }

        @Override
        void prepareAnimation() {
            mFrontContents = mFrontItem.getView();
            mFrontWrapper = new InfoBarWrapper(getContext(), mFrontItem);
            mFrontWrapper.addView(mFrontContents);
            addWrapper(mFrontWrapper);
        }

        @Override
        Animator createAnimator() {
            mFrontWrapper.setTranslationY(mFrontWrapper.getHeight());
            mFrontContents.setAlpha(0f);

            AnimatorSet animator = new AnimatorSet();
            animator.playSequentially(
                    createTranslationYAnimator(mFrontWrapper, 0f).setDuration(DURATION_SLIDE_UP_MS),
                    ObjectAnimator.ofFloat(mFrontContents, View.ALPHA, 1f)
                            .setDuration(DURATION_FADE_MS));
            return animator;
        }

        @Override
        void onAnimationEnd() {
            announceForAccessibility(mFrontItem.getAccessibilityText());
        }

        @Override
        int getAnimationType() {
            return InfoBarAnimationListener.ANIMATION_TYPE_SHOW;
        }
    }

    /**
     * The animation to show the a new front-most infobar in front of existing visible infobars. The
     * infobar slides up from the bottom; then its content fades in. The previously visible infobars
     * will be resized simulatenously to the new desired size.
     */
    private class FrontInfoBarAppearingAnimation extends InfoBarAnimation {
        private InfoBarUiItem mFrontItem;
        private InfoBarWrapper mFrontWrapper;
        private InfoBarWrapper mOldFrontWrapper;
        private View mFrontContents;

        FrontInfoBarAppearingAnimation(InfoBarUiItem frontItem) {
            mFrontItem = frontItem;
        }

        @Override
        void prepareAnimation() {
            mOldFrontWrapper = mInfoBarWrappers.get(0);

            mFrontContents = mFrontItem.getView();
            mFrontWrapper = new InfoBarWrapper(getContext(), mFrontItem);
            mFrontWrapper.addView(mFrontContents);
            addWrapperToFront(mFrontWrapper);
        }

        @Override
        Animator createAnimator() {
            // After adding the new wrapper, the new front item's view, and the old front item's
            // view are both in their wrappers, and the height of the stack as determined by
            // FrameLayout will take both into account. This means the height of the container will
            // be larger than it needs to be, if the previous old front item is larger than the sum
            // of the new front item and mBackInfobarHeight.
            //
            // First work out how much the container will grow or shrink by.
            int heightDelta =
                    mFrontWrapper.getHeight() + mBackInfobarHeight - mOldFrontWrapper.getHeight();

            // Now work out where to animate the new front item to / from.
            int newFrontStart = mFrontWrapper.getHeight();
            int newFrontEnd = 0;
            if (heightDelta < 0) {
                // If the container is shrinking, this won't be reflected in the layout just yet.
                // The layout will have extra space in it for the previous front infobar, which the
                // animation of the new front infobar has to take into account.
                newFrontStart -= heightDelta;
                newFrontEnd -= heightDelta;
            }
            mFrontWrapper.setTranslationY(newFrontStart);
            mFrontContents.setAlpha(0f);

            // Since we are adding the infobar to the top of the stack, make the container fully
            // visible since it could be at hidden or partially hidden state.
            mMakeContainerVisibleRunnable.run();

            AnimatorSet animator = new AnimatorSet();
            animator.play(
                    createTranslationYAnimator(mFrontWrapper, newFrontEnd)
                            .setDuration(DURATION_SLIDE_UP_MS));

            // If the container is shrinking, the back infobars need to animate down (from 0 to the
            // positive delta). Otherwise they have to animate up (from the negative delta to 0).
            int backStart = Math.max(0, heightDelta);
            int backEnd = Math.max(-heightDelta, 0);
            for (int i = 1; i < mInfoBarWrappers.size(); i++) {
                mInfoBarWrappers.get(i).setTranslationY(backStart);
                animator.play(
                        createTranslationYAnimator(mInfoBarWrappers.get(i), backEnd)
                                .setDuration(DURATION_SLIDE_UP_MS));
            }

            animator.play(
                            ObjectAnimator.ofFloat(mFrontContents, View.ALPHA, 1f)
                                    .setDuration(DURATION_FADE_MS))
                    .after(DURATION_SLIDE_UP_MS);

            return animator;
        }

        @Override
        void onAnimationEnd() {
            // Remove the old front wrappers view so it won't affect the height of the container any
            // more.
            mOldFrontWrapper.removeAllViews();

            // Now set any Y offsets to 0 as there is no need to account for the old front wrapper
            // making the container higher than it should be.
            for (int i = 0; i < mInfoBarWrappers.size(); i++) {
                mInfoBarWrappers.get(i).setTranslationY(0);
            }
            updateLayoutParams();
            announceForAccessibility(mFrontItem.getAccessibilityText());
        }

        @Override
        int getAnimationType() {
            return InfoBarAnimationListener.ANIMATION_TYPE_SHOW;
        }
    }

    /**
     * The animation to show a back infobar. The infobar slides up behind the existing infobars, so
     * its top edge peeks out just a bit.
     */
    private class BackInfoBarAppearingAnimation extends InfoBarAnimation {
        private InfoBarWrapper mAppearingWrapper;

        BackInfoBarAppearingAnimation(InfoBarUiItem appearingItem) {
            mAppearingWrapper = new InfoBarWrapper(getContext(), appearingItem);
        }

        @Override
        void prepareAnimation() {
            addWrapper(mAppearingWrapper);
        }

        @Override
        Animator createAnimator() {
            mAppearingWrapper.setTranslationY(mAppearingWrapper.getHeight());
            return createTranslationYAnimator(mAppearingWrapper, 0f)
                    .setDuration(DURATION_SLIDE_UP_MS);
        }

        @Override
        public void onAnimationEnd() {
            mAppearingWrapper.removeView(mAppearingWrapper.getItem().getView());
        }

        @Override
        int getAnimationType() {
            return InfoBarAnimationListener.ANIMATION_TYPE_SHOW;
        }
    }

    /**
     * The animation to hide the front infobar and reveal the second-to-front infobar. The front
     * infobar slides down and off the screen. The back infobar(s) will adjust to the size of the
     * new front infobar, and then the new front infobar's contents will fade in.
     */
    private class FrontInfoBarDisappearingAndRevealingAnimation extends InfoBarAnimation {
        private InfoBarWrapper mOldFrontWrapper;
        private InfoBarWrapper mNewFrontWrapper;
        private View mNewFrontContents;

        @Override
        void prepareAnimation() {
            mOldFrontWrapper = mInfoBarWrappers.get(0);
            mNewFrontWrapper = mInfoBarWrappers.get(1);
            mNewFrontContents = mNewFrontWrapper.getItem().getView();
            mNewFrontWrapper.addView(mNewFrontContents);
        }

        @Override
        Animator createAnimator() {
            // The amount by which mNewFrontWrapper will grow (negative value indicates shrinking).
            int deltaHeight =
                    (mNewFrontWrapper.getHeight() - mBackInfobarHeight)
                            - mOldFrontWrapper.getHeight();
            int startTranslationY = Math.max(deltaHeight, 0);
            int endTranslationY = Math.max(-deltaHeight, 0);

            // Slide the front infobar down and away.
            AnimatorSet animator = new AnimatorSet();
            mOldFrontWrapper.setTranslationY(startTranslationY);
            animator.play(
                    createTranslationYAnimator(
                                    mOldFrontWrapper,
                                    startTranslationY + mOldFrontWrapper.getHeight())
                            .setDuration(DURATION_SLIDE_UP_MS));

            // Slide the other infobars to their new positions.
            // Note: animator.play() causes these animations to run simultaneously.
            for (int i = 1; i < mInfoBarWrappers.size(); i++) {
                mInfoBarWrappers.get(i).setTranslationY(startTranslationY);
                animator.play(
                        createTranslationYAnimator(mInfoBarWrappers.get(i), endTranslationY)
                                .setDuration(DURATION_SLIDE_UP_MS));
            }

            mNewFrontContents.setAlpha(0f);
            animator.play(
                            ObjectAnimator.ofFloat(mNewFrontContents, View.ALPHA, 1f)
                                    .setDuration(DURATION_FADE_MS))
                    .after(DURATION_SLIDE_UP_MS);

            return animator;
        }

        @Override
        void onAnimationEnd() {
            mOldFrontWrapper.removeAllViews();
            removeWrapper(mOldFrontWrapper);
            for (int i = 0; i < mInfoBarWrappers.size(); i++) {
                mInfoBarWrappers.get(i).setTranslationY(0);
            }
            announceForAccessibility(mNewFrontWrapper.getItem().getAccessibilityText());
        }

        @Override
        int getAnimationType() {
            return InfoBarAnimationListener.ANIMATION_TYPE_HIDE;
        }
    }

    /**
     * The animation to hide the backmost infobar, or the front infobar if there's only one infobar.
     * The infobar simply slides down out of the container.
     */
    private class InfoBarDisappearingAnimation extends InfoBarAnimation {
        private InfoBarWrapper mDisappearingWrapper;

        @Override
        void prepareAnimation() {
            mDisappearingWrapper = mInfoBarWrappers.get(mInfoBarWrappers.size() - 1);
        }

        @Override
        Animator createAnimator() {
            return createTranslationYAnimator(
                            mDisappearingWrapper, mDisappearingWrapper.getHeight())
                    .setDuration(DURATION_SLIDE_DOWN_MS);
        }

        @Override
        void onAnimationEnd() {
            mDisappearingWrapper.removeAllViews();
            removeWrapper(mDisappearingWrapper);
        }

        @Override
        int getAnimationType() {
            return InfoBarAnimationListener.ANIMATION_TYPE_HIDE;
        }
    }

    /**
     * The animation to swap the contents of the front infobar. The current contents fade out,
     * then the infobar resizes to fit the new contents, then the new contents fade in.
     */
    private class FrontInfoBarSwapContentsAnimation extends InfoBarAnimation {
        private InfoBarWrapper mFrontWrapper;
        private View mOldContents;
        private View mNewContents;

        @Override
        void prepareAnimation() {
            mFrontWrapper = mInfoBarWrappers.get(0);
            mOldContents = mFrontWrapper.getChildAt(0);
            mNewContents = mFrontWrapper.getItem().getView();
            mFrontWrapper.addView(mNewContents);
        }

        @Override
        Animator createAnimator() {
            int deltaHeight = mNewContents.getHeight() - mOldContents.getHeight();
            InfoBarContainerLayout.this.setTranslationY(Math.max(0, deltaHeight));
            mNewContents.setAlpha(0f);

            AnimatorSet animator = new AnimatorSet();
            animator.playSequentially(
                    ObjectAnimator.ofFloat(mOldContents, View.ALPHA, 0f)
                            .setDuration(DURATION_FADE_OUT_MS),
                    ObjectAnimator.ofFloat(
                                    InfoBarContainerLayout.this,
                                    View.TRANSLATION_Y,
                                    Math.max(0, -deltaHeight))
                            .setDuration(DURATION_SLIDE_UP_MS),
                    ObjectAnimator.ofFloat(mNewContents, View.ALPHA, 1f)
                            .setDuration(DURATION_FADE_OUT_MS));
            return animator;
        }

        @Override
        void onAnimationEnd() {
            mFrontWrapper.removeViewAt(0);
            InfoBarContainerLayout.this.setTranslationY(0f);
            mFrontWrapper.getItem().setControlsEnabled(true);
            announceForAccessibility(mFrontWrapper.getItem().getAccessibilityText());
        }

        @Override
        int getAnimationType() {
            return InfoBarAnimationListener.ANIMATION_TYPE_SWAP;
        }
    }

    /**
     * Controls whether infobars fill the full available width, or whether they "float" in the
     * middle of the available space. The latter case happens if the available space is wider than
     * the max width allowed for infobars.
     *
     * Also handles the shadows on the sides of the infobars in floating mode. The side shadows are
     * separate views -- rather than being part of each InfoBarWrapper -- to avoid a double-shadow
     * effect, which would happen during animations when two InfoBarWrappers overlap each other.
     */
    private static class FloatingBehavior {
        /** The InfoBarContainerLayout. */
        private FrameLayout mLayout;

        /**
         * The max width of the infobars. If the available space is wider than this, the infobars
         * will switch to floating mode.
         */
        private final int mMaxWidth;

        /** The width of the left and right shadows. */
        private final int mShadowWidth;

        /** Whether the layout is currently floating. */
        private boolean mIsFloating;

        /** The shadows that appear on the sides of the infobars in floating mode. */
        private View mLeftShadowView;

        private View mRightShadowView;

        FloatingBehavior(FrameLayout layout) {
            mLayout = layout;
            Resources res = mLayout.getContext().getResources();
            mMaxWidth = res.getDimensionPixelSize(R.dimen.infobar_max_width);
            mShadowWidth = res.getDimensionPixelSize(R.dimen.infobar_shadow_width);
        }

        /**
         * This should be called in onMeasure() before super.onMeasure(). The return value is a new
         * widthMeasureSpec that should be passed to super.onMeasure().
         */
        int beforeOnMeasure(int widthMeasureSpec) {
            int width = MeasureSpec.getSize(widthMeasureSpec);
            boolean isFloating = width > mMaxWidth;
            if (isFloating != mIsFloating) {
                mIsFloating = isFloating;
                onIsFloatingChanged();
            }

            if (isFloating) {
                int mode = MeasureSpec.getMode(widthMeasureSpec);
                width = Math.min(width, mMaxWidth + 2 * mShadowWidth);
                widthMeasureSpec = MeasureSpec.makeMeasureSpec(width, mode);
            }
            return widthMeasureSpec;
        }

        /** This should be called in onMeasure() after super.onMeasure(). */
        void afterOnMeasure(int measuredHeight) {
            if (!mIsFloating) return;
            // Measure side shadows to match the parent view's height.
            int widthSpec = MeasureSpec.makeMeasureSpec(mShadowWidth, MeasureSpec.EXACTLY);
            int heightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY);
            mLeftShadowView.measure(widthSpec, heightSpec);
            mRightShadowView.measure(widthSpec, heightSpec);
        }

        /** This should be called whenever the Y-position of an infobar changes. */
        void updateShadowPosition() {
            if (!mIsFloating) return;
            float minY = mLayout.getHeight();
            int childCount = mLayout.getChildCount();
            for (int i = 0; i < childCount; i++) {
                View child = mLayout.getChildAt(i);
                if (child != mLeftShadowView && child != mRightShadowView) {
                    minY = Math.min(minY, child.getY());
                }
            }
            mLeftShadowView.setY(minY);
            mRightShadowView.setY(minY);
        }

        private void onIsFloatingChanged() {
            if (mIsFloating) {
                initShadowViews();
                mLayout.setPadding(mShadowWidth, 0, mShadowWidth, 0);
                mLayout.setClipToPadding(false);
                mLayout.addView(mLeftShadowView);
                mLayout.addView(mRightShadowView);
            } else {
                mLayout.setPadding(0, 0, 0, 0);
                mLayout.removeView(mLeftShadowView);
                mLayout.removeView(mRightShadowView);
            }
        }

        @SuppressLint("RtlHardcoded")
        private void initShadowViews() {
            if (mLeftShadowView != null) return;

            mLeftShadowView = new View(mLayout.getContext());
            mLeftShadowView.setBackgroundResource(R.drawable.infobar_shadow_left);
            LayoutParams leftLp = new FrameLayout.LayoutParams(0, 0, Gravity.LEFT);
            leftLp.leftMargin = -mShadowWidth;
            mLeftShadowView.setLayoutParams(leftLp);

            mRightShadowView = new View(mLayout.getContext());
            mRightShadowView.setBackgroundResource(R.drawable.infobar_shadow_left);
            LayoutParams rightLp = new FrameLayout.LayoutParams(0, 0, Gravity.RIGHT);
            rightLp.rightMargin = -mShadowWidth;
            mRightShadowView.setScaleX(-1f);
            mRightShadowView.setLayoutParams(rightLp);
        }
    }

    /**
     * The height of back infobars, i.e. the distance between the top of the front infobar and the
     * top of the next infobar back.
     */
    private final int mBackInfobarHeight;

    /**
     * All the Items, in front to back order.
     * This list is updated immediately when addInfoBar(), removeInfoBar(), and swapInfoBar() are
     * called; so during animations, it does *not* match the currently visible views.
     */
    private final ArrayList<InfoBarUiItem> mItems = new ArrayList<>();

    /** The currently visible InfoBarWrappers, in front to back order. */
    private final ArrayList<InfoBarWrapper> mInfoBarWrappers = new ArrayList<>();

    /** A observer that is notified when animations finish. */
    private final InfoBarAnimationListener mAnimationListener;

    /** The current animation, or null if no animation is happening currently. */
    private InfoBarAnimation mAnimation;

    private FloatingBehavior mFloatingBehavior;

    /** The runnable to make infobar container fully visible. */
    private Runnable mMakeContainerVisibleRunnable;

    /**
     * Determines whether any animations need to run in order to make the visible views match the
     * current list of Items in mItems. If so, kicks off the next animation that's needed.
     */
    private void processPendingAnimations() {
        // If an animation is running, wait until it finishes before beginning the next animation.
        if (mAnimation != null) return;

        // The steps below are ordered to minimize movement during animations. In particular,
        // removals happen before additions or swaps, and changes are made to back infobars before
        // front infobars.

        // First, remove any infobars that are no longer in mItems, if any. Check the back infobars
        // before the front.
        for (int i = mInfoBarWrappers.size() - 1; i >= 0; i--) {
            InfoBarUiItem visibleItem = mInfoBarWrappers.get(i).getItem();
            if (!mItems.contains(visibleItem)) {
                if (i == 0 && mInfoBarWrappers.size() >= 2) {
                    // Remove the front infobar and reveal the second-to-front infobar.
                    runAnimation(new FrontInfoBarDisappearingAndRevealingAnimation());
                    return;

                } else {
                    // Move the infobar to the very back if it's not already there.
                    InfoBarWrapper wrapper = mInfoBarWrappers.get(i);
                    if (i != mInfoBarWrappers.size() - 1) {
                        removeWrapper(wrapper);
                        addWrapper(wrapper);
                    }

                    // Remove the backmost infobar (which may be the front infobar).
                    runAnimation(new InfoBarDisappearingAnimation());
                    return;
                }
            }
        }

        // Second, run swap animation on front infobar if needed.
        if (!mInfoBarWrappers.isEmpty()) {
            InfoBarUiItem frontItem = mInfoBarWrappers.get(0).getItem();
            View frontContents = mInfoBarWrappers.get(0).getChildAt(0);
            if (frontContents != frontItem.getView()) {
                runAnimation(new FrontInfoBarSwapContentsAnimation());
                return;
            }
        }

        // Third, check if we should add any infobars in front of visible infobars. This can happen
        // if an infobar has been inserted into mItems, in front of the currently visible item. To
        // detect this the items at the beginning of mItems are compared against the first item in
        // mInfoBarWrappers.
        if (!mInfoBarWrappers.isEmpty()) {
            // Find the infobar with the highest index that isn't currently being shown.
            InfoBarUiItem currentVisibleItem = mInfoBarWrappers.get(0).getItem();
            InfoBarUiItem itemToInsert = null;
            for (int checkIndex = 0; checkIndex < mItems.size(); checkIndex++) {
                if (mItems.get(checkIndex) == currentVisibleItem) {
                    // There are no remaining infobars that can possibly override the
                    // currently displayed one.
                    break;
                } else {
                    // Found an infobar that isn't being displayed yet.  Track it so that
                    // it can be animated in.
                    itemToInsert = mItems.get(checkIndex);
                }
            }
            if (itemToInsert != null) {
                runAnimation(new FrontInfoBarAppearingAnimation(itemToInsert));
                return;
            }
        }

        // Fourth, check if we should add any infobars at the back.
        int desiredChildCount = Math.min(mItems.size(), MAX_STACK_DEPTH);
        if (mInfoBarWrappers.size() < desiredChildCount) {
            InfoBarUiItem itemToShow = mItems.get(mInfoBarWrappers.size());
            runAnimation(
                    mInfoBarWrappers.isEmpty()
                            ? new FirstInfoBarAppearingAnimation(itemToShow)
                            : new BackInfoBarAppearingAnimation(itemToShow));
            return;
        }

        // Fifth, now that we've stabilized, let listeners know that we have no more animations.
        InfoBarUiItem frontItem =
                mInfoBarWrappers.size() > 0 ? mInfoBarWrappers.get(0).getItem() : null;
        mAnimationListener.notifyAllAnimationsFinished(frontItem);
    }

    private void runAnimation(InfoBarAnimation animation) {
        mAnimation = animation;
        mAnimation.prepareAnimation();
        if (isLayoutRequested()) {
            // onLayout() will call mAnimation.start().
        } else {
            mAnimation.start();
        }
    }

    private void addWrapper(InfoBarWrapper wrapper) {
        addView(wrapper, 0, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
        mInfoBarWrappers.add(wrapper);
        updateLayoutParams();
    }

    private void addWrapperToFront(InfoBarWrapper wrapper) {
        addView(wrapper, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
        mInfoBarWrappers.add(0, wrapper);
        updateLayoutParams();
    }

    private void removeWrapper(InfoBarWrapper wrapper) {
        removeView(wrapper);
        mInfoBarWrappers.remove(wrapper);
        updateLayoutParams();
    }

    private void updateLayoutParams() {
        // Stagger the top margins so the back infobars peek out a bit.
        int childCount = mInfoBarWrappers.size();
        for (int i = 0; i < childCount; i++) {
            View child = mInfoBarWrappers.get(i);
            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            lp.topMargin = (childCount - 1 - i) * mBackInfobarHeight;
            child.setLayoutParams(lp);
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        widthMeasureSpec = mFloatingBehavior.beforeOnMeasure(widthMeasureSpec);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mFloatingBehavior.afterOnMeasure(getMeasuredHeight());
    }

    @Override
    public void announceForAccessibility(CharSequence text) {
        if (TextUtils.isEmpty(text)) return;
        super.announceForAccessibility(text);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        mFloatingBehavior.updateShadowPosition();

        // Animations start after a layout has completed, at which point all views are guaranteed
        // to have valid sizes and positions.
        if (mAnimation != null && !mAnimation.isStarted()) {
            mAnimation.start();
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        // Trap any attempts to fiddle with the infobars while we're animating.
        return super.onInterceptTouchEvent(ev)
                || mAnimation != null
                || (!mInfoBarWrappers.isEmpty()
                        && !mInfoBarWrappers.get(0).getItem().areControlsEnabled());
    }

    @Override
    @SuppressLint("ClickableViewAccessibility")
    public boolean onTouchEvent(MotionEvent event) {
        super.onTouchEvent(event);
        // Consume all touch events so they do not reach the ContentView.
        return true;
    }

    @Override
    public boolean onHoverEvent(MotionEvent event) {
        super.onHoverEvent(event);
        // Consume all hover events so they do not reach the ContentView. In touch exploration mode,
        // this prevents the user from interacting with the part of the ContentView behind the
        // infobars. http://crbug.com/430701
        return true;
    }
}