// Copyright 2014 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.banners; import android.animation.Animator; import android.animation.Animator.AnimatorListener; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.content.Context; import android.graphics.Region; import android.util.AttributeSet; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.view.animation.Interpolator; import android.widget.FrameLayout; import org.chromium.chrome.browser.tab.TabContentViewParent; import org.chromium.content.browser.ContentViewCore; import org.chromium.content_public.browser.GestureStateListener; /** * View that slides up from the bottom of the page and slides away as the user scrolls the page. * Meant to be tacked onto the {@link org.chromium.content.browser.ContentViewCore}'s view and * alerted when either the page scroll position or viewport size changes. * * GENERAL BEHAVIOR * This View is brought onto the screen by sliding upwards from the bottom of the screen. Afterward * the View slides onto and off of the screen vertically as the user scrolls upwards or * downwards on the page. * * VERTICAL SCROLL CALCULATIONS * To determine how close the user is to the top of the page, the View must not only be informed of * page scroll position changes, but also of changes in the viewport size (which happens as the * omnibox appears and disappears, or as the page rotates e.g.). When the viewport size gradually * shrinks, the user is most likely to be scrolling the page downwards while the omnibox comes back * into view. * * When the user first begins scrolling the page, both the scroll position and the viewport size are * summed and recorded together. This is because a pixel change in the viewport height is * equivalent to a pixel change in the content's scroll offset: * - As the user scrolls the page downward, either the viewport height will increase (as the omnibox * is slid off of the screen) or the content scroll offset will increase. * - As the user scrolls the page upward, either the viewport height will decrease (as the omnibox * is brought back onto the screen) or the content scroll offset will decrease. * * As the scroll offset or the viewport height are updated via a scroll or fling, the difference * from the initial value is used to determine the View's Y-translation. If a gesture is stopped, * the View will be snapped back into the center of the screen or entirely off of the screen, based * on how much of the View is visible, or where the user is currently located on the page. */ public abstract class SwipableOverlayView extends FrameLayout { private static final float FULL_THRESHOLD = 0.5f; private static final float VERTICAL_FLING_SHOW_THRESHOLD = 0.2f; private static final float VERTICAL_FLING_HIDE_THRESHOLD = 0.9f; private static final int GESTURE_NONE = 0; private static final int GESTURE_SCROLLING = 1; private static final int GESTURE_FLINGING = 2; private static final long ANIMATION_DURATION_MS = 250; // Detects when the user is dragging the ContentViewCore. private final GestureStateListener mGestureStateListener; // Listens for changes in the layout. private final View.OnLayoutChangeListener mLayoutChangeListener; // Monitors for animation completions and resets the state. private final AnimatorListener mAnimatorListener; // Interpolator used for the animation. private final Interpolator mInterpolator; // Tracks whether the user is scrolling or flinging. private int mGestureState; // Animation currently being used to translate the View. private Animator mCurrentAnimation; // Used to determine when the layout has changed and the Viewport must be updated. private int mParentHeight; // Location of the View when the current gesture was first started. private float mInitialTranslationY; // Offset from the top of the page when the current gesture was first started. private int mInitialOffsetY; // How tall the View is, including its margins. private int mTotalHeight; // Whether or not the View ever been fully displayed. private boolean mIsBeingDisplayedForFirstTime; // The ContentViewCore to which the overlay is added. private ContentViewCore mContentViewCore; /** * Creates a SwipableOverlayView. * @param context Context for acquiring resources. * @param attrs Attributes from the XML layout inflation. */ public SwipableOverlayView(Context context, AttributeSet attrs) { super(context, attrs); mGestureStateListener = createGestureStateListener(); mGestureState = GESTURE_NONE; mLayoutChangeListener = createLayoutChangeListener(); mAnimatorListener = createAnimatorListener(); mInterpolator = new DecelerateInterpolator(1.0f); // We make this view 'draw' to provide a placeholder for its animations. setWillNotDraw(false); } /** * Watches the given ContentViewCore for scrolling changes. */ public void setContentViewCore(ContentViewCore contentViewCore) { if (mContentViewCore != null) { mContentViewCore.removeGestureStateListener(mGestureStateListener); } mContentViewCore = contentViewCore; if (mContentViewCore != null) { mContentViewCore.addGestureStateListener(mGestureStateListener); } } /** * @return the ContentViewCore that this View is monitoring. */ protected ContentViewCore getContentViewCore() { return mContentViewCore; } protected void addToParentView(TabContentViewParent parentView) { if (getParent() == null) { parentView.addInfobarView(this, createLayoutParams()); // Listen for the layout to know when to animate the View coming onto the screen. addOnLayoutChangeListener(mLayoutChangeListener); } } /** * Removes the SwipableOverlayView from its parent and stops monitoring the ContentViewCore. * @return Whether the View was removed from its parent. */ public boolean removeFromParentView() { if (getParent() == null) return false; ((ViewGroup) getParent()).removeView(this); removeOnLayoutChangeListener(mLayoutChangeListener); return true; } /** * Creates a set of LayoutParams that makes the View hug the bottom of the screen. Override it * for other types of behavior. * @return LayoutParams for use when adding the View to its parent. */ public ViewGroup.MarginLayoutParams createLayoutParams() { return new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); if (!isAllowedToAutoHide()) setTranslationY(0.0f); } @Override public void onWindowFocusChanged(boolean hasWindowFocus) { super.onWindowFocusChanged(hasWindowFocus); if (!isAllowedToAutoHide()) setTranslationY(0.0f); } /** * See {@link #android.view.ViewGroup.onLayout(boolean, int, int, int, int)}. */ @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { // Update the viewport height when the parent View's height changes (e.g. after rotation). int currentParentHeight = getParent() == null ? 0 : ((View) getParent()).getHeight(); if (mParentHeight != currentParentHeight) { mParentHeight = currentParentHeight; mGestureState = GESTURE_NONE; if (mCurrentAnimation != null) mCurrentAnimation.end(); } // Update the known effective height of the View. MarginLayoutParams params = (MarginLayoutParams) getLayoutParams(); mTotalHeight = getMeasuredHeight() + params.topMargin + params.bottomMargin; super.onLayout(changed, l, t, r, b); } /** * Creates a listener than monitors the ContentViewCore for scrolls and flings. * The listener updates the location of this View to account for the user's gestures. * @return GestureStateListener to send to the ContentViewCore. */ private GestureStateListener createGestureStateListener() { return new GestureStateListener() { @Override public void onFlingStartGesture(int scrollOffsetY, int scrollExtentY) { if (!isAllowedToAutoHide() || !cancelCurrentAnimation()) return; beginGesture(scrollOffsetY, scrollExtentY); mGestureState = GESTURE_FLINGING; } @Override public void onFlingEndGesture(int scrollOffsetY, int scrollExtentY) { if (mGestureState != GESTURE_FLINGING) return; mGestureState = GESTURE_NONE; int finalOffsetY = computeScrollDifference(scrollOffsetY, scrollExtentY); updateTranslation(scrollOffsetY, scrollExtentY); boolean isScrollingDownward = finalOffsetY > 0; boolean isVisibleInitially = mInitialTranslationY < mTotalHeight; float percentageVisible = 1.0f - (getTranslationY() / mTotalHeight); float visibilityThreshold = isVisibleInitially ? VERTICAL_FLING_HIDE_THRESHOLD : VERTICAL_FLING_SHOW_THRESHOLD; boolean isVisibleEnough = percentageVisible > visibilityThreshold; boolean show = !isScrollingDownward; if (isVisibleInitially) { // Check if the View was moving off-screen. boolean isHiding = getTranslationY() > mInitialTranslationY; show &= isVisibleEnough || !isHiding; } else { // When near the top of the page, there's not much room left to scroll. boolean isNearTopOfPage = finalOffsetY < (mTotalHeight * FULL_THRESHOLD); show &= isVisibleEnough || isNearTopOfPage; } createVerticalSnapAnimation(show); } @Override public void onScrollStarted(int scrollOffsetY, int scrollExtentY) { if (!isAllowedToAutoHide() || !cancelCurrentAnimation()) return; beginGesture(scrollOffsetY, scrollExtentY); mGestureState = GESTURE_SCROLLING; } @Override public void onScrollEnded(int scrollOffsetY, int scrollExtentY) { if (mGestureState != GESTURE_SCROLLING) return; mGestureState = GESTURE_NONE; int finalOffsetY = computeScrollDifference(scrollOffsetY, scrollExtentY); updateTranslation(scrollOffsetY, scrollExtentY); boolean isNearTopOfPage = finalOffsetY < (mTotalHeight * FULL_THRESHOLD); boolean isVisibleEnough = getTranslationY() < mTotalHeight * FULL_THRESHOLD; createVerticalSnapAnimation(isNearTopOfPage || isVisibleEnough); } @Override public void onScrollOffsetOrExtentChanged(int scrollOffsetY, int scrollExtentY) { // This function is called for both fling and scrolls. if (mGestureState == GESTURE_NONE || !cancelCurrentAnimation()) return; updateTranslation(scrollOffsetY, scrollExtentY); } private void updateTranslation(int scrollOffsetY, int scrollExtentY) { float translation = mInitialTranslationY + computeScrollDifference(scrollOffsetY, scrollExtentY); translation = Math.max(0.0f, Math.min(mTotalHeight, translation)); setTranslationY(translation); } }; } /** * Creates a listener that is used only to animate the View coming onto the screen. * @return The SimpleOnGestureListener that will monitor the View. */ private View.OnLayoutChangeListener createLayoutChangeListener() { return new View.OnLayoutChangeListener() { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { removeOnLayoutChangeListener(mLayoutChangeListener); // Animate the View coming in from the bottom of the screen. setTranslationY(mTotalHeight); mIsBeingDisplayedForFirstTime = true; createVerticalSnapAnimation(true); mCurrentAnimation.start(); } }; } /** * Create an animation that snaps the View into position vertically. * @param visible If true, snaps the View to the bottom-center of the screen. If false, * translates the View below the bottom-center of the screen so that it is * effectively invisible. */ private void createVerticalSnapAnimation(boolean visible) { float translationY = visible ? 0.0f : mTotalHeight; float yDifference = Math.abs(translationY - getTranslationY()) / mTotalHeight; long duration = Math.max(0, (long) (ANIMATION_DURATION_MS * yDifference)); mCurrentAnimation = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, translationY); mCurrentAnimation.setDuration(duration); mCurrentAnimation.addListener(mAnimatorListener); mCurrentAnimation.setInterpolator(mInterpolator); mCurrentAnimation.start(); } private int computeScrollDifference(int scrollOffsetY, int scrollExtentY) { return scrollOffsetY + scrollExtentY - mInitialOffsetY; } /** * Creates an AnimatorListenerAdapter that cleans up after an animation is completed. * @return {@link AnimatorListenerAdapter} to use for animations. */ private AnimatorListener createAnimatorListener() { return new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mGestureState = GESTURE_NONE; mCurrentAnimation = null; mIsBeingDisplayedForFirstTime = false; } }; } /** * Records the conditions of the page when a gesture is initiated. */ private void beginGesture(int scrollOffsetY, int scrollExtentY) { mInitialTranslationY = getTranslationY(); boolean isInitiallyVisible = mInitialTranslationY < mTotalHeight; int startingY = isInitiallyVisible ? scrollOffsetY : Math.min(scrollOffsetY, mTotalHeight); mInitialOffsetY = startingY + scrollExtentY; } /** * Cancels the current animation, unless the View is coming onto the screen for the first time. * @return True if the animation was canceled or wasn't running, false otherwise. */ private boolean cancelCurrentAnimation() { if (mIsBeingDisplayedForFirstTime) return false; if (mCurrentAnimation != null) mCurrentAnimation.cancel(); return true; } /** * @return Whether the SwipableOverlayView is allowed to hide itself on scroll. */ protected boolean isAllowedToAutoHide() { return true; } /** * Override gatherTransparentRegion to make this view's layout a placeholder for its * animations. This is only called during layout, so it doesn't really make sense to apply * post-layout properties like it does by default. Together with setWillNotDraw(false), * this ensures no child animation within this view's layout will be clipped by a SurfaceView. */ @Override public boolean gatherTransparentRegion(Region region) { float translationY = getTranslationY(); setTranslationY(0); boolean result = super.gatherTransparentRegion(region); // Restoring TranslationY invalidates this view unnecessarily. However, this function // is called as part of layout, which implies a full redraw is about to occur anyway. setTranslationY(translationY); return result; } }