// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.infobar;
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.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.infobar.InfoBarContainer.InfoBarAnimationListener;
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.
*/
class InfoBarContainerLayout extends FrameLayout {
/**
* An interface for items that can be added to an InfoBarContainerLayout.
*/
interface Item {
/**
* Returns the View that represents this infobar. This should have no background or borders;
* a background and shadow will be added by a wrapper view.
*/
View getView();
/**
* Returns whether controls for this View should be clickable. If false, all input events on
* this item will be ignored.
*/
boolean areControlsEnabled();
/**
* Sets whether or not controls for this View should be clickable. This does not affect the
* visual state of the infobar.
* @param state If false, all input events on this Item will be ignored.
*/
void setControlsEnabled(boolean state);
/**
* Returns the accessibility text to announce when this infobar is first shown.
*/
CharSequence getAccessibilityText();
}
/**
* Creates an empty InfoBarContainerLayout.
*/
InfoBarContainerLayout(Context context) {
super(context);
Resources res = context.getResources();
mBackInfobarHeight = res.getDimensionPixelSize(R.dimen.infobar_peeking_height);
mFloatingBehavior = new FloatingBehavior(this);
}
/**
* Adds an infobar to the container. The infobar appearing animation will happen after the
* current animation, if any, finishes.
*/
void addInfoBar(Item item) {
mItems.add(item);
processPendingAnimations();
}
/**
* Removes an infobar from the container. The infobar will be animated off the screen if it's
* currently visible.
*/
void removeInfoBar(Item item) {
mItems.remove(item);
processPendingAnimations();
}
/**
* Notifies that an infobar's View ({@link Item#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.
*/
void notifyInfoBarViewChanged() {
processPendingAnimations();
}
/**
* Returns true if any animations are pending or in progress.
*/
boolean isAnimating() {
return mAnimation != null;
}
/**
* Sets a listener to receive updates when each animation is complete.
*/
void setAnimationListener(InfoBarAnimationListener listener) {
mAnimationListener = listener;
}
/////////////////////////////////////////
// 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) {
mAnimation = null;
InfoBarAnimation.this.onAnimationEnd();
if (mAnimationListener != 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 FrontInfoBarAppearingAnimation extends InfoBarAnimation {
private Item mFrontItem;
private InfoBarWrapper mFrontWrapper;
private View mFrontContents;
FrontInfoBarAppearingAnimation(Item 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 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(Item 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
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<Item> mItems = new ArrayList<>();
/**
* The currently visible InfoBarWrappers, in front to back order.
*/
private final ArrayList<InfoBarWrapper> mInfoBarWrappers = new ArrayList<>();
/** The current animation, or null if no animation is happening currently. */
private InfoBarAnimation mAnimation;
private InfoBarAnimationListener mAnimationListener;
private FloatingBehavior mFloatingBehavior;
/**
* 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--) {
Item 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()) {
Item 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.
int desiredChildCount = Math.min(mItems.size(), MAX_STACK_DEPTH);
if (mInfoBarWrappers.size() < desiredChildCount) {
Item itemToShow = mItems.get(mInfoBarWrappers.size());
runAnimation(mInfoBarWrappers.isEmpty()
? new FrontInfoBarAppearingAnimation(itemToShow)
: new BackInfoBarAppearingAnimation(itemToShow));
}
}
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 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
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;
}
}