Index: chrome/android/java/src/org/chromium/chrome/browser/widget/BottomSheet.java |
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/widget/BottomSheet.java b/chrome/android/java/src/org/chromium/chrome/browser/widget/BottomSheet.java |
new file mode 100644 |
index 0000000000000000000000000000000000000000..a0b7dc510640ec20f133586be20a6bc677150fdf |
--- /dev/null |
+++ b/chrome/android/java/src/org/chromium/chrome/browser/widget/BottomSheet.java |
@@ -0,0 +1,385 @@ |
+// Copyright 2017 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.widget; |
+ |
+import android.animation.Animator; |
+import android.animation.AnimatorListenerAdapter; |
+import android.animation.ObjectAnimator; |
+import android.content.Context; |
+import android.util.AttributeSet; |
+import android.view.GestureDetector; |
+import android.view.MotionEvent; |
+import android.view.VelocityTracker; |
+import android.view.View; |
+import android.view.ViewGroup; |
+import android.view.animation.DecelerateInterpolator; |
+import android.view.animation.Interpolator; |
+import android.widget.LinearLayout; |
+ |
+import org.chromium.chrome.R; |
+ |
+/** |
+ * This class defines the behavior of a bottom sheet that has multiple states and a persistently |
+ * showing toolbar. Namely, the states are: |
+ * - PEEK: Only the toolbar is visible at the bottom of the screen. |
+ * - HALF: The sheet is expanded to consume around half of the screen. |
+ * - FULL: The sheet is expanded to its full height. |
+ * |
+ * All the computation in this file is based off of the bottom of the screen instead of the top |
+ * for simplicity. This means that the bottom of the screen is 0 on the Y axis. |
+ */ |
+public class BottomSheet extends LinearLayout { |
+ /** The different states that the bottom sheet can have. */ |
+ private enum SheetState { PEEK, HALF, FULL } |
Ian Wen
2017/01/11 19:46:54
Nit: use int instead? Enums are slow on Android.
mdjones
2017/01/12 21:26:57
Done.
|
+ |
+ /** The base duration of the settling animation of the sheet. */ |
+ private static final long BASE_ANIMATION_DURATION_MS = 218; |
+ |
+ /** |
+ * The fraction of the way to the next state the sheet must be swiped to animate there when |
+ * released. A smaller value here means a smaller swipe is needed to move the sheet around. |
+ */ |
+ private static final float THRESHOLD_TO_NEXT_STATE = 0.5f; |
+ |
+ /** The minimum y/x ratio that a scroll must have to be considered vertical. */ |
+ private static final float MIN_VERTICAL_SCROLL_SLOPE = 2.0f; |
+ |
+ /** For detecting scroll and fling events on the bottom sheet. */ |
+ private GestureDetector mGestureDetector; |
+ |
+ /** Whether or not the user is scrolling the bottom sheet. */ |
+ private boolean mIsScrolling; |
+ |
+ /** Handle to the views that make up the bottom sheet. */ |
+ private ViewGroup mSheetContentWrapper; |
+ |
+ /** Track the velocity of the user's scrolls to determine up or down direction. */ |
+ private VelocityTracker mVelocityTracker; |
+ |
+ /** The animator used to move the sheet to a fixed state when released by the user. */ |
+ private ObjectAnimator mHeightAnimator; |
Ian Wen
2017/01/11 19:46:54
Nit: how about mSettleAnimator? You mentioned belo
mdjones
2017/01/12 21:26:57
Done.
|
+ |
+ /** The interpolator that the height animator uses. */ |
+ private Interpolator mInterpolator; |
Ian Wen
2017/01/11 19:46:54
Nit: inline the assignment here.
mdjones
2017/01/12 21:26:57
Done.
|
+ |
+ /** The height of the toolbar. */ |
+ private float mToolbarHeight; |
+ |
+ /** The height of the view that contains the bottom sheet. */ |
+ private float mContainerHeight; |
+ |
+ /** The current sheet state. If the sheet is moving, this will be the target state. */ |
+ private SheetState mCurrentState; |
+ |
+ /** |
+ * Information about the different scroll states of the sheet. Order is important for these, |
+ * they go from smallest to largest. |
+ */ |
+ private float[] mStateRatios; |
+ |
+ public BottomSheet(Context context, AttributeSet atts) { |
+ super(context, atts); |
+ |
+ mStateRatios = new float[] {0.0f, 0.55f, 0.95f}; |
+ |
+ mVelocityTracker = VelocityTracker.obtain(); |
+ |
+ mInterpolator = new DecelerateInterpolator(1.0f); |
+ |
+ mGestureDetector = |
+ new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { |
Ian Wen
2017/01/11 19:46:54
Nit: You might want to move SimpleOnGestureListene
mdjones
2017/01/12 21:26:57
Done.
|
+ @Override |
+ public boolean onDown(MotionEvent e) { |
+ return true; |
+ } |
+ |
+ @Override |
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, |
+ float distanceY) { |
+ // Only start scrolling if the scroll is up or down. If the user is already |
+ // scrolling, continue moving the sheet. |
+ if (!mIsScrolling && (distanceX == 0 |
+ || Math.abs(distanceY) / Math.abs(distanceX) |
Ian Wen
2017/01/11 19:46:54
distanceX might be 0 here. Also I think you need t
mdjones
2017/01/12 21:26:57
Done.
|
+ < MIN_VERTICAL_SCROLL_SLOPE)) { |
+ mVelocityTracker.clear(); |
+ return false; |
+ } |
+ |
+ // Cancel the settling animation if it is running so it doesn't conflict |
+ // with where the user wants to move the sheet. |
+ cancelAnimation(); |
+ |
+ mVelocityTracker.addMovement(e2); |
+ |
+ float currentShownRatio = getSheetOffsetFromBottom() / mContainerHeight; |
+ |
+ // If the sheet is in the max position, don't move if the scroll is upward. |
+ if (currentShownRatio >= mStateRatios[mStateRatios.length - 1] |
+ && distanceY > 0) { |
+ mIsScrolling = false; |
+ return false; |
+ } |
+ |
+ // Similarly, if the sheet is in the min position, don't move if the scroll |
+ // is downward. |
+ if (currentShownRatio <= mStateRatios[0] && distanceY < 0) { |
+ mIsScrolling = false; |
+ return false; |
+ } |
+ |
+ setSheetOffsetFromBottom(Math.max(getMinOffset(), |
Ian Wen
2017/01/11 19:46:53
Nit: maybe write a clamp function below, instead o
mdjones
2017/01/12 21:26:57
Done. Used MathUtils.clamp(...)
|
+ Math.min(getMaxOffset(), getSheetOffsetFromBottom() + distanceY))); |
+ |
+ mIsScrolling = true; |
+ return true; |
+ } |
+ |
+ @Override |
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, |
+ float velocityY) { |
+ cancelAnimation(); |
+ |
+ // Figure out the projected state of the sheet and animate there. |
+ // Note that a swipe up will have a negative velocity, swipe down will have |
+ // a positive velocity. Negate this values so that the logic is more |
+ // intuitive. |
+ SheetState targetState = getTargetSheetState( |
+ getSheetOffsetFromBottom() + getFlingDistance(-velocityY), |
+ -velocityY); |
+ setSheetState(targetState, true); |
+ |
+ return true; |
+ } |
+ }); |
+ } |
+ |
+ @Override |
+ public boolean onInterceptTouchEvent(MotionEvent e) { |
+ return true; |
+ } |
+ |
+ @Override |
+ public boolean onTouchEvent(MotionEvent e) { |
+ // Don't trust incoming motion events for the gesture detector. |
Ian Wen
2017/01/11 19:46:54
Hmmm "trust" is ambiguous here.
Are you trying to
mdjones
2017/01/12 21:26:57
The incoming event may have been adjusted. I updat
|
+ MotionEvent rawEvent = MotionEvent.obtain(e); |
+ rawEvent.setLocation(e.getRawX(), e.getRawY()); |
+ mGestureDetector.onTouchEvent(rawEvent); |
+ |
+ // If the user is scrolling and the event is a cancel or up action, update scroll state |
+ // and return. |
+ if (mIsScrolling && (e.getActionMasked() == MotionEvent.ACTION_UP |
+ || e.getActionMasked() == MotionEvent.ACTION_CANCEL)) { |
+ mIsScrolling = false; |
+ |
+ mVelocityTracker.computeCurrentVelocity(1000); |
+ |
+ // If an animation was not created to settle the sheet at some state, do it now. |
+ if (mHeightAnimator == null) { |
+ // Negate velocity so a positive number indicates a swipe up. |
+ float currentVelocity = -mVelocityTracker.getYVelocity(); |
+ SheetState targetState = |
+ getTargetSheetState(getSheetOffsetFromBottom(), currentVelocity); |
+ |
+ setSheetState(targetState, true); |
+ } |
+ |
+ return true; |
+ } |
+ |
+ // Send a cancel event to super if the sheet is scrolling. |
+ if (mIsScrolling) { |
+ MotionEvent cancel = MotionEvent.obtain(e); |
+ cancel.setAction(MotionEvent.ACTION_CANCEL); |
Ian Wen
2017/01/11 19:46:54
You only need to send cancel once. IIUC you are se
mdjones
2017/01/12 21:26:57
Removed the need for this with the code added to o
|
+ mSheetContentWrapper.dispatchTouchEvent(cancel); |
+ return true; |
+ } |
+ |
+ mSheetContentWrapper.dispatchTouchEvent(e); |
+ |
+ return true; |
+ } |
+ |
+ /** |
+ * Add layout change listeners to the views that the bottom sheet depends on. Namely the |
+ * heights of the root view and control container are important as they are used in many of the |
+ * calculations in this class. |
+ * @param root The container of the bottom sheet. |
+ * @param controlContainer The container for the toolbar. |
+ */ |
+ public void init(View root, View controlContainer) { |
+ mSheetContentWrapper = (ViewGroup) findViewById(R.id.bottom_sheet_content_wrapper); |
Ian Wen
2017/01/11 19:46:53
Let's unify the name to either wrapper or containe
mdjones
2017/01/12 21:26:58
Removed the need for this.
|
+ mToolbarHeight = controlContainer.getHeight(); |
+ mCurrentState = SheetState.PEEK; |
+ |
+ // Listen to height changes on the root. |
+ root.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { |
+ public void onLayoutChange(View v, int left, int top, int right, int bottom, |
+ int oldLeft, int oldTop, int oldRight, int oldBottom) { |
+ mContainerHeight = bottom - top; |
+ updateSheetPeekHeight(mToolbarHeight, mContainerHeight); |
+ setSheetState(mCurrentState, false); |
+ } |
+ }); |
+ |
+ // Listen to height changes on the toolbar. |
+ controlContainer.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { |
+ public void onLayoutChange(View v, int left, int top, int right, int bottom, |
+ int oldLeft, int oldTop, int oldRight, int oldBottom) { |
+ mToolbarHeight = bottom - top; |
+ updateSheetPeekHeight(mToolbarHeight, mContainerHeight); |
+ setSheetState(mCurrentState, false); |
+ } |
+ }); |
+ } |
+ |
+ /** |
+ * Update the bottom sheet's peeking height. |
+ * @param toolbarHeight The height of the toolbar control container. |
+ * @param containerHeight The height of the bottom sheet's container. |
+ */ |
+ private void updateSheetPeekHeight(float toolbarHeight, float containerHeight) { |
+ if (containerHeight <= 0) return; |
+ |
+ mStateRatios[0] = toolbarHeight / containerHeight; |
+ } |
+ |
+ /** |
+ * Cancel and null the height animation if it exists. |
+ */ |
+ private void cancelAnimation() { |
+ if (mHeightAnimator == null) return; |
+ mHeightAnimator.cancel(); |
+ mHeightAnimator = null; |
+ } |
+ |
+ /** |
+ * Create the sheet's animation to a target state. |
+ * @param targetState The target state. |
+ */ |
+ private void createYAnimation(SheetState targetState) { |
Ian Wen
2017/01/11 19:46:53
Similarly, let's unify the animations' name as wel
mdjones
2017/01/12 21:26:57
Done.
|
+ mCurrentState = targetState; |
+ mHeightAnimator = ObjectAnimator.ofFloat( |
+ this, View.TRANSLATION_Y, mContainerHeight - getSheetHeightForState(targetState)); |
+ mHeightAnimator.setDuration(BASE_ANIMATION_DURATION_MS); |
+ mHeightAnimator.setInterpolator(mInterpolator); |
+ |
+ // When the animation is canceled or ends, reset the handle to null. |
+ mHeightAnimator.addListener(new AnimatorListenerAdapter() { |
+ @Override |
+ public void onAnimationEnd(Animator animator) { |
+ mHeightAnimator = null; |
+ } |
+ }); |
+ |
+ mHeightAnimator.start(); |
+ } |
+ |
+ /** |
+ * Get the distance of a fling based on the velocity and the base animation time. This formula |
+ * assumes the deceleration curve is quadratic (t^2), hence the displacement formula should be: |
+ * displacement = initialVelocity * duration / 2. |
+ * @param velocity The velocity of the fling. |
+ * @return The distance the fling would cover. |
+ */ |
+ private float getFlingDistance(float velocity) { |
+ // This includes conversion from seconds to ms. |
+ return velocity * BASE_ANIMATION_DURATION_MS / 2000f; |
+ } |
+ |
+ /** |
+ * Get the maximum offset of the bottom sheet. |
+ * @return The max offset. |
+ */ |
+ private float getMaxOffset() { |
+ return mStateRatios[mStateRatios.length - 1] * mContainerHeight; |
+ } |
+ |
+ /** |
+ * Get the minimum offset of the bottom sheet. |
+ * @return The min offset. |
+ */ |
+ private float getMinOffset() { |
+ return mStateRatios[0] * mContainerHeight; |
+ } |
+ |
+ /** |
+ * Get the sheet's offset from the bottom of the screen. |
+ * @return The sheet's distance from the bottom of the screen. |
+ */ |
+ private float getSheetOffsetFromBottom() { |
+ return mContainerHeight - getTranslationY(); |
+ } |
+ |
+ /** |
+ * Set the sheet's offset relative to the bottom of the screen. |
+ * @param offset The offset that the sheet should be. |
+ */ |
+ private void setSheetOffsetFromBottom(float offset) { |
+ setTranslationY(mContainerHeight - offset); |
+ } |
+ |
+ /** |
+ * Move the sheet to the provided state. |
+ * @param state The state to move the panel to. |
+ * @param animate If true, the sheet will animate to the provided state, otherwise it will |
+ * move there instantly. |
+ */ |
+ private void setSheetState(SheetState state, boolean animate) { |
+ mCurrentState = state; |
+ |
+ if (animate) { |
+ createYAnimation(state); |
+ } else { |
+ setSheetOffsetFromBottom(getSheetHeightForState(state)); |
+ } |
+ } |
+ |
+ /** |
+ * Get the height of the bottom sheet based on a provided state. |
+ * @param state The state to get the height from. |
+ * @return The height of the sheet at the provided state. |
+ */ |
+ private float getSheetHeightForState(SheetState state) { |
+ return mStateRatios[state.ordinal()] * mContainerHeight; |
+ } |
+ |
+ /** |
+ * Get the target state of the sheet based on the sheet's height and velocity. |
+ * @param sheetHeight The current height of the sheet. |
+ * @param yVelocity The current Y velocity of the sheet. This is only used for determining the |
Ian Wen
2017/01/11 19:46:53
Please document if yVolocity is positive, the dire
mdjones
2017/01/12 21:26:58
Done.
|
+ * scroll or fling direction. |
+ * @return The target state of the bottom sheet. |
+ */ |
+ private SheetState getTargetSheetState(float sheetHeight, float yVelocity) { |
+ if (sheetHeight <= getMinOffset()) return SheetState.PEEK; |
+ if (sheetHeight >= getMaxOffset()) return SheetState.FULL; |
+ |
+ // First, find the two states that the sheet height is between. |
+ SheetState nextState = SheetState.values()[0]; |
+ SheetState prevState = nextState; |
+ for (SheetState state : SheetState.values()) { |
+ prevState = nextState; |
+ nextState = state; |
+ // The values in PanelState are ascending, they should be kept that way in order for |
+ // this to work. |
+ if (sheetHeight >= getSheetHeightForState(prevState) |
+ && sheetHeight < getSheetHeightForState(nextState)) { |
+ break; |
+ } |
+ } |
+ |
+ // If the desired height is close enough to a certain state, depending on the direction of |
+ // the velocity, move to that state. |
+ float lowerBound = getSheetHeightForState(prevState); |
+ float distance = getSheetHeightForState(nextState) - lowerBound; |
+ float thresholdToNextState = |
+ yVelocity < 0.0f ? THRESHOLD_TO_NEXT_STATE : 1.0f - THRESHOLD_TO_NEXT_STATE; |
+ if ((sheetHeight - lowerBound) / distance > thresholdToNextState) { |
+ return nextState; |
+ } else { |
+ return prevState; |
+ } |
+ } |
+} |