1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 package org.chromium.chrome.browser.banners;
7 import android.animation.Animator;
8 import android.animation.AnimatorListenerAdapter;
9 import android.animation.AnimatorSet;
10 import android.animation.ObjectAnimator;
11 import android.animation.PropertyValuesHolder;
12 import android.content.Context;
13 import android.util.AttributeSet;
14 import android.view.GestureDetector;
15 import android.view.GestureDetector.SimpleOnGestureListener;
16 import android.view.Gravity;
17 import android.view.MotionEvent;
18 import android.view.View;
19 import android.view.ViewGroup;
20 import android.view.animation.DecelerateInterpolator;
21 import android.view.animation.Interpolator;
22 import android.widget.FrameLayout;
24 import org.chromium.content.browser.ContentViewCore;
25 import org.chromium.content_public.browser.GestureStateListener;
26 import org.chromium.ui.UiUtils;
29 * View that appears on the screen as the user scrolls on the page and can be swiped away.
30 * Meant to be tacked onto the {@link org.chromium.content.browser.ContentViewCore}'s view and
31 * alerted when either the page scroll position or viewport size changes.
34 * This View is brought onto the screen by sliding upwards from the bottom of the screen. Afterward
35 * the View slides onto and off of the screen vertically as the user scrolls upwards or
36 * downwards on the page. Users dismiss the View by swiping it away horizontally.
38 * VERTICAL SCROLL CALCULATIONS
39 * To determine how close the user is to the top of the page, the View must not only be informed of
40 * page scroll position changes, but also of changes in the viewport size (which happens as the
41 * omnibox appears and disappears, or as the page rotates e.g.). When the viewport size gradually
42 * shrinks, the user is most likely to be scrolling the page downwards while the omnibox comes back
45 * When the user first begins scrolling the page, both the scroll position and the viewport size are
46 * summed and recorded together. This is because a pixel change in the viewport height is
47 * equivalent to a pixel change in the content's scroll offset:
48 * - As the user scrolls the page downward, either the viewport height will increase (as the omnibox
49 * is slid off of the screen) or the content scroll offset will increase.
50 * - As the user scrolls the page upward, either the viewport height will decrease (as the omnibox
51 * is brought back onto the screen) or the content scroll offset will decrease.
53 * As the scroll offset or the viewport height are updated via a scroll or fling, the difference
54 * from the initial value is used to determine the View's Y-translation. If a gesture is stopped,
55 * the View will be snapped back into the center of the screen or entirely off of the screen, based
56 * on how much of the View is visible, or where the user is currently located on the page.
58 * HORIZONTAL SCROLL CALCULATIONS
59 * Horizontal drags and swipes are used to dismiss the View. Translating the View far enough
60 * horizontally (with "enough" defined by the DISMISS_SWIPE_THRESHOLD AND DISMISS_FLING_THRESHOLD)
61 * triggers an animation that removes the View from the hierarchy. Failing to meet the threshold
62 * will result in the View being translated back to the center of the screen.
64 * Because the fling velocity handed in by Android is highly inaccurate and often indicates
65 * that a fling is moving in an opposite direction than expected, the scroll direction is tracked
66 * to determine which direction the user was dragging the View when the fling was initiated. When a
67 * fling is completed, the more forgiving FLING_THRESHOLD is used to determine how far a user must
68 * swipe to dismiss the View rather than try to use the fling velocity.
70 public abstract class SwipableOverlayView extends FrameLayout {
71 private static final float ALPHA_THRESHOLD = 0.25f;
72 private static final float DISMISS_SWIPE_THRESHOLD = 0.75f;
73 private static final float FULL_THRESHOLD = 0.5f;
74 private static final float VERTICAL_FLING_SHOW_THRESHOLD = 0.2f;
75 private static final float VERTICAL_FLING_HIDE_THRESHOLD = 0.9f;
76 protected static final float ZERO_THRESHOLD = 0.001f;
78 private static final int GESTURE_NONE = 0;
79 private static final int GESTURE_SCROLLING = 1;
80 private static final int GESTURE_FLINGING = 2;
82 private static final int DRAGGED_LEFT = -1;
83 private static final int DRAGGED_CANCEL = 0;
84 private static final int DRAGGED_RIGHT = 1;
86 protected static final long MS_ANIMATION_DURATION = 250;
87 private static final long MS_DISMISS_FLING_THRESHOLD = MS_ANIMATION_DURATION * 2;
88 private static final long MS_SLOW_DISMISS = MS_ANIMATION_DURATION * 3;
90 // Detects when the user is dragging the View.
91 private final GestureDetector mGestureDetector;
93 // Detects when the user is dragging the ContentViewCore.
94 private final GestureStateListener mGestureStateListener;
96 // Monitors for animation completions and resets the state.
97 private final AnimatorListenerAdapter mAnimatorListenerAdapter;
99 // Interpolator used for the animation.
100 private final Interpolator mInterpolator;
102 // Tracks whether the user is scrolling or flinging.
103 private int mGestureState;
105 // Animation currently being used to translate the View.
106 private AnimatorSet mCurrentAnimation;
108 // Direction the user is horizontally dragging.
109 private int mDragDirection;
111 // How quickly the user is horizontally dragging.
112 private float mDragXPerMs;
114 // WHen the user first started dragging.
115 private long mDragStartMs;
117 // Used to determine when the layout has changed and the Viewport must be updated.
118 private int mParentHeight;
120 // Location of the View when the current gesture was first started.
121 private float mInitialTranslationY;
123 // Offset from the top of the page when the current gesture was first started.
124 private int mInitialOffsetY;
126 // How tall the View is, including its margins.
127 private int mTotalHeight;
129 // Whether or not the View ever been fully displayed.
130 private boolean mIsBeingDisplayedForFirstTime;
132 // Whether or not the View has been, or is being, dismissed.
133 private boolean mIsDismissed;
135 // The ContentViewCore to which the overlay is added.
136 private ContentViewCore mContentViewCore;
139 * Creates a SwipableOverlayView.
140 * @param context Context for acquiring resources.
141 * @param attrs Attributes from the XML layout inflation.
143 public SwipableOverlayView(Context context, AttributeSet attrs) {
144 super(context, attrs);
145 SimpleOnGestureListener gestureListener = createGestureListener();
146 mGestureDetector = new GestureDetector(context, gestureListener);
147 mGestureStateListener = createGestureStateListener();
148 mGestureState = GESTURE_NONE;
149 mAnimatorListenerAdapter = createAnimatorListenerAdapter();
150 mInterpolator = new DecelerateInterpolator(1.0f);
154 * Adds this View to the given ContentViewCore's view.
155 * @param layout Layout to add this View to.
157 protected void addToView(ContentViewCore contentViewCore) {
158 assert mContentViewCore == null;
159 mContentViewCore = contentViewCore;
160 contentViewCore.getContainerView().addView(this, 0, createLayoutParams());
161 contentViewCore.addGestureStateListener(mGestureStateListener);
163 // Listen for the layout to know when to animate the View coming onto the screen.
164 addOnLayoutChangeListener(createLayoutChangeListener());
168 * Creates a set of LayoutParams that makes the View hug the bottom of the screen. Override it
169 * for other types of behavior.
170 * @return LayoutParams for use when adding the View to its parent.
172 protected ViewGroup.MarginLayoutParams createLayoutParams() {
173 return new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT,
174 Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
178 * Removes the View from its parent.
180 boolean removeFromParent() {
181 if (mContentViewCore != null) {
182 mContentViewCore.getContainerView().removeView(this);
183 mContentViewCore = null;
190 * See {@link #android.view.ViewGroup.onLayout(boolean, int, int, int, int)}.
193 protected void onLayout(boolean changed, int l, int t, int r, int b) {
194 // Hide the View when the keyboard is showing.
195 boolean keyboardIsShowing = UiUtils.isKeyboardShowing(getContext(), this);
196 setVisibility(keyboardIsShowing ? INVISIBLE : VISIBLE);
198 // Update the viewport height when the parent View's height changes (e.g. after rotation).
199 int currentParentHeight = getParent() == null ? 0 : ((View) getParent()).getHeight();
200 if (mParentHeight != currentParentHeight) {
201 mParentHeight = currentParentHeight;
202 mGestureState = GESTURE_NONE;
203 if (mCurrentAnimation != null) mCurrentAnimation.end();
206 // Update the known effective height of the View.
207 mTotalHeight = getMeasuredHeight();
208 if (getLayoutParams() instanceof MarginLayoutParams) {
209 MarginLayoutParams params = (MarginLayoutParams) getLayoutParams();
210 mTotalHeight += params.topMargin + params.bottomMargin;
213 super.onLayout(changed, l, t, r, b);
217 * See {@link #android.view.View.onTouchEvent(MotionEvent)}.
220 public boolean onTouchEvent(MotionEvent event) {
221 if (mGestureDetector.onTouchEvent(event)) return true;
222 if (mCurrentAnimation != null) return true;
224 int action = event.getActionMasked();
225 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
226 onFinishHorizontalGesture();
233 * Creates a listener that monitors horizontal gestures performed on the View.
234 * @return The SimpleOnGestureListener that will monitor the View.
236 private SimpleOnGestureListener createGestureListener() {
237 return new SimpleOnGestureListener() {
239 public boolean onDown(MotionEvent e) {
240 mGestureState = GESTURE_SCROLLING;
241 mDragDirection = DRAGGED_CANCEL;
243 mDragStartMs = e.getEventTime();
248 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distX, float distY) {
249 float distance = e2.getX() - e1.getX();
250 setTranslationX(getTranslationX() + distance);
251 setAlpha(calculateAnimationAlpha());
253 // Because the Android-calculated fling velocity is highly unreliable, we track what
254 // direction the user is dragging the View from here.
255 mDragDirection = distance < 0 ? DRAGGED_LEFT : DRAGGED_RIGHT;
260 public boolean onFling(MotionEvent e1, MotionEvent e2, float vX, float vY) {
261 mGestureState = GESTURE_FLINGING;
263 // The direction and speed of the Android-given velocity feels completely disjoint
264 // from what the user actually perceives.
265 float androidXPerMs = Math.abs(vX) / 1000.0f;
267 // Track how quickly the user has translated the view to this point.
268 float dragXPerMs = Math.abs(getTranslationX()) / (e2.getEventTime() - mDragStartMs);
270 // Check if the velocity from the user's drag is higher; if so, use that one
271 // instead since that often feels more correct.
272 mDragXPerMs = mDragDirection * Math.max(androidXPerMs, dragXPerMs);
273 onFinishHorizontalGesture();
278 public boolean onSingleTapConfirmed(MotionEvent e) {
284 public void onShowPress(MotionEvent e) {
291 * Called at the end of a user gesture on the banner to either return the banner to a neutral
292 * position in the center of the screen or dismiss it entirely.
294 private void onFinishHorizontalGesture() {
295 mDragDirection = determineFinalHorizontalLocation();
296 if (mDragDirection == DRAGGED_CANCEL) {
297 // Move the View back to the center of the screen.
298 createHorizontalSnapAnimation(true);
300 // User swiped the View away. Dismiss it.
307 * Creates a listener than monitors the ContentViewCore for scrolls and flings.
308 * The listener updates the location of this View to account for the user's gestures.
309 * @return GestureStateListener to send to the ContentViewCore.
311 private GestureStateListener createGestureStateListener() {
312 return new GestureStateListener() {
314 public void onFlingStartGesture(int vx, int vy, int scrollOffsetY, int scrollExtentY) {
315 if (!cancelCurrentAnimation()) return;
316 beginGesture(scrollOffsetY, scrollExtentY);
317 mGestureState = GESTURE_FLINGING;
321 public void onFlingEndGesture(int scrollOffsetY, int scrollExtentY) {
322 if (mGestureState != GESTURE_FLINGING) return;
323 mGestureState = GESTURE_NONE;
325 int finalOffsetY = computeScrollDifference(scrollOffsetY, scrollExtentY);
326 updateTranslation(scrollOffsetY, scrollExtentY);
328 boolean isScrollingDownward = finalOffsetY > 0;
330 boolean isVisibleInitially = mInitialTranslationY < mTotalHeight;
331 float percentageVisible = 1.0f - (getTranslationY() / mTotalHeight);
332 float visibilityThreshold = isVisibleInitially
333 ? VERTICAL_FLING_HIDE_THRESHOLD : VERTICAL_FLING_SHOW_THRESHOLD;
334 boolean isVisibleEnough = percentageVisible > visibilityThreshold;
336 boolean show = !isScrollingDownward;
337 if (isVisibleInitially) {
338 // Check if the View was moving off-screen.
339 boolean isHiding = getTranslationY() > mInitialTranslationY;
340 show &= isVisibleEnough || !isHiding;
342 // When near the top of the page, there's not much room left to scroll.
343 boolean isNearTopOfPage = finalOffsetY < (mTotalHeight * FULL_THRESHOLD);
344 show &= isVisibleEnough || isNearTopOfPage;
346 createVerticalSnapAnimation(show);
350 public void onScrollStarted(int scrollOffsetY, int scrollExtentY) {
351 if (!cancelCurrentAnimation()) return;
352 beginGesture(scrollOffsetY, scrollExtentY);
353 mGestureState = GESTURE_SCROLLING;
357 public void onScrollEnded(int scrollOffsetY, int scrollExtentY) {
358 if (mGestureState != GESTURE_SCROLLING) return;
359 mGestureState = GESTURE_NONE;
361 int finalOffsetY = computeScrollDifference(scrollOffsetY, scrollExtentY);
362 updateTranslation(scrollOffsetY, scrollExtentY);
364 boolean isNearTopOfPage = finalOffsetY < (mTotalHeight * FULL_THRESHOLD);
365 boolean isVisibleEnough = getTranslationY() < mTotalHeight * FULL_THRESHOLD;
366 createVerticalSnapAnimation(isNearTopOfPage || isVisibleEnough);
370 public void onScrollOffsetOrExtentChanged(int scrollOffsetY, int scrollExtentY) {
371 // This function is called for both fling and scrolls.
372 if (mGestureState == GESTURE_NONE || !cancelCurrentAnimation()) return;
373 updateTranslation(scrollOffsetY, scrollExtentY);
376 private void updateTranslation(int scrollOffsetY, int scrollExtentY) {
377 float translation = mInitialTranslationY
378 + computeScrollDifference(scrollOffsetY, scrollExtentY);
379 translation = Math.max(0.0f, Math.min(mTotalHeight, translation));
380 setTranslationY(translation);
386 * Creates a listener that is used only to animate the View coming onto the screen.
387 * @return The SimpleOnGestureListener that will monitor the View.
389 private View.OnLayoutChangeListener createLayoutChangeListener() {
390 return new View.OnLayoutChangeListener() {
392 public void onLayoutChange(View v, int left, int top, int right, int bottom,
393 int oldLeft, int oldTop, int oldRight, int oldBottom) {
394 removeOnLayoutChangeListener(this);
396 // Animate the View coming in from the bottom of the screen.
397 setTranslationY(mTotalHeight);
398 mIsBeingDisplayedForFirstTime = true;
399 createVerticalSnapAnimation(true);
400 mCurrentAnimation.start();
406 * Create an animation that snaps the View into position vertically.
407 * @param visible If true, snaps the View to the bottom-center of the screen. If false,
408 * translates the View below the bottom-center of the screen so that it is
409 * effectively invisible.
411 void createVerticalSnapAnimation(boolean visible) {
412 float translationY = visible ? 0.0f : mTotalHeight;
413 float yDifference = Math.abs(translationY - getTranslationY()) / mTotalHeight;
414 long duration = (long) (MS_ANIMATION_DURATION * yDifference);
415 createAnimation(1.0f, 0, translationY, duration);
419 * Create an animation that snaps the View into position horizontally.
420 * @param visible If true, snaps the View to the bottom-center of the screen. If false,
421 * translates the View to the side of the screen.
423 private void createHorizontalSnapAnimation(boolean visible) {
425 // Move back to the center of the screen.
426 createAnimation(1.0f, 0.0f, getTranslationY(), MS_ANIMATION_DURATION);
428 if (mDragDirection == DRAGGED_CANCEL) {
429 // No direction was selected
430 mDragDirection = DRAGGED_LEFT;
433 float finalX = mDragDirection * getWidth();
435 // Determine how long it will take for the banner to leave the screen.
436 long duration = MS_ANIMATION_DURATION;
437 switch (mGestureState) {
438 case GESTURE_FLINGING:
439 duration = (long) calculateMsRequiredToFlingOffScreen();
442 // Explicitly use a slow animation to help educate the user about swiping.
443 duration = MS_SLOW_DISMISS;
449 createAnimation(0.0f, finalX, getTranslationY(), duration);
454 * Dismisses the View, animating it moving off of the screen if needed.
455 * @param horizontally True if the View is being dismissed to the side of the screen.
457 protected boolean dismiss(boolean horizontally) {
458 if (getParent() == null || mIsDismissed) return false;
462 createHorizontalSnapAnimation(false);
464 createVerticalSnapAnimation(false);
470 * @return Whether or not the View has been dismissed.
472 protected boolean isDismissed() {
477 * Calculates how transparent the View should be.
479 * The transparency value is proportional to how far the View has been swiped away from the
480 * center of the screen. The {@link ALPHA_THRESHOLD} determines at what point the View should
482 * @return The alpha value to use for the View.
484 private float calculateAnimationAlpha() {
485 float percentageSwiped = Math.abs(getTranslationX() / getWidth());
486 float percentageAdjusted = Math.max(0.0f, percentageSwiped - ALPHA_THRESHOLD);
487 float alphaRange = 1.0f - ALPHA_THRESHOLD;
488 return 1.0f - percentageAdjusted / alphaRange;
491 private int computeScrollDifference(int scrollOffsetY, int scrollExtentY) {
492 return scrollOffsetY + scrollExtentY - mInitialOffsetY;
496 * Determine where the View needs to move. If the user hasn't tried hard enough to dismiss
497 * the View, move it back to the center.
498 * @return DRAGGED_CANCEL if the View should return to a neutral center position.
499 * DRAGGED_LEFT if the View should be dismissed to the left.
500 * DRAGGED_RIGHT if the View should be dismissed to the right.
502 private int determineFinalHorizontalLocation() {
503 if (mGestureState == GESTURE_FLINGING) {
504 // Because of the unreliability of the fling velocity, we ignore it and instead rely on
505 // the direction the user was last dragging the View. Moreover, we lower the
506 // translation threshold for dismissal, requiring the View to translate off screen
507 // within a reasonable time frame.
508 float msRequired = calculateMsRequiredToFlingOffScreen();
509 if (msRequired > MS_DISMISS_FLING_THRESHOLD) return DRAGGED_CANCEL;
510 } else if (mGestureState == GESTURE_SCROLLING) {
511 // Check if the user has dragged the View far enough to be dismissed.
512 float dismissPercentage = DISMISS_SWIPE_THRESHOLD;
513 float dismissThreshold = getWidth() * dismissPercentage;
514 if (Math.abs(getTranslationX()) < dismissThreshold) return DRAGGED_CANCEL;
517 return mDragDirection;
521 * Assuming a linear velocity, determine how long it would take for the View to translate off
524 private float calculateMsRequiredToFlingOffScreen() {
525 float remainingDifference = mDragDirection * getWidth() - getTranslationX();
526 return Math.abs(remainingDifference / mDragXPerMs);
530 * Creates an animation that slides the View to the given location and visibility.
531 * @param alpha How opaque the View should be at the end.
532 * @param x X-coordinate of the final translation.
533 * @param y Y-coordinate of the final translation.
534 * @param duration How long the animation should run for.
536 private void createAnimation(float alpha, float x, float y, long duration) {
537 Animator alphaAnimator =
538 ObjectAnimator.ofPropertyValuesHolder(this,
539 PropertyValuesHolder.ofFloat("alpha", getAlpha(), alpha));
540 Animator translationXAnimator =
541 ObjectAnimator.ofPropertyValuesHolder(this,
542 PropertyValuesHolder.ofFloat("translationX", getTranslationX(), x));
543 Animator translationYAnimator =
544 ObjectAnimator.ofPropertyValuesHolder(this,
545 PropertyValuesHolder.ofFloat("translationY", getTranslationY(), y));
547 mCurrentAnimation = new AnimatorSet();
548 mCurrentAnimation.setDuration(duration);
549 mCurrentAnimation.playTogether(alphaAnimator, translationXAnimator, translationYAnimator);
550 mCurrentAnimation.addListener(mAnimatorListenerAdapter);
551 mCurrentAnimation.setInterpolator(mInterpolator);
552 mCurrentAnimation.start();
556 * Creates an AnimatorListenerAdapter that cleans up after an animation is completed.
557 * @return {@link AnimatorListenerAdapter} to use for animations.
559 private AnimatorListenerAdapter createAnimatorListenerAdapter() {
560 return new AnimatorListenerAdapter() {
562 public void onAnimationEnd(Animator animation) {
563 if (mIsDismissed) removeFromParent();
565 mGestureState = GESTURE_NONE;
566 mCurrentAnimation = null;
567 mIsBeingDisplayedForFirstTime = false;
573 * Records the conditions of the page when a gesture is initiated.
575 private void beginGesture(int scrollOffsetY, int scrollExtentY) {
576 mInitialTranslationY = getTranslationY();
577 boolean isInitiallyVisible = mInitialTranslationY < mTotalHeight;
578 int startingY = isInitiallyVisible ? scrollOffsetY : Math.min(scrollOffsetY, mTotalHeight);
579 mInitialOffsetY = startingY + scrollExtentY;
583 * Cancels the current animation, if the View isn't being dismissed.
584 * @return True if the animation was canceled or wasn't running, false otherwise.
586 private boolean cancelCurrentAnimation() {
587 if (!mayCancelCurrentAnimation()) return false;
588 if (mCurrentAnimation != null) mCurrentAnimation.cancel();
593 * Determines whether or not the animation can be interrupted. Animations may not be canceled
594 * when the View is being dismissed or when it's coming onto screen for the first time.
595 * @return Whether or not the animation may be interrupted.
597 private boolean mayCancelCurrentAnimation() {
598 return !mIsBeingDisplayedForFirstTime && !mIsDismissed;
602 * Called when the View has been swiped away by the user.
604 protected abstract void onViewSwipedAway();
607 * Called when the View has been clicked.
609 protected abstract void onViewClicked();
612 * Called when the View needs to show that it's been pressed.
614 protected abstract void onViewPressed(MotionEvent event);