summaryrefslogtreecommitdiff
path: root/chromium/components/browser_ui/android
diff options
context:
space:
mode:
Diffstat (limited to 'chromium/components/browser_ui/android')
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/BUILD.gn42
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/DEPS7
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/internal/BUILD.gn44
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheet.java1244
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerFactory.java33
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerImpl.java505
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetector.java269
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetectorTest.java319
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/TouchRestrictingFrameLayout.java50
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/java/res/layout/bottom_sheet.xml44
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/java/res/values/dimens.xml12
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetController.java158
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetObserver.java57
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/EmptyBottomSheetObserver.java30
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/ManagedBottomSheetController.java54
-rw-r--r--chromium/components/browser_ui/android/bottomsheet/test/BUILD.gn16
16 files changed, 2881 insertions, 3 deletions
diff --git a/chromium/components/browser_ui/android/bottomsheet/BUILD.gn b/chromium/components/browser_ui/android/bottomsheet/BUILD.gn
index a9965b2f22d..f43f2943228 100644
--- a/chromium/components/browser_ui/android/bottomsheet/BUILD.gn
+++ b/chromium/components/browser_ui/android/bottomsheet/BUILD.gn
@@ -2,11 +2,47 @@
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
-import("//build/config/android/config.gni")
import("//build/config/android/rules.gni")
+import("//chrome/android/features/android_library_factory_tmpl.gni")
android_library("java") {
- sources = [ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetContent.java" ]
+ sources = [
+ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetContent.java",
+ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetController.java",
+ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetObserver.java",
+ "java/src/org/chromium/components/browser_ui/bottomsheet/EmptyBottomSheetObserver.java",
+ ]
- deps = [ "//third_party/android_deps:androidx_annotation_annotation_java" ]
+ deps = [
+ ":java_resources",
+ "//components/browser_ui/widget/android:java",
+ "//third_party/android_deps:androidx_annotation_annotation_java",
+ "//ui/android:ui_java",
+ ]
+}
+
+android_resources("java_resources") {
+ custom_package = "org.chromium.components.browser_ui.bottomsheet"
+ sources = [
+ "java/res/layout/bottom_sheet.xml",
+ "java/res/values/dimens.xml",
+ ]
+
+ deps = [ "//components/browser_ui/strings/android:browser_ui_strings_grd" ]
+}
+
+# The only dependent on this code outside of this component should be glue.
+android_library("manager_java") {
+ sources = [ "java/src/org/chromium/components/browser_ui/bottomsheet/ManagedBottomSheetController.java" ]
+
+ deps = [ ":java" ]
+}
+
+android_library_factory("factory_java") {
+ sources = [ "internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerFactory.java" ]
+
+ deps = [
+ ":java",
+ ":manager_java",
+ ]
}
diff --git a/chromium/components/browser_ui/android/bottomsheet/DEPS b/chromium/components/browser_ui/android/bottomsheet/DEPS
new file mode 100644
index 00000000000..1ff4639a034
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/DEPS
@@ -0,0 +1,7 @@
+noparent = True
+include_rules = [
+ "+base/android",
+ "+base/test/android",
+ "+components/browser_ui/widget/android",
+ "+ui/android",
+]
diff --git a/chromium/components/browser_ui/android/bottomsheet/internal/BUILD.gn b/chromium/components/browser_ui/android/bottomsheet/internal/BUILD.gn
new file mode 100644
index 00000000000..a81af4a478e
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/internal/BUILD.gn
@@ -0,0 +1,44 @@
+# Copyright 2020 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.
+
+import("//build/config/android/rules.gni")
+
+android_library("java") {
+ visibility = [
+ ":*",
+ "../test:*",
+ "//chrome/android:chrome_all_java",
+ ]
+
+ sources = [
+ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheet.java",
+ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerFactory.java",
+ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerImpl.java",
+ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetector.java",
+ "java/src/org/chromium/components/browser_ui/bottomsheet/TouchRestrictingFrameLayout.java",
+ ]
+
+ deps = [
+ "..:java",
+ "..:java_resources",
+ "..:manager_java",
+ "//base:base_java",
+ "//components/browser_ui/widget/android:java",
+ "//third_party/android_deps:androidx_annotation_annotation_java",
+ "//ui/android:ui_java",
+ ]
+}
+
+android_library("junit_tests") {
+ bypass_platform_checks = true
+ testonly = true
+ sources = [ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetectorTest.java" ]
+ deps = [
+ ":java",
+ "//base:base_java",
+ "//base:base_junit_test_support",
+ "//third_party/junit",
+ "//third_party/mockito:mockito_java",
+ ]
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheet.java b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheet.java
new file mode 100644
index 00000000000..1ced11721f2
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheet.java
@@ -0,0 +1,1244 @@
+// 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.components.browser_ui.bottomsheet;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Interpolator;
+import android.widget.FrameLayout;
+
+import androidx.annotation.DimenRes;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import org.chromium.base.MathUtils;
+import org.chromium.base.ObserverList;
+import org.chromium.components.browser_ui.bottomsheet.BottomSheetContent.HeightMode;
+import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.SheetState;
+import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.StateChangeReason;
+import org.chromium.ui.KeyboardVisibilityDelegate;
+import org.chromium.ui.util.AccessibilityUtil;
+
+/**
+ * This class defines the 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.
+ */
+class BottomSheet extends FrameLayout
+ implements BottomSheetSwipeDetector.SwipeableBottomSheet, View.OnLayoutChangeListener {
+ /**
+ * The base duration of the settling animation of the sheet. 218 ms is a spec for material
+ * design (this is the minimum time a user is guaranteed to pay attention to something).
+ */
+ private static final int BASE_ANIMATION_DURATION_MS = 218;
+
+ /**
+ * The fraction of the way to the next state the sheet must be swiped to animate there when
+ * released. This is the value used when there are 3 active states. A smaller value here means
+ * a smaller swipe is needed to move the sheet around.
+ */
+ private static final float THRESHOLD_TO_NEXT_STATE_3 = 0.4f;
+
+ /** This is similar to {@link #THRESHOLD_TO_NEXT_STATE_3} but for 2 states instead of 3. */
+ private static final float THRESHOLD_TO_NEXT_STATE_2 = 0.3f;
+
+ /** The height ratio for the sheet in the SheetState.HALF state. */
+ private static final float HALF_HEIGHT_RATIO = 0.75f;
+
+ /** The desired height of a content that has just been shown or whose height was invalidated. */
+ private static final float HEIGHT_UNSPECIFIED = -1.0f;
+
+ /** A flag to force the small screen state of the bottom sheet. */
+ private static Boolean sIsSmallScreenForTesting;
+
+ /** The interpolator that the height animator uses. */
+ private final Interpolator mInterpolator = new DecelerateInterpolator(1.0f);
+
+ /** The list of observers of this sheet. */
+ private final ObserverList<BottomSheetObserver> mObservers = new ObserverList<>();
+
+ /** The visible rect for the screen taking the keyboard into account. */
+ private final Rect mVisibleViewportRect = new Rect();
+
+ /** An out-array for use with getLocationInWindow to prevent constant allocations. */
+ private final int[] mCachedLocation = new int[2];
+
+ /** The minimum distance between half and full states to allow the half state. */
+ private final float mMinHalfFullDistance;
+
+ /** The height of the shadow that sits above the toolbar. */
+ private final int mToolbarShadowHeight;
+
+ /** The view that contains the sheet. */
+ private ViewGroup mSheetContainer;
+
+ /** For detecting scroll and fling events on the bottom sheet. */
+ private BottomSheetSwipeDetector mGestureDetector;
+
+ /** The animator used to move the sheet to a fixed state when released by the user. */
+ private ValueAnimator mSettleAnimator;
+
+ /** The width of the view that contains the bottom sheet. */
+ private int mContainerWidth;
+
+ /** The height of the view that contains the bottom sheet. */
+ private int mContainerHeight;
+
+ /** The desired height of the current content view. */
+ private float mContentDesiredHeight = HEIGHT_UNSPECIFIED;
+
+ /**
+ * The current offset of the sheet from the bottom of the screen in px. This does not include
+ * added offset from the scrolling of the browser controls which allows the sheet's toolbar to
+ * show and hide in-sync with the top toolbar.
+ */
+ private float mCurrentOffsetPx;
+
+ /** The current state that the sheet is in. */
+ @SheetState
+ private int mCurrentState = SheetState.HIDDEN;
+
+ /** The target sheet state. This is the state that the sheet is currently moving to. */
+ @SheetState
+ private int mTargetState = SheetState.NONE;
+
+ /** While scrolling, this holds the state the scrolling started in. Otherwise, it's NONE. */
+ @SheetState
+ int mScrollingStartState = SheetState.NONE;
+
+ /** A handle to the content being shown by the sheet. */
+ @Nullable
+ protected BottomSheetContent mSheetContent;
+
+ /** A handle to the FrameLayout that holds the content of the bottom sheet. */
+ private TouchRestrictingFrameLayout mBottomSheetContentContainer;
+
+ /**
+ * The last offset ratio sent to observers of onSheetOffsetChanged(). This is used to ensure the
+ * min and max values are provided at least once (0 and 1).
+ */
+ private float mLastOffsetRatioSent;
+
+ /** The FrameLayout used to hold the bottom sheet toolbar. */
+ private TouchRestrictingFrameLayout mToolbarHolder;
+
+ /**
+ * The default toolbar view. This is shown when the current bottom sheet content doesn't have
+ * its own toolbar and when the bottom sheet is closed.
+ */
+ protected View mDefaultToolbarView;
+
+ /** Whether the {@link BottomSheet} and its children should react to touch events. */
+ private boolean mIsTouchEnabled;
+
+ /** Whether the sheet is currently open. */
+ private boolean mIsSheetOpen;
+
+ /** Whether {@link #destroy()} has been called. **/
+ private boolean mIsDestroyed;
+
+ /** The ratio in the range [0, 1] that the browser controls are hidden. */
+ private float mBrowserControlsHiddenRatio;
+
+ /** A means of checking whether accessibility is currently enabled. */
+ private AccessibilityUtil mAccessibilityUtil;
+
+ @Override
+ public boolean shouldGestureMoveSheet(MotionEvent initialEvent, MotionEvent currentEvent) {
+ // If the sheet is scrolling off-screen or in the process of hiding, gestures should not
+ // affect it.
+ if (getCurrentOffsetPx() < getSheetHeightForState(SheetState.PEEK)
+ || getOffsetFromBrowserControls() > 0) {
+ return false;
+ }
+
+ // If the sheet is already open, the experiment is not enabled, or accessibility is enabled
+ // there is no need to restrict the swipe area.
+ if (isSheetOpen() || mAccessibilityUtil.isAccessibilityEnabled()) {
+ return true;
+ }
+
+ float startX = mVisibleViewportRect.left;
+ float endX = getToolbarView().getWidth() + mVisibleViewportRect.left;
+ return currentEvent.getRawX() > startX && currentEvent.getRawX() < endX;
+ }
+
+ /**
+ * Constructor for inflation from XML.
+ * @param context An Android context.
+ * @param atts The XML attributes.
+ */
+ public BottomSheet(Context context, AttributeSet atts) {
+ super(context, atts);
+
+ mMinHalfFullDistance =
+ getResources().getDimensionPixelSize(R.dimen.bottom_sheet_min_full_half_distance);
+ mToolbarShadowHeight = getResources().getDimensionPixelOffset(getTopShadowResourceId());
+
+ mGestureDetector = new BottomSheetSwipeDetector(context, this);
+ mIsTouchEnabled = true;
+ }
+
+ /** @return The dimen describing the height of the shadow above the bottom sheet. */
+ static @DimenRes int getTopShadowResourceId() {
+ return R.dimen.bottom_sheet_toolbar_shadow_height;
+ }
+
+ static @DimenRes int getShadowTopOffsetResourceId() {
+ return R.dimen.bottom_sheet_shadow_top_offset;
+ }
+
+ /**
+ * Called when the activity containing the {@link BottomSheet} is destroyed.
+ */
+ void destroy() {
+ mIsDestroyed = true;
+ mIsTouchEnabled = false;
+ mObservers.clear();
+ endAnimations();
+ }
+
+ /** @param accessibilityUtil A mechanism for testing whether accessibility is enabled. */
+ void setAccssibilityUtil(AccessibilityUtil accessibilityUtil) {
+ mAccessibilityUtil = accessibilityUtil;
+ }
+
+ /** Immediately end all animations and null the animators. */
+ void endAnimations() {
+ if (mSettleAnimator != null) mSettleAnimator.end();
+ mSettleAnimator = null;
+ }
+
+ /** @return Whether the sheet is in the process of hiding. */
+ boolean isHiding() {
+ return mSettleAnimator != null && mTargetState == SheetState.HIDDEN;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent e) {
+ if (!isTouchEventInUsableArea(e) && e.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ return false;
+ }
+
+ // If touch is disabled, act like a black hole and consume touch events without doing
+ // anything with them.
+ if (!mIsTouchEnabled) return true;
+
+ if (isHiding()) return false;
+
+ return mGestureDetector.onInterceptTouchEvent(e);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent e) {
+ if (!isTouchEventInUsableArea(e) && e.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ return false;
+ }
+
+ // If touch is disabled, act like a black hole and consume touch events without doing
+ // anything with them.
+ if (!mIsTouchEnabled) return true;
+
+ mGestureDetector.onTouchEvent(e);
+
+ return true;
+ }
+
+ @Override
+ public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int heightSize = MeasureSpec.getSize(heightMeasureSpec);
+ assert heightSize != 0;
+ int height = heightSize + mToolbarShadowHeight;
+ int mode = isFullHeightWrapContent() ? MeasureSpec.AT_MOST : MeasureSpec.EXACTLY;
+ super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, mode));
+ }
+
+ /**
+ * Adds 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 window Android window for getting insets.
+ * @param keyboardDelegate Delegate for hiding the keyboard.
+ */
+ public void init(Window window, KeyboardVisibilityDelegate keyboardDelegate) {
+ View root = (View) getParent();
+
+ mToolbarHolder =
+ (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_toolbar_container);
+ mToolbarHolder.setBackgroundResource(
+ org.chromium.components.browser_ui.styles.R.drawable.top_round);
+
+ mDefaultToolbarView = mToolbarHolder.findViewById(R.id.bottom_sheet_toolbar);
+
+ getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
+
+ mBottomSheetContentContainer =
+ (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_content);
+ mBottomSheetContentContainer.setBottomSheet(this);
+ mBottomSheetContentContainer.setBackgroundResource(
+ org.chromium.components.browser_ui.styles.R.drawable.top_round);
+
+ mContainerWidth = root.getWidth();
+ mContainerHeight = root.getHeight();
+
+ // Listen to height changes on the root.
+ root.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+ private int mPreviousKeyboardHeight;
+
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ // Compute the new height taking the keyboard into account.
+ // TODO(mdjones): Share this logic with LocationBarLayout: crbug.com/725725.
+ int previousWidth = mContainerWidth;
+ int previousHeight = mContainerHeight;
+ mContainerWidth = right - left;
+ mContainerHeight = bottom - top;
+
+ if (previousWidth != mContainerWidth || previousHeight != mContainerHeight) {
+ if (mCurrentState == SheetState.HALF && !isHalfStateEnabled()) {
+ setSheetState(SheetState.FULL, false);
+ }
+ invalidateContentDesiredHeight();
+ }
+
+ int heightMinusKeyboard = (int) mContainerHeight;
+ int keyboardHeight = 0;
+
+ // Reset mVisibleViewportRect regardless of sheet open state as it is used outside
+ // of calculating the keyboard height.
+ window.getDecorView().getWindowVisibleDisplayFrame(mVisibleViewportRect);
+ if (isSheetOpen()) {
+ int decorHeight = window.getDecorView().getHeight();
+ heightMinusKeyboard = Math.min(decorHeight, mVisibleViewportRect.height());
+ keyboardHeight = (int) (mContainerHeight - heightMinusKeyboard);
+ }
+
+ if (keyboardHeight != mPreviousKeyboardHeight) {
+ // If the keyboard height changed, recompute the padding for the content area.
+ // This shrinks the content size while retaining the default background color
+ // where the keyboard is appearing. If the sheet is not showing, resize the
+ // sheet to its default state.
+ mBottomSheetContentContainer.setPadding(
+ mBottomSheetContentContainer.getPaddingLeft(),
+ mBottomSheetContentContainer.getPaddingTop(),
+ mBottomSheetContentContainer.getPaddingRight(), keyboardHeight);
+ }
+
+ if (previousHeight != mContainerHeight
+ || mPreviousKeyboardHeight != keyboardHeight) {
+ // If we are in the middle of a touch event stream (i.e. scrolling while
+ // keyboard is up) don't set the sheet state. Instead allow the gesture detector
+ // to position the sheet and make sure the keyboard hides.
+ if (mGestureDetector.isScrolling() && keyboardDelegate != null) {
+ keyboardDelegate.hideKeyboard(BottomSheet.this);
+ } else {
+ cancelAnimation();
+ setSheetState(mCurrentState, false);
+ }
+ }
+
+ mPreviousKeyboardHeight = keyboardHeight;
+ }
+ });
+
+ // Listen to height changes on the toolbar.
+ mToolbarHolder.addOnLayoutChangeListener(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) {
+ // Make sure the size of the layout actually changed.
+ if (bottom - top == oldBottom - oldTop && right - left == oldRight - oldLeft) {
+ return;
+ }
+
+ if (!mGestureDetector.isScrolling() && isRunningSettleAnimation()) return;
+
+ setSheetState(mCurrentState, false);
+ }
+ });
+
+ mSheetContainer = (ViewGroup) this.getParent();
+ mSheetContainer.removeView(this);
+ }
+
+ /** @param ratio The current browser controls hidden ratio. */
+ void setBrowserControlsHiddenRatio(float ratio) {
+ mBrowserControlsHiddenRatio = ratio;
+
+ if (getSheetState() == SheetState.HIDDEN) return;
+ if (getCurrentOffsetPx() > getSheetHeightForState(SheetState.PEEK)) return;
+
+ // Updating the offset will automatically account for the browser controls.
+ setSheetOffsetFromBottom(getCurrentOffsetPx(), StateChangeReason.SWIPE);
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasWindowFocus) {
+ super.onWindowFocusChanged(hasWindowFocus);
+
+ // Trigger a relayout on window focus to correct any positioning issues when leaving Chrome
+ // previously. This is required as a layout is not triggered when coming back to Chrome
+ // with the keyboard previously shown.
+ if (hasWindowFocus) requestLayout();
+ }
+
+ @Override
+ public boolean isContentScrolledToTop() {
+ return mSheetContent == null || mSheetContent.getVerticalScrollOffset() <= 0;
+ }
+
+ @Override
+ public float getCurrentOffsetPx() {
+ return mCurrentOffsetPx;
+ }
+
+ @Override
+ public float getMinOffsetPx() {
+ return (swipeToDismissEnabled() ? getHiddenRatio() : getPeekRatio()) * mContainerHeight;
+ }
+
+ /**
+ * Test whether a motion event is in the area of the sheet considered to be usable (i.e. not
+ * on the shadow shown above the sheet or some other decorative part of the view).
+ * @param e The motion event relative to the bottom sheet view.
+ * @return Whether the event is considered to be in the usable area of the sheet.
+ */
+ public boolean isTouchEventInUsableArea(MotionEvent event) {
+ return event.getY() > getToolbarShadowHeight();
+ }
+
+ @Override
+ public boolean isTouchEventInToolbar(MotionEvent event) {
+ mToolbarHolder.getLocationInWindow(mCachedLocation);
+ // This check only tests for collision for the Y component since the sheet is the full width
+ // of the screen. We only care if the touch event is above the bottom of the toolbar since
+ // we won't receive an event if the touch is outside the sheet.
+ return mCachedLocation[1] + mToolbarHolder.getHeight() > event.getRawY();
+ }
+
+ /**
+ * @return Whether flinging down hard enough will close the sheet.
+ */
+ private boolean swipeToDismissEnabled() {
+ return mSheetContent != null ? mSheetContent.swipeToDismissEnabled() : true;
+ }
+
+ /**
+ * @return Whether the half state should be skipped when moving the sheet down.
+ */
+ private boolean shouldSkipHalfStateOnScrollingDown() {
+ return mSheetContent == null || mSheetContent.skipHalfStateOnScrollingDown();
+ }
+
+ /**
+ * @return The minimum sheet state that the user can swipe to. i.e. flinging down will either
+ * close the sheet or peek it.
+ */
+ @SheetState
+ int getMinSwipableSheetState() {
+ return swipeToDismissEnabled() || !isPeekStateEnabled() ? SheetState.HIDDEN
+ : SheetState.PEEK;
+ }
+
+ /**
+ * Get the state that the bottom sheet should open to with the provided content.
+ * @return The minimum opened state for the current content.
+ */
+ @SheetState
+ int getOpeningState() {
+ if (mSheetContent == null) {
+ return SheetState.HIDDEN;
+ } else if (isPeekStateEnabled()) {
+ return SheetState.PEEK;
+ } else if (isHalfStateEnabled()) {
+ return SheetState.HALF;
+ }
+ return SheetState.FULL;
+ }
+
+ @Override
+ public float getMaxOffsetPx() {
+ return getFullRatio() * mContainerHeight;
+ }
+
+ /**
+ * Show content in the bottom sheet's content area.
+ * @param content The {@link BottomSheetContent} to show, or null if no content should be shown.
+ */
+ @VisibleForTesting
+ void showContent(@Nullable final BottomSheetContent content) {
+ // If the desired content is already showing, do nothing.
+ if (mSheetContent == content) return;
+
+ // Remove this as listener from previous content layout and size changes.
+ if (mSheetContent != null) {
+ mSheetContent.setContentSizeListener(null);
+ mSheetContent.getContentView().removeOnLayoutChangeListener(this);
+ }
+
+ swapViews(content != null ? content.getContentView() : null,
+ mSheetContent != null ? mSheetContent.getContentView() : null,
+ mBottomSheetContentContainer);
+
+ View newToolbar = content != null ? content.getToolbarView() : null;
+ swapViews(newToolbar, mSheetContent != null ? mSheetContent.getToolbarView() : null,
+ mToolbarHolder);
+
+ // We hide the default toolbar if the new content has its own.
+ mDefaultToolbarView.setVisibility(newToolbar != null ? GONE : VISIBLE);
+
+ onSheetContentChanged(content);
+ }
+
+ /**
+ * Removes the oldView (or sets it to invisible) and adds the new view to the specified parent.
+ * @param newView The new view to transition to.
+ * @param oldView The old view to transition from.
+ * @param parent The parent for newView and oldView.
+ */
+ private void swapViews(final View newView, final View oldView, final ViewGroup parent) {
+ if (oldView != null && oldView.getParent() != null) parent.removeView(oldView);
+ if (newView != null && parent != newView.getParent()) parent.addView(newView);
+ }
+
+ /**
+ * A notification that the sheet is exiting the peek state into one that shows content.
+ * @param reason The reason the sheet was opened, if any.
+ */
+ private void onSheetOpened(@StateChangeReason int reason) {
+ if (mIsSheetOpen) return;
+
+ mIsSheetOpen = true;
+
+ for (BottomSheetObserver o : mObservers) o.onSheetOpened(reason);
+ }
+
+ /**
+ * A notification that the sheet has returned to the peeking state.
+ * @param reason The {@link StateChangeReason} that the sheet was closed,
+ * if any.
+ */
+ private void onSheetClosed(@StateChangeReason int reason) {
+ if (!mIsSheetOpen) return;
+ mIsSheetOpen = false;
+
+ for (BottomSheetObserver o : mObservers) o.onSheetClosed(reason);
+ // If the sheet contents are cleared out before #onSheetClosed is called, do not try to
+ // retrieve the accessibility string.
+ if (getCurrentSheetContent() != null) {
+ announceForAccessibility(getResources().getString(
+ getCurrentSheetContent().getSheetClosedAccessibilityStringId()));
+ }
+ clearFocus();
+
+ setFocusable(false);
+ setFocusableInTouchMode(false);
+ setContentDescription(null);
+ }
+
+ /**
+ * Cancels and nulls the height animation if it exists.
+ */
+ private void cancelAnimation() {
+ if (mSettleAnimator == null) return;
+ mSettleAnimator.cancel();
+ mSettleAnimator = null;
+ }
+
+ /**
+ * Creates the sheet's animation to a target state.
+ * @param targetState The target state.
+ * @param reason The reason the sheet started animation.
+ */
+ private void createSettleAnimation(
+ @SheetState final int targetState, @StateChangeReason final int reason) {
+ mTargetState = targetState;
+ mSettleAnimator =
+ ValueAnimator.ofFloat(getCurrentOffsetPx(), getSheetHeightForState(targetState));
+ mSettleAnimator.setDuration(BASE_ANIMATION_DURATION_MS);
+ mSettleAnimator.setInterpolator(mInterpolator);
+
+ // When the animation is canceled or ends, reset the handle to null.
+ mSettleAnimator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ if (mIsDestroyed) return;
+
+ mSettleAnimator = null;
+ setInternalCurrentState(targetState, reason);
+ mTargetState = SheetState.NONE;
+ }
+ });
+
+ mSettleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animator) {
+ setSheetOffsetFromBottom((Float) animator.getAnimatedValue(), reason);
+ }
+ });
+
+ setInternalCurrentState(SheetState.SCROLLING, reason);
+ mSettleAnimator.start();
+ }
+
+ /**
+ * @return Get the height in px that the peeking bar is offset due to the browser controls.
+ */
+ private float getOffsetFromBrowserControls() {
+ if (mSheetContent == null || !mSheetContent.hideOnScroll() || !isPeekStateEnabled()) {
+ return 0;
+ }
+
+ return getPeekRatio() * mContainerHeight * mBrowserControlsHiddenRatio;
+ }
+
+ /**
+ * Sets the sheet's offset relative to the bottom of the screen.
+ * @param offset The offset that the sheet should be.
+ */
+ void setSheetOffsetFromBottom(float offset, @StateChangeReason int reason) {
+ mCurrentOffsetPx = offset;
+
+ // The browser controls offset is added here so that the sheet's toolbar behaves like the
+ // browser controls do.
+ float translationY = (mContainerHeight - mCurrentOffsetPx) + getOffsetFromBrowserControls();
+
+ if (isSheetOpen() && MathUtils.areFloatsEqual(translationY, getTranslationY())) return;
+
+ setTranslationY(translationY);
+
+ float hiddenHeight = getHiddenRatio() * mContainerHeight;
+ if (mCurrentOffsetPx <= hiddenHeight && this.getParent() != null) {
+ mSheetContainer.removeView(this);
+ } else if (mCurrentOffsetPx > hiddenHeight && this.getParent() == null) {
+ mSheetContainer.addView(this);
+ }
+
+ // Do open/close computation based on the minimum allowed state by the sheet's content.
+ // Note that when transitioning from hidden to peek, even dismissable sheets may want
+ // to have a peek state.
+ @SheetState
+ int minSwipableState = getMinSwipableSheetState();
+ if (isPeekStateEnabled() && (!isSheetOpen() || mTargetState == SheetState.PEEK)) {
+ minSwipableState = SheetState.PEEK;
+ }
+
+ float minScrollableHeight = getSheetHeightForState(minSwipableState);
+ boolean isAtMinHeight = MathUtils.areFloatsEqual(getCurrentOffsetPx(), minScrollableHeight);
+ boolean heightLessThanPeek = getCurrentOffsetPx() < minScrollableHeight;
+ // Trigger the onSheetClosed event when the sheet is moving toward the hidden state if peek
+ // is disabled. This should be fine since touch is disabled when the sheet's target is
+ // hidden.
+ boolean triggerCloseWithHidden = !isPeekStateEnabled() && mTargetState == SheetState.HIDDEN;
+
+ if (isSheetOpen() && (heightLessThanPeek || isAtMinHeight || triggerCloseWithHidden)) {
+ onSheetClosed(reason);
+ } else if (!isSheetOpen() && mTargetState != SheetState.HIDDEN
+ && getCurrentOffsetPx() > minScrollableHeight) {
+ onSheetOpened(reason);
+ }
+
+ sendOffsetChangeEvents();
+ }
+
+ @Override
+ public void setSheetOffset(float offset, boolean shouldAnimate) {
+ cancelAnimation();
+ if (mSheetContent == null) return;
+
+ if (shouldAnimate) {
+ float velocityY = getCurrentOffsetPx() - offset;
+
+ @SheetState
+ int targetState = getTargetSheetState(offset, -velocityY);
+
+ setSheetState(targetState, true, StateChangeReason.SWIPE);
+ } else {
+ setInternalCurrentState(SheetState.SCROLLING, StateChangeReason.SWIPE);
+ setSheetOffsetFromBottom(offset, StateChangeReason.SWIPE);
+ }
+ }
+
+ /**
+ * @return The ratio of the height of the screen that the hidden state is.
+ */
+ @VisibleForTesting
+ float getHiddenRatio() {
+ return 0;
+ }
+
+ /** @return Whether the peeking state for the sheet's content is enabled. */
+ boolean isPeekStateEnabled() {
+ return mSheetContent != null && mSheetContent.getPeekHeight() != HeightMode.DISABLED;
+ }
+
+ /** @return Whether the half-height of the sheet is enabled. */
+ private boolean isHalfStateEnabled() {
+ if (mSheetContent == null) return false;
+
+ // Half state is invalid on small screens, when wrapping content at full height, and when
+ // explicitly disabled.
+ return !isSmallScreen() && mSheetContent.getHalfHeightRatio() != HeightMode.DISABLED
+ && mSheetContent.getFullHeightRatio() != HeightMode.WRAP_CONTENT;
+ }
+
+ /** @return Whether the height mode for the full state is WRAP_CONTENT. */
+ private boolean isFullHeightWrapContent() {
+ return mSheetContent != null
+ && mSheetContent.getFullHeightRatio() == HeightMode.WRAP_CONTENT;
+ }
+
+ /**
+ * @return The ratio of the height of the screen that the peeking state is.
+ */
+ public float getPeekRatio() {
+ if (mContainerHeight <= 0 || !isPeekStateEnabled()) return 0;
+
+ // If the content has a custom peek ratio set, use that instead of computing one.
+ if (mSheetContent != null && mSheetContent.getPeekHeight() != HeightMode.DEFAULT) {
+ assert mSheetContent.getPeekHeight()
+ != HeightMode.WRAP_CONTENT : "The peek mode can't wrap content.";
+ float ratio = mSheetContent.getPeekHeight() / (float) mContainerHeight;
+ assert ratio > 0 && ratio <= 1 : "Custom peek ratios must be in the range of (0, 1].";
+ return ratio;
+ }
+ assert getToolbarView() != null : "Using default peek height requires a non-null toolbar";
+
+ View toolbarView = getToolbarView();
+ int toolbarHeight = toolbarView.getHeight();
+ if (toolbarHeight == 0) {
+ // If the toolbar is not laid out yet and has a fixed height layout parameter, we assume
+ // that the toolbar will have this height in the future.
+ ViewGroup.LayoutParams layoutParams = toolbarView.getLayoutParams();
+ if (layoutParams != null) {
+ if (layoutParams.height > 0) {
+ toolbarHeight = layoutParams.height;
+ } else {
+ toolbarView.measure(
+ MeasureSpec.makeMeasureSpec(mContainerWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(
+ (int) mContainerHeight, MeasureSpec.AT_MOST));
+ toolbarHeight = toolbarView.getMeasuredHeight();
+ }
+ }
+ }
+ return (toolbarHeight + mToolbarShadowHeight) / (float) mContainerHeight;
+ }
+
+ private View getToolbarView() {
+ return mSheetContent != null && mSheetContent.getToolbarView() != null
+ ? mSheetContent.getToolbarView()
+ : mDefaultToolbarView;
+ }
+
+ /**
+ * @return The ratio of the height of the screen that the half expanded state is.
+ */
+ @VisibleForTesting
+ float getHalfRatio() {
+ if (mContainerHeight <= 0 || !isHalfStateEnabled()) return 0;
+
+ float customHalfRatio = mSheetContent.getHalfHeightRatio();
+ assert customHalfRatio
+ != HeightMode.WRAP_CONTENT
+ : "Half-height cannot be WRAP_CONTENT. This is only supported for full-height.";
+
+ return customHalfRatio == HeightMode.DEFAULT ? HALF_HEIGHT_RATIO : customHalfRatio;
+ }
+
+ /**
+ * @return The ratio of the height of the screen that the fully expanded state is.
+ */
+ @VisibleForTesting
+ float getFullRatio() {
+ if (mContainerHeight <= 0 || mSheetContent == null) return 0;
+
+ float customFullRatio = mSheetContent.getFullHeightRatio();
+ assert customFullRatio != HeightMode.DISABLED : "The full height cannot be DISABLED.";
+
+ if (isFullHeightWrapContent()) {
+ ensureContentDesiredHeightIsComputed();
+ float heightPx =
+ Math.min(mContainerHeight, mContentDesiredHeight + mToolbarShadowHeight);
+ return heightPx / mContainerHeight;
+ }
+
+ return customFullRatio == HeightMode.DEFAULT
+ ? (mContainerHeight + mToolbarShadowHeight) / (float) mContainerHeight
+ : customFullRatio;
+ }
+
+ /**
+ * @return The height of the container that the bottom sheet exists in.
+ */
+ public float getSheetContainerHeight() {
+ return mContainerHeight;
+ }
+
+ /**
+ * Sends notifications if the sheet is transitioning from the peeking to half expanded state and
+ * from the peeking to fully expanded state. The peek to half events are only sent when the
+ * sheet is between the peeking and half states.
+ */
+ private void sendOffsetChangeEvents() {
+ float offsetWithBrowserControls = getCurrentOffsetPx() - getOffsetFromBrowserControls();
+
+ // Do not send events for states less than the hidden state unless 0 has not been sent.
+ if (offsetWithBrowserControls <= getSheetHeightForState(SheetState.HIDDEN)
+ && mLastOffsetRatioSent <= 0) {
+ return;
+ }
+
+ float screenRatio =
+ mContainerHeight > 0 ? offsetWithBrowserControls / (float) mContainerHeight : 0;
+
+ // This ratio is relative to the peek and full positions of the sheet.
+ float maxHiddenFullRatio = getFullRatio() - getHiddenRatio();
+ float hiddenFullRatio = maxHiddenFullRatio == 0
+ ? 0
+ : MathUtils.clamp((screenRatio - getHiddenRatio()) / maxHiddenFullRatio, 0, 1);
+
+ if (offsetWithBrowserControls < getSheetHeightForState(SheetState.HIDDEN)) {
+ mLastOffsetRatioSent = 0;
+ } else {
+ mLastOffsetRatioSent =
+ MathUtils.areFloatsEqual(hiddenFullRatio, 0) ? 0 : hiddenFullRatio;
+ }
+
+ for (BottomSheetObserver o : mObservers) {
+ o.onSheetOffsetChanged(mLastOffsetRatioSent, getCurrentOffsetPx());
+ }
+
+ if (isPeekStateEnabled()
+ && MathUtils.areFloatsEqual(
+ offsetWithBrowserControls, getSheetHeightForState(SheetState.PEEK))) {
+ for (BottomSheetObserver o : mObservers) o.onSheetFullyPeeked();
+ }
+ }
+
+ /** @see #setSheetState(int, boolean, int) */
+ void setSheetState(@SheetState int state, boolean animate) {
+ setSheetState(state, animate, StateChangeReason.NONE);
+ }
+
+ /**
+ * Moves the sheet to the provided state.
+ * @param state The state to move the panel to. This cannot be SheetState.SCROLLING or
+ * SheetState.NONE.
+ * @param animate If true, the sheet will animate to the provided state, otherwise it will
+ * move there instantly.
+ * @param reason The reason the sheet state is changing. This can be specified to indicate to
+ * observers that a more specific event has occurred, otherwise
+ * STATE_CHANGE_REASON_NONE can be used.
+ */
+ void setSheetState(@SheetState int state, boolean animate, @StateChangeReason int reason) {
+ assert state != SheetState.NONE;
+
+ // Setting state to SCROLLING is not a valid operation. This can happen only when
+ // we're already in the scrolling state. Make it no-op.
+ if (state == SheetState.SCROLLING) {
+ assert mCurrentState == SheetState.SCROLLING && isRunningSettleAnimation();
+ return;
+ }
+
+ if (state == SheetState.HALF && !isHalfStateEnabled()) state = SheetState.FULL;
+
+ mTargetState = state;
+
+ cancelAnimation();
+
+ if (animate && state != mCurrentState) {
+ createSettleAnimation(state, reason);
+ } else {
+ setSheetOffsetFromBottom(getSheetHeightForState(state), reason);
+ setInternalCurrentState(mTargetState, reason);
+ mTargetState = SheetState.NONE;
+ }
+ }
+
+ /**
+ * @return The target state that the sheet is moving to during animation. If the sheet is
+ * stationary or a target state has not been determined, SheetState.NONE will be
+ * returned.
+ */
+ int getTargetSheetState() {
+ return mTargetState;
+ }
+
+ /**
+ * @return The current state of the bottom sheet. If the sheet is animating, this will be the
+ * state the sheet is animating to.
+ */
+ @SheetState
+ int getSheetState() {
+ return mCurrentState;
+ }
+
+ /** @return Whether the sheet is currently open. */
+ boolean isSheetOpen() {
+ return mIsSheetOpen;
+ }
+
+ /**
+ * Set the current state of the bottom sheet. This is for internal use to notify observers of
+ * state change events.
+ * @param state The current state of the sheet.
+ * @param reason The reason the state is changing if any.
+ */
+ private void setInternalCurrentState(@SheetState int state, @StateChangeReason int reason) {
+ if (state == mCurrentState) return;
+
+ // TODO(mdjones): This shouldn't be able to happen, but does occasionally during layout.
+ // Fix the race condition that is making this happen.
+ if (state == SheetState.NONE) {
+ setSheetState(getTargetSheetState(getCurrentOffsetPx(), 0), false);
+ return;
+ }
+
+ // Remember which state precedes the scrolling.
+ mScrollingStartState = state == SheetState.SCROLLING
+ ? mCurrentState != SheetState.SCROLLING ? mCurrentState : SheetState.NONE
+ : SheetState.NONE; // Not scrolling anymore.
+ mCurrentState = state;
+
+ if (mCurrentState == SheetState.HALF || mCurrentState == SheetState.FULL) {
+ int resId = mCurrentState == SheetState.FULL
+ ? getCurrentSheetContent().getSheetFullHeightAccessibilityStringId()
+ : getCurrentSheetContent().getSheetHalfHeightAccessibilityStringId();
+ announceForAccessibility(getResources().getString(resId));
+
+ // TalkBack will announce the content description if it has changed, so wait to set the
+ // content description until after announcing full/half height.
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ String contentDescription = getResources().getString(
+ getCurrentSheetContent().getSheetContentDescriptionStringId());
+
+ if (getCurrentSheetContent().swipeToDismissEnabled()) {
+ contentDescription += ". "
+ + getResources().getString(
+ org.chromium.components.browser_ui.widget.R.string
+ .bottom_sheet_accessibility_description);
+ }
+
+ setContentDescription(contentDescription);
+ if (getFocusedChild() == null) requestFocus();
+ }
+
+ for (BottomSheetObserver o : mObservers) {
+ o.onSheetStateChanged(mCurrentState);
+ }
+ }
+
+ /**
+ * If the animation to settle the sheet in one of its states is running.
+ * @return True if the animation is running.
+ */
+ private boolean isRunningSettleAnimation() {
+ return mSettleAnimator != null;
+ }
+
+ /** @return The current sheet content, or null if there is no content. */
+ @Nullable
+ BottomSheetContent getCurrentSheetContent() {
+ return mSheetContent;
+ }
+
+ /**
+ * Gets 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 int state) {
+ if (isFullHeightWrapContent() && state == SheetState.FULL) {
+ ensureContentDesiredHeightIsComputed();
+ return mContentDesiredHeight + mToolbarShadowHeight;
+ }
+
+ return getRatioForState(state) * mContainerHeight;
+ }
+
+ private void ensureContentDesiredHeightIsComputed() {
+ if (mContentDesiredHeight != HEIGHT_UNSPECIFIED) {
+ return;
+ }
+
+ mSheetContent.getContentView().measure(
+ MeasureSpec.makeMeasureSpec(mContainerWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(mContainerHeight, MeasureSpec.AT_MOST));
+ mContentDesiredHeight = mSheetContent.getContentView().getMeasuredHeight();
+ }
+
+ private float getRatioForState(int state) {
+ switch (state) {
+ case SheetState.HIDDEN:
+ return getHiddenRatio();
+ case SheetState.PEEK:
+ return getPeekRatio();
+ case SheetState.HALF:
+ return getHalfRatio();
+ case SheetState.FULL:
+ return getFullRatio();
+ }
+
+ throw new IllegalArgumentException("Invalid state: " + state);
+ }
+
+ /**
+ * Adds an observer to the bottom sheet.
+ * @param observer The observer to add.
+ */
+ void addObserver(BottomSheetObserver observer) {
+ mObservers.addObserver(observer);
+ }
+
+ /**
+ * Removes an observer to the bottom sheet.
+ * @param observer The observer to remove.
+ */
+ void removeObserver(BottomSheetObserver observer) {
+ mObservers.removeObserver(observer);
+ }
+
+ /**
+ * Gets 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. If this value is positive, the movement
+ * is from bottom to top.
+ * @return The target state of the bottom sheet.
+ */
+ @SheetState
+ private int getTargetSheetState(float sheetHeight, float yVelocity) {
+ if (sheetHeight <= getMinOffsetPx()) return getMinSwipableSheetState();
+ if (sheetHeight >= getMaxOffsetPx()) return SheetState.FULL;
+
+ boolean isMovingDownward = yVelocity < 0;
+
+ // If velocity shouldn't affect dismissing the sheet, reverse effect on the sheet height.
+ if (isMovingDownward && !swipeToDismissEnabled()) sheetHeight -= yVelocity;
+
+ // Find the two states that the sheet height is between.
+ @SheetState
+ int prevState = mScrollingStartState;
+ @SheetState
+ int nextState = isMovingDownward ? getLargestCollapsingState(isMovingDownward, sheetHeight)
+ : getSmallestExpandingState(isMovingDownward, sheetHeight);
+
+ // Go into the next state only if the threshold for minimal change has been cleared.
+ return hasCrossedThresholdToNextState(prevState, nextState, sheetHeight, isMovingDownward)
+ ? nextState
+ : prevState;
+ }
+
+ /**
+ * Returns whether the sheet was scrolled far enough to transition into the next state.
+ * @param prev The state before the scrolling transition happened.
+ * @param next The state before the scrolling transitions into.
+ * @param sheetMovesDown True if the sheet moves down.
+ * @param sheetHeight The current sheet height in flux.
+ * @return True, iff the sheet was scrolled far enough to transition from |prev| to |next|.
+ */
+ private boolean hasCrossedThresholdToNextState(
+ @SheetState int prev, @SheetState int next, float sheetHeight, boolean sheetMovesDown) {
+ if (next == prev) return false;
+ // Moving from an internal/temporary state always works:
+ if (prev == SheetState.NONE || prev == SheetState.SCROLLING) return true;
+ float lowerBound = getSheetHeightForState(prev);
+ float distance = getSheetHeightForState(next) - lowerBound;
+ return Math.abs((sheetHeight - lowerBound) / distance)
+ > getThresholdToNextState(prev, next, sheetMovesDown);
+ }
+
+ /**
+ * The threshold to enter a state depends on whether a transition skips the half state. The more
+ * states to cross, the smaller the (percentual) threshold. A small threshold is used iff:
+ * * It doesn't move into the HALF state,
+ * * Skipping the HALF state is allowed, and
+ * * The is large enough to skip the HALF state
+ * @param prev The state before the scrolling transition happened.
+ * @param next The state before the scrolling transitions into.
+ * @param sheetMovesDown True if the sheet is being moved down.
+ * @return a threshold (as percentage of the scroll distance covered).
+ */
+ private float getThresholdToNextState(
+ @SheetState int prev, @SheetState int next, boolean sheetMovesDown) {
+ if (next == SheetState.HALF) return THRESHOLD_TO_NEXT_STATE_3;
+ boolean crossesHalf = sheetMovesDown && prev > SheetState.HALF && next < SheetState.HALF
+ || !sheetMovesDown && prev < SheetState.HALF && next > SheetState.HALF;
+ if (!crossesHalf) return THRESHOLD_TO_NEXT_STATE_3;
+ if (!shouldSkipHalfStateOnScrollingDown()) return THRESHOLD_TO_NEXT_STATE_3;
+ return THRESHOLD_TO_NEXT_STATE_2;
+ }
+
+ /**
+ * Returns the largest, acceptable state whose height is smaller than the given sheet height.
+ * E.g. if a sheet is between FULL and HALF, collapsing states are PEEK and HALF. Although HALF
+ * is closer to the sheet's height, it might have to be skipped. Then, PEEK is returned instead.
+ * @param sheetMovesDown If the sheet moves down, some smaller states might be skipped.
+ * @param sheetHeight The current sheet height in flux.
+ * @return The largest, acceptable, collapsing state.
+ */
+ private @SheetState int getLargestCollapsingState(boolean sheetMovesDown, float sheetHeight) {
+ @SheetState
+ int largestCollapsingState = getMinSwipableSheetState();
+ boolean skipHalfState = !isHalfStateEnabled() || shouldSkipHalfStateOnScrollingDown();
+ for (@SheetState int i = largestCollapsingState + 1; i < SheetState.FULL; i++) {
+ if (i == SheetState.PEEK && !isPeekStateEnabled()) continue;
+ if (i == SheetState.HALF && skipHalfState) continue;
+
+ if (sheetHeight > getSheetHeightForState(i)
+ || sheetHeight == getSheetHeightForState(i) && !sheetMovesDown) {
+ largestCollapsingState = i;
+ }
+ }
+ return largestCollapsingState;
+ }
+
+ /**
+ * Returns the smallest, acceptable state whose height is larger than the given sheet height.
+ * E.g. if the sheet is between PEEK and HALF, expanding states are HALF and FULL. Although HALF
+ * is closer to the sheet's height, it might not be enabled. Then, FULL is returned instead.
+ * @param sheetMovesDown If the sheet moves down, some collapsing states might be skipped. This
+ * affects the smallest possible expanding state as well.
+ * @param sheetHeight The current sheet height in flux.
+ * @return The smallest, acceptable, expanding state.
+ */
+ private @SheetState int getSmallestExpandingState(boolean sheetMovesDown, float sheetHeight) {
+ @SheetState
+ int largestCollapsingState = getLargestCollapsingState(sheetMovesDown, sheetHeight);
+ @SheetState
+ int smallestExpandingState = SheetState.FULL;
+ for (@SheetState int i = smallestExpandingState - 1; i > largestCollapsingState + 1; i--) {
+ if (i == SheetState.HALF && !isHalfStateEnabled()) continue;
+ if (i == SheetState.PEEK && !isPeekStateEnabled()) continue;
+
+ if (sheetHeight <= getSheetHeightForState(i)) {
+ smallestExpandingState = i;
+ }
+ }
+
+ return smallestExpandingState;
+ }
+
+ @VisibleForTesting
+ public static void setSmallScreenForTesting(boolean isSmallScreen) {
+ sIsSmallScreenForTesting = isSmallScreen;
+ }
+
+ public boolean isSmallScreen() {
+ if (sIsSmallScreenForTesting != null) return sIsSmallScreenForTesting;
+
+ // A small screen is defined by there being less than 160dp between half and full states.
+ float fullHeightRatio =
+ (mContainerHeight + mToolbarShadowHeight) / (float) mContainerHeight;
+ float fullToHalfDiff = (fullHeightRatio - HALF_HEIGHT_RATIO) * mContainerHeight;
+ return fullToHalfDiff < mMinHalfFullDistance;
+ }
+
+ /**
+ * @return The height of the toolbar shadow.
+ */
+ public int getToolbarShadowHeight() {
+ return mToolbarShadowHeight;
+ }
+
+ /**
+ * Called when the sheet content has changed, to update dependent state and notify observers.
+ * @param content The new sheet content, or null if the sheet has no content.
+ */
+ protected void onSheetContentChanged(@Nullable final BottomSheetContent content) {
+ mSheetContent = content;
+
+ if (isFullHeightWrapContent()) {
+ // Listen for layout/size changes.
+ if (!content.setContentSizeListener(this::onContentSizeChanged)) {
+ content.getContentView().addOnLayoutChangeListener(this);
+ }
+
+ invalidateContentDesiredHeight();
+ ensureContentIsWrapped(/* animate= */ true);
+
+ // HALF state is forbidden when wrapping the content.
+ if (mCurrentState == SheetState.HALF) {
+ setSheetState(SheetState.FULL, /* animate= */ true);
+ }
+ }
+
+ for (BottomSheetObserver o : mObservers) {
+ o.onSheetContentChanged(content);
+ }
+ mToolbarHolder.setBackgroundColor(Color.TRANSPARENT);
+ }
+
+ /**
+ * Called when the sheet content layout changed.
+ */
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
+ int oldTop, int oldRight, int oldBottom) {
+ invalidateContentDesiredHeight();
+ ensureContentIsWrapped(/* animate= */ true);
+ }
+
+ /**
+ * Called when the sheet content size changed.
+ */
+ private void onContentSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ boolean heightChanged = mContentDesiredHeight != height;
+ mContentDesiredHeight = height;
+
+ if (heightChanged && mCurrentState == SheetState.SCROLLING) {
+ endAnimations();
+ return;
+ }
+
+ ensureContentIsWrapped(/* animate= */ false);
+ }
+
+ private void ensureContentIsWrapped(boolean animate) {
+ if (mCurrentState == SheetState.HIDDEN || mCurrentState == SheetState.PEEK) return;
+
+ // The SCROLLING state is used when animating the sheet height or when the user is swiping
+ // the sheet. If it is the latter, we should not change the sheet height.
+ if (!isRunningSettleAnimation() && mCurrentState == SheetState.SCROLLING) return;
+ setSheetState(mCurrentState, animate);
+ }
+
+ private void invalidateContentDesiredHeight() {
+ mContentDesiredHeight = HEIGHT_UNSPECIFIED;
+ }
+
+ /**
+ * WARNING: This destroys the state of the BottomSheet. Only use in tests and only use once.
+ * Puts the sheet into a scrolling state that can't be reached in tests otherwise.
+ *
+ * @param sheetHeightInPx The height in px that the sheet should be "scrolled" to.
+ * @param yUpwardsVelocity The sheet's upwards y velocity when reaching the scrolled height.
+ * @return The state the bottom sheet would target when the scrolling ends.
+ */
+ @VisibleForTesting
+ @SheetState
+ int forceScrollingStateForTesting(float sheetHeightInPx, float yUpwardsVelocity) {
+ mScrollingStartState = mCurrentState;
+ mCurrentState = SheetState.SCROLLING;
+ return getTargetSheetState(sheetHeightInPx, yUpwardsVelocity);
+ }
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerFactory.java b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerFactory.java
new file mode 100644
index 00000000000..732588d5234
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerFactory.java
@@ -0,0 +1,33 @@
+// Copyright 2020 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.components.browser_ui.bottomsheet;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+
+import org.chromium.base.Callback;
+import org.chromium.base.supplier.Supplier;
+import org.chromium.components.browser_ui.widget.scrim.ScrimCoordinator;
+import org.chromium.ui.KeyboardVisibilityDelegate;
+
+/** A factory for producing a {@link BottomSheetController}. */
+public class BottomSheetControllerFactory {
+ /**
+ * @param scrim A supplier of scrim to be shown behind the sheet.
+ * @param initializedCallback A callback for the sheet having been created.
+ * @param window The activity's window.
+ * @param keyboardDelegate A means of hiding the keyboard.
+ * @param root The view that should contain the sheet.
+ * @param inflater A mechanism for building views from XML.
+ * @return A new instance of the {@link BottomSheetController}.
+ */
+ public static ManagedBottomSheetController createBottomSheetController(
+ final Supplier<ScrimCoordinator> scrim, Callback<View> initializedCallback,
+ Window window, KeyboardVisibilityDelegate keyboardDelegate, Supplier<ViewGroup> root) {
+ return new BottomSheetControllerImpl(
+ scrim, initializedCallback, window, keyboardDelegate, root);
+ }
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerImpl.java b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerImpl.java
new file mode 100644
index 00000000000..88dd095af89
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerImpl.java
@@ -0,0 +1,505 @@
+// Copyright 2018 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.components.browser_ui.bottomsheet;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+
+import androidx.annotation.VisibleForTesting;
+
+import org.chromium.base.Callback;
+import org.chromium.base.supplier.Supplier;
+import org.chromium.components.browser_ui.widget.scrim.ScrimCoordinator;
+import org.chromium.components.browser_ui.widget.scrim.ScrimProperties;
+import org.chromium.ui.KeyboardVisibilityDelegate;
+import org.chromium.ui.modelutil.PropertyModel;
+import org.chromium.ui.util.AccessibilityUtil;
+import org.chromium.ui.util.TokenHolder;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.PriorityQueue;
+
+/**
+ * This class is responsible for managing the content shown by the {@link BottomSheet}. Features
+ * wishing to show content in the {@link BottomSheet} UI must implement {@link BottomSheetContent}
+ * and call {@link #requestShowContent(BottomSheetContent, boolean)} which will return true if the
+ * content was actually shown (see full doc on method).
+ */
+class BottomSheetControllerImpl implements ManagedBottomSheetController {
+ /** The initial capacity for the priority queue handling pending content show requests. */
+ private static final int INITIAL_QUEUE_CAPACITY = 1;
+
+ /** The height of the shadow that sits above the toolbar. */
+ private int mToolbarShadowHeight;
+
+ /** The offset of the toolbar shadow from the top that remains empty. */
+ private int mShadowTopOffset;
+
+ /** A handle to the {@link BottomSheet} that this class controls. */
+ private BottomSheet mBottomSheet;
+
+ /** A queue for content that is waiting to be shown in the {@link BottomSheet}. */
+ private PriorityQueue<BottomSheetContent> mContentQueue;
+
+ /** Whether the controller is already processing a hide request for the tab. */
+ private boolean mIsProcessingHideRequest;
+
+ /** A runnable that initializes the bottom sheet when necessary. */
+ private Runnable mSheetInitializer;
+
+ /**
+ * A list of observers maintained by this controller until the bottom sheet is created, at which
+ * point they will be added to the bottom sheet.
+ */
+ private List<BottomSheetObserver> mPendingSheetObservers;
+
+ /** The state of the sheet so it can be returned to what it was prior to suppression. */
+ @SheetState
+ private int mSheetStateBeforeSuppress;
+
+ /** The content being shown prior to the sheet being suppressed. */
+ private BottomSheetContent mContentWhenSuppressed;
+
+ /** A means of accessing the ScrimCoordinator. */
+ private Supplier<ScrimCoordinator> mScrimCoordinatorSupplier;
+
+ /**
+ * A set of tokens for features suppressing the bottom sheet. If this holder has tokens, the
+ * sheet is suppressed.
+ */
+ private final TokenHolder mSuppressionTokens;
+
+ /** A means of checking whether accessibility is currently enabled. */
+ private AccessibilityUtil mAccessibilityUtil;
+
+ /**
+ * Build a new controller of the bottom sheet.
+ * @param scrim A supplier of the scrim that shows when the bottom sheet is opened.
+ * @param initializedCallback A callback for the sheet being created (as the sheet is not
+ * initialized until first use.
+ * @param window A means of accessing the screen size.
+ * @param keyboardDelegate A means of hiding the keyboard.
+ * @param root The view that should contain the sheet.
+ */
+ public BottomSheetControllerImpl(final Supplier<ScrimCoordinator> scrim,
+ Callback<View> initializedCallback, Window window,
+ KeyboardVisibilityDelegate keyboardDelegate, Supplier<ViewGroup> root) {
+ mScrimCoordinatorSupplier = scrim;
+ mPendingSheetObservers = new ArrayList<>();
+ mSuppressionTokens = new TokenHolder(() -> onSuppressionTokensChanged());
+
+ mSheetInitializer = () -> {
+ initializeSheet(initializedCallback, window, keyboardDelegate, root);
+ };
+ }
+
+ /**
+ * Do the actual initialization of the bottom sheet.
+ * @param initializedCallback A callback for the creation of the sheet.
+ * @param window A means of accessing the screen size.
+ * @param keyboardDelegate A means of hiding the keyboard.
+ * @param root The view that should contain the sheet.
+ */
+ private void initializeSheet(Callback<View> initializedCallback, Window window,
+ KeyboardVisibilityDelegate keyboardDelegate, Supplier<ViewGroup> root) {
+ LayoutInflater.from(root.get().getContext()).inflate(R.layout.bottom_sheet, root.get());
+ mBottomSheet = (BottomSheet) root.get().findViewById(R.id.bottom_sheet);
+ initializedCallback.onResult(mBottomSheet);
+
+ mBottomSheet.init(window, keyboardDelegate);
+ mBottomSheet.setAccssibilityUtil(mAccessibilityUtil);
+ mToolbarShadowHeight = mBottomSheet.getResources().getDimensionPixelOffset(
+ BottomSheet.getTopShadowResourceId());
+ mShadowTopOffset = mBottomSheet.getResources().getDimensionPixelOffset(
+ BottomSheet.getShadowTopOffsetResourceId());
+
+ // Initialize the queue with a comparator that checks content priority.
+ mContentQueue = new PriorityQueue<>(INITIAL_QUEUE_CAPACITY,
+ (content1, content2) -> content1.getPriority() - content2.getPriority());
+
+ PropertyModel scrimProperties =
+ new PropertyModel.Builder(ScrimProperties.REQUIRED_KEYS)
+ .with(ScrimProperties.TOP_MARGIN, 0)
+ .with(ScrimProperties.AFFECTS_STATUS_BAR, true)
+ .with(ScrimProperties.ANCHOR_VIEW, mBottomSheet)
+ .with(ScrimProperties.SHOW_IN_FRONT_OF_ANCHOR_VIEW, false)
+ .with(ScrimProperties.CLICK_DELEGATE,
+ () -> {
+ if (!mBottomSheet.isSheetOpen()) return;
+ mBottomSheet.setSheetState(
+ mBottomSheet.getMinSwipableSheetState(), true,
+ StateChangeReason.TAP_SCRIM);
+ })
+ .build();
+
+ mBottomSheet.addObserver(new EmptyBottomSheetObserver() {
+ /**
+ * Whether the scrim was shown for the last content.
+ * TODO(mdjones): We should try to make sure the content in the sheet is not nulled
+ * prior to the close event occurring; sheets that don't have a peek
+ * state make this difficult since the sheet needs to be hidden before it
+ * is closed.
+ */
+ private boolean mScrimShown;
+
+ @Override
+ public void onSheetOpened(@StateChangeReason int reason) {
+ if (mBottomSheet.getCurrentSheetContent() != null
+ && mBottomSheet.getCurrentSheetContent().hasCustomScrimLifecycle()) {
+ return;
+ }
+
+ mScrimCoordinatorSupplier.get().showScrim(scrimProperties);
+ mScrimShown = true;
+ }
+
+ @Override
+ public void onSheetClosed(@StateChangeReason int reason) {
+ // Hide the scrim if the current content doesn't have a custom scrim lifecycle.
+ if (mScrimShown) {
+ mScrimCoordinatorSupplier.get().hideScrim(true);
+ mScrimShown = false;
+ }
+
+ // Try to swap contents unless the sheet's content has a custom lifecycle.
+ if (mBottomSheet.getCurrentSheetContent() != null
+ && !mBottomSheet.getCurrentSheetContent().hasCustomLifecycle()) {
+ // If the sheet is closed, it is an opportunity for another content to try to
+ // take its place if it is a higher priority.
+ BottomSheetContent content = mBottomSheet.getCurrentSheetContent();
+ BottomSheetContent nextContent = mContentQueue.peek();
+ if (content != null && nextContent != null
+ && nextContent.getPriority() < content.getPriority()) {
+ mContentQueue.add(content);
+ mBottomSheet.setSheetState(SheetState.HIDDEN, true);
+ }
+ }
+ }
+
+ @Override
+ public void onSheetStateChanged(@SheetState int state) {
+ // If hiding request is in progress, destroy the current sheet content being hidden
+ // even when it is in suppressed state. See https://crbug.com/1057966.
+ if (state != SheetState.HIDDEN
+ || (!mIsProcessingHideRequest && mSuppressionTokens.hasTokens())) {
+ return;
+ }
+ if (mBottomSheet.getCurrentSheetContent() != null) {
+ mBottomSheet.getCurrentSheetContent().destroy();
+ }
+ mIsProcessingHideRequest = false;
+ showNextContent(true);
+ }
+ });
+
+ // Add any of the pending observers that were added prior to the sheet being created.
+ for (int i = 0; i < mPendingSheetObservers.size(); i++) {
+ mBottomSheet.addObserver(mPendingSheetObservers.get(i));
+ }
+ mPendingSheetObservers.clear();
+
+ mSheetInitializer = null;
+ }
+
+ @Override
+ public void setBrowserControlsHiddenRatio(float ratio) {
+ if (mBottomSheet != null) mBottomSheet.setBrowserControlsHiddenRatio(ratio);
+ }
+
+ @Override
+ public ScrimCoordinator getScrimCoordinator() {
+ return mScrimCoordinatorSupplier.get();
+ }
+
+ @Override
+ public PropertyModel createScrimParams() {
+ return new PropertyModel.Builder(ScrimProperties.REQUIRED_KEYS)
+ .with(ScrimProperties.TOP_MARGIN, 0)
+ .with(ScrimProperties.AFFECTS_STATUS_BAR, true)
+ .with(ScrimProperties.ANCHOR_VIEW, mBottomSheet)
+ .with(ScrimProperties.SHOW_IN_FRONT_OF_ANCHOR_VIEW, false)
+ .with(ScrimProperties.CLICK_DELEGATE,
+ () -> {
+ if (!mBottomSheet.isSheetOpen()) return;
+ mBottomSheet.setSheetState(mBottomSheet.getMinSwipableSheetState(),
+ true, StateChangeReason.TAP_SCRIM);
+ })
+ .build();
+ }
+
+ // Destroyable implementation.
+ @Override
+ public void destroy() {
+ if (mBottomSheet != null) mBottomSheet.destroy();
+ }
+
+ @Override
+ public boolean handleBackPress() {
+ // If suppressed (therefore invisible), users are likely to expect for Chrome
+ // browser, not the bottom sheet, to react. Do not consume the event.
+ if (mBottomSheet == null || mSuppressionTokens.hasTokens()) return false;
+
+ // Give the sheet the opportunity to handle the back press itself before falling to the
+ // default "close" behavior.
+ if (getCurrentSheetContent() != null && getCurrentSheetContent().handleBackPress()) {
+ return true;
+ }
+
+ if (!mBottomSheet.isSheetOpen()) return false;
+ int sheetState = mBottomSheet.getMinSwipableSheetState();
+ mBottomSheet.setSheetState(sheetState, true, StateChangeReason.BACK_PRESS);
+ return true;
+ }
+
+ @Override
+ public BottomSheetContent getCurrentSheetContent() {
+ return mBottomSheet == null ? null : mBottomSheet.getCurrentSheetContent();
+ }
+
+ @Override
+ @SheetState
+ public int getSheetState() {
+ return mBottomSheet == null ? SheetState.HIDDEN : mBottomSheet.getSheetState();
+ }
+
+ @Override
+ @SheetState
+ public int getTargetSheetState() {
+ return mBottomSheet == null ? SheetState.NONE : mBottomSheet.getTargetSheetState();
+ }
+
+ @Override
+ public boolean isSheetOpen() {
+ return mBottomSheet != null && mBottomSheet.isSheetOpen();
+ }
+
+ @Override
+ public boolean isSheetHiding() {
+ return mBottomSheet == null ? false : mBottomSheet.isHiding();
+ }
+
+ @Override
+ public int getCurrentOffset() {
+ return mBottomSheet == null ? 0 : (int) mBottomSheet.getCurrentOffsetPx();
+ }
+
+ @Override
+ public int getContainerHeight() {
+ return mBottomSheet != null ? (int) mBottomSheet.getSheetContainerHeight() : 0;
+ }
+
+ @Override
+ public int getTopShadowHeight() {
+ return mToolbarShadowHeight + mShadowTopOffset;
+ }
+
+ @Override
+ public void addObserver(BottomSheetObserver observer) {
+ if (mBottomSheet == null) {
+ mPendingSheetObservers.add(observer);
+ return;
+ }
+ mBottomSheet.addObserver(observer);
+ }
+
+ @Override
+ public void removeObserver(BottomSheetObserver observer) {
+ if (mBottomSheet != null) {
+ mBottomSheet.removeObserver(observer);
+ } else {
+ mPendingSheetObservers.remove(observer);
+ }
+ }
+
+ /** Handle a change in the state of the token holder responsible for the suppression tokens. */
+ private void onSuppressionTokensChanged() {
+ if (!mSuppressionTokens.hasTokens()) doUnsuppression();
+ }
+
+ @Override
+ public int suppressSheet(@StateChangeReason int reason) {
+ boolean hadTokens = mSuppressionTokens.hasTokens();
+ int token = mSuppressionTokens.acquireToken();
+ if (!hadTokens && mBottomSheet != null) {
+ mSheetStateBeforeSuppress = getSheetState();
+ mContentWhenSuppressed = getCurrentSheetContent();
+ mBottomSheet.setSheetState(SheetState.HIDDEN, false, reason);
+ }
+
+ return token;
+ }
+
+ @Override
+ public void unsuppressSheet(int token) {
+ mSuppressionTokens.releaseToken(token);
+ }
+
+ private void doUnsuppression() {
+ if (mBottomSheet == null) return;
+
+ if (mBottomSheet.getCurrentSheetContent() != null) {
+ @SheetState
+ int openState = mContentWhenSuppressed == getCurrentSheetContent()
+ ? mSheetStateBeforeSuppress
+ : mBottomSheet.getOpeningState();
+ mBottomSheet.setSheetState(openState, true);
+ } else {
+ // In the event the previous content was hidden, try to show the next one.
+ showNextContent(true);
+ }
+ mContentWhenSuppressed = null;
+ mSheetStateBeforeSuppress = SheetState.NONE;
+ }
+
+ @Override
+ public void setAccssibilityUtil(AccessibilityUtil enabledSupplier) {
+ mAccessibilityUtil = enabledSupplier;
+ }
+
+ @VisibleForTesting
+ void setSheetStateForTesting(@SheetState int state, boolean animate) {
+ mBottomSheet.setSheetState(state, animate);
+ }
+
+ @VisibleForTesting
+ View getBottomSheetViewForTesting() {
+ return mBottomSheet;
+ }
+
+ @VisibleForTesting
+ public void endAnimationsForTesting() {
+ mBottomSheet.endAnimations();
+ }
+
+ @Override
+ public boolean requestShowContent(BottomSheetContent content, boolean animate) {
+ if (mBottomSheet == null) mSheetInitializer.run();
+
+ // If already showing the requested content, do nothing.
+ if (content == mBottomSheet.getCurrentSheetContent()) return true;
+
+ boolean shouldSuppressExistingContent = mBottomSheet.getCurrentSheetContent() != null
+ && content.getPriority() < mBottomSheet.getCurrentSheetContent().getPriority()
+ && canBottomSheetSwitchContent();
+
+ // Always add the content to the queue, it will be handled after the sheet closes if
+ // necessary. If already hidden, |showNextContent| will handle the request.
+ mContentQueue.add(content);
+
+ if (mBottomSheet.getCurrentSheetContent() == null) {
+ showNextContent(animate);
+ return true;
+ } else if (shouldSuppressExistingContent) {
+ mContentQueue.add(mBottomSheet.getCurrentSheetContent());
+ mBottomSheet.setSheetState(SheetState.HIDDEN, animate);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void hideContent(
+ BottomSheetContent content, boolean animate, @StateChangeReason int hideReason) {
+ if (mBottomSheet == null) return;
+
+ if (content != mBottomSheet.getCurrentSheetContent()) {
+ mContentQueue.remove(content);
+ return;
+ }
+
+ if (mIsProcessingHideRequest) return;
+
+ // Handle showing the next content if it exists.
+ if (mBottomSheet.getSheetState() == SheetState.HIDDEN) {
+ // If the sheet is already hidden, destroy it and simply show the next content.
+ // TODO(mdjones): Add tests to make sure the content is being destroyed as expected.
+ if (mBottomSheet.getCurrentSheetContent() != null) {
+ mBottomSheet.getCurrentSheetContent().destroy();
+ }
+ showNextContent(animate);
+ } else {
+ mIsProcessingHideRequest = true;
+ mBottomSheet.setSheetState(SheetState.HIDDEN, animate, hideReason);
+ }
+ }
+
+ @Override
+ public void hideContent(BottomSheetContent content, boolean animate) {
+ hideContent(content, animate, StateChangeReason.NONE);
+ }
+
+ @Override
+ public void expandSheet() {
+ if (mBottomSheet == null || mSuppressionTokens.hasTokens()) return;
+
+ if (mBottomSheet.getCurrentSheetContent() == null) return;
+ mBottomSheet.setSheetState(SheetState.HALF, true);
+ }
+
+ @Override
+ public boolean collapseSheet(boolean animate) {
+ if (mBottomSheet == null || mSuppressionTokens.hasTokens()) return false;
+ if (mBottomSheet.isSheetOpen() && mBottomSheet.isPeekStateEnabled()) {
+ mBottomSheet.setSheetState(SheetState.PEEK, animate);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Show the next {@link BottomSheetContent} if it is available and peek the sheet. If no content
+ * is available the sheet's content is set to null.
+ * @param animate Whether the sheet should animate opened.
+ */
+ private void showNextContent(boolean animate) {
+ if (mContentQueue.isEmpty()) {
+ mBottomSheet.showContent(null);
+ return;
+ }
+
+ BottomSheetContent nextContent = mContentQueue.poll();
+ mBottomSheet.showContent(nextContent);
+ mBottomSheet.setSheetState(mBottomSheet.getOpeningState(), animate);
+ }
+
+ @Override
+ public void clearRequestsAndHide() {
+ if (mBottomSheet == null) return;
+
+ clearRequests(mContentQueue.iterator());
+
+ BottomSheetContent currentContent = mBottomSheet.getCurrentSheetContent();
+ if (currentContent == null || !currentContent.hasCustomLifecycle()) {
+ hideContent(currentContent, /* animate= */ true);
+ }
+ mContentWhenSuppressed = null;
+ mSheetStateBeforeSuppress = SheetState.NONE;
+ }
+
+ /**
+ * Remove all contents from {@code iterator} that don't have a custom lifecycle.
+ * @param iterator The iterator whose items must be removed.
+ */
+ private void clearRequests(Iterator<BottomSheetContent> iterator) {
+ while (iterator.hasNext()) {
+ if (!iterator.next().hasCustomLifecycle()) {
+ iterator.remove();
+ }
+ }
+ }
+
+ /**
+ * The bottom sheet cannot change content while it is open. If the user has the bottom sheet
+ * open, they are currently engaged in a task and shouldn't be interrupted.
+ * @return Whether the sheet currently supports switching its content.
+ */
+ private boolean canBottomSheetSwitchContent() {
+ return !mBottomSheet.isSheetOpen();
+ }
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetector.java b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetector.java
new file mode 100644
index 00000000000..ae9d4e7e04d
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetector.java
@@ -0,0 +1,269 @@
+// 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.components.browser_ui.bottomsheet;
+
+import android.content.Context;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+
+import org.chromium.base.MathUtils;
+import org.chromium.base.ThreadUtils;
+
+/**
+ * A class that determines whether a sequence of motion events is a valid swipe in the context of a
+ * bottom sheet. The {@link SwipeableBottomSheet} that this class is built with provides information
+ * useful to determining if a swipe is valid. This class does not move the sheet itself, it only
+ * provides information on if/where it should move and whether it should animate. The
+ * {@link SwipeableBottomSheet} is responsible for applying the changes to the relevant views. Each
+ * swipe or fling is converted into a sequence of calls to
+ * {@link SwipeableBottomSheet#setSheetOffset(float, boolean)}.
+ */
+class BottomSheetSwipeDetector extends GestureDetector.SimpleOnGestureListener {
+ /** The minimum y/x ratio that a scroll must have to be considered vertical. */
+ private static final float MIN_VERTICAL_SCROLL_SLOPE = 2.0f;
+
+ /**
+ * The base duration of the settling animation of the sheet. 218 ms is a spec for material
+ * design (this is the minimum time a user is guaranteed to pay attention to something).
+ */
+ public static final long BASE_ANIMATION_DURATION_MS = 218;
+
+ /** For detecting scroll and fling events on the bottom sheet. */
+ private final GestureDetector mGestureDetector;
+
+ /** An interface for retrieving information from a bottom sheet. */
+ private final SwipeableBottomSheet mSheetDelegate;
+
+ /** Track the velocity of the user's scrolls to determine up or down direction. */
+ private VelocityTracker mVelocityTracker;
+
+ /** Whether or not the user is scrolling the bottom sheet. */
+ private boolean mIsScrolling;
+
+ /**
+ * An interface for views that are swipable from the bottom of the screen. This interface
+ * assumes that any part of the bottom sheet visible at the peeking state is the toolbar.
+ */
+ public interface SwipeableBottomSheet {
+ /**
+ * @return Whether the content being shown in the sheet is scrolled to the top.
+ */
+ boolean isContentScrolledToTop();
+
+ /**
+ * Gets the sheet's offset from the bottom of the screen.
+ * @return The sheet's distance from the bottom of the screen.
+ */
+ float getCurrentOffsetPx();
+
+ /**
+ * Gets the minimum offset of the bottom sheet.
+ * @return The min offset.
+ */
+ float getMinOffsetPx();
+
+ /**
+ * Gets the maximum offset of the bottom sheet.
+ * @return The max offset.
+ */
+ float getMaxOffsetPx();
+
+ /**
+ * @param event The motion event to test.
+ * @return Whether the provided motion event is inside the toolbar.
+ */
+ boolean isTouchEventInToolbar(MotionEvent event);
+
+ /**
+ * Check if a particular gesture or touch event should move the bottom sheet when in peeking
+ * mode. If the "chrome-home-swipe-logic" flag is not set this function returns true.
+ * @param initialDownEvent The event that started the scroll.
+ * @param currentEvent The current motion event.
+ * @return True if the bottom sheet should move.
+ */
+ boolean shouldGestureMoveSheet(MotionEvent initialDownEvent, MotionEvent currentEvent);
+
+ /**
+ * Set the sheet's offset.
+ * @param offset The target offset.
+ * @param shouldAnimate Whether the sheet should animate to that position.
+ */
+ void setSheetOffset(float offset, boolean shouldAnimate);
+ }
+
+ /**
+ * This class is responsible for detecting swipe and scroll events on the bottom sheet or
+ * ignoring them when appropriate.
+ */
+ private class SwipeGestureListener extends GestureDetector.SimpleOnGestureListener {
+ @Override
+ public boolean onDown(MotionEvent e) {
+ if (e == null) return false;
+ return mSheetDelegate.shouldGestureMoveSheet(e, e);
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+ if (e1 == null || !mSheetDelegate.shouldGestureMoveSheet(e1, e2)) return false;
+
+ // Only start scrolling if the scroll is up or down. If the user is already scrolling,
+ // continue moving the sheet.
+ float slope = Math.abs(distanceX) > 0f ? Math.abs(distanceY) / Math.abs(distanceX)
+ : MIN_VERTICAL_SCROLL_SLOPE;
+ if (!mIsScrolling && slope < MIN_VERTICAL_SCROLL_SLOPE) {
+ mVelocityTracker.clear();
+ return false;
+ }
+
+ mVelocityTracker.addMovement(e2);
+
+ boolean isSheetInMaxPosition = MathUtils.areFloatsEqual(
+ mSheetDelegate.getCurrentOffsetPx(), mSheetDelegate.getMaxOffsetPx());
+
+ // Allow the bottom sheet's content to be scrolled up without dragging the sheet down.
+ if (!mSheetDelegate.isTouchEventInToolbar(e2) && isSheetInMaxPosition
+ && !mSheetDelegate.isContentScrolledToTop()) {
+ return false;
+ }
+
+ // If the sheet is in the max position, don't move the sheet if the scroll is upward.
+ // Instead, allow the sheet's content to handle it if it needs to.
+ if (isSheetInMaxPosition && distanceY > 0) return false;
+
+ boolean isSheetInMinPosition = MathUtils.areFloatsEqual(
+ mSheetDelegate.getCurrentOffsetPx(), mSheetDelegate.getMinOffsetPx());
+
+ // Similarly, if the sheet is in the min position, don't move if the scroll is downward.
+ if (isSheetInMinPosition && distanceY < 0) return false;
+
+ float newOffset = mSheetDelegate.getCurrentOffsetPx() + distanceY;
+
+ mIsScrolling = true;
+
+ mSheetDelegate.setSheetOffset(
+ MathUtils.clamp(newOffset, mSheetDelegate.getMinOffsetPx(),
+ mSheetDelegate.getMaxOffsetPx()),
+ false);
+
+ return true;
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ mIsScrolling = false;
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ if (e1 == null || !mSheetDelegate.shouldGestureMoveSheet(e1, e2) || !mIsScrolling) {
+ return false;
+ }
+
+ mIsScrolling = false;
+
+ float newOffset = mSheetDelegate.getCurrentOffsetPx() + getFlingDistance(-velocityY);
+
+ mSheetDelegate.setSheetOffset(
+ MathUtils.clamp(newOffset, mSheetDelegate.getMinOffsetPx(),
+ mSheetDelegate.getMaxOffsetPx()),
+ true);
+
+ return true;
+ }
+ }
+
+ /**
+ * Default constructor.
+ * @param context A context for the GestureDetector this class uses.
+ * @param delegate A SwipeableBottomSheet that processes swipes.
+ */
+ public BottomSheetSwipeDetector(Context context, SwipeableBottomSheet delegate) {
+ mGestureDetector = new GestureDetector(
+ context, new SwipeGestureListener(), ThreadUtils.getUiThreadHandler());
+ mGestureDetector.setIsLongpressEnabled(true);
+
+ mSheetDelegate = delegate;
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+
+ /**
+ * Test whether or not a motion event should be intercepted by this class.
+ * @param e The motion event to test.
+ * @return Whether or not the event was intercepted.
+ */
+ public boolean onInterceptTouchEvent(MotionEvent e) {
+ // The incoming motion event may have been adjusted by the view sending it down. Create a
+ // motion event with the raw (x, y) coordinates of the original so the gesture detector
+ // functions properly.
+ mGestureDetector.onTouchEvent(createRawMotionEvent(e));
+
+ return mIsScrolling;
+ }
+
+ /**
+ * Process a motion event.
+ * @param e The motion event to process.
+ * @return Whether or not the motion event was used.
+ */
+ public boolean onTouchEvent(MotionEvent e) {
+ // The down event is interpreted above in onInterceptTouchEvent, it does not need to be
+ // interpreted a second time.
+ if (e.getActionMasked() != MotionEvent.ACTION_DOWN) {
+ mGestureDetector.onTouchEvent(createRawMotionEvent(e));
+ }
+
+ // If the user is scrolling and the event is a cancel or up action, update scroll state and
+ // return. Fling should have already cleared the mIsScrolling flag, the following is for the
+ // non-fling release.
+ if (mIsScrolling
+ && (e.getActionMasked() == MotionEvent.ACTION_UP
+ || e.getActionMasked() == MotionEvent.ACTION_CANCEL)) {
+ mIsScrolling = false;
+
+ mVelocityTracker.computeCurrentVelocity(1000);
+
+ float newOffset = mSheetDelegate.getCurrentOffsetPx()
+ + getFlingDistance(-mVelocityTracker.getYVelocity());
+
+ mSheetDelegate.setSheetOffset(
+ MathUtils.clamp(newOffset, mSheetDelegate.getMinOffsetPx(),
+ mSheetDelegate.getMaxOffsetPx()),
+ true);
+ }
+
+ return true;
+ }
+
+ /**
+ * @return Whether or not a gesture is currently being detected as a scroll.
+ */
+ public boolean isScrolling() {
+ return mIsScrolling;
+ }
+
+ /**
+ * Creates an unadjusted version of a MotionEvent.
+ * @param e The original event.
+ * @return The unadjusted version of the event.
+ */
+ private MotionEvent createRawMotionEvent(MotionEvent e) {
+ MotionEvent rawEvent = MotionEvent.obtain(e);
+ rawEvent.setLocation(e.getRawX(), e.getRawY());
+ return rawEvent;
+ }
+
+ /**
+ * Gets 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;
+ }
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetectorTest.java b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetectorTest.java
new file mode 100644
index 00000000000..a3a557a9cfa
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetSwipeDetectorTest.java
@@ -0,0 +1,319 @@
+// 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.components.browser_ui.bottomsheet;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.view.MotionEvent;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+
+import org.chromium.base.MathUtils;
+import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.components.browser_ui.bottomsheet.BottomSheetSwipeDetector.SwipeableBottomSheet;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Unit tests for the {@link BottomSheetSwipeDetector} class.
+ */
+@RunWith(BaseRobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public final class BottomSheetSwipeDetectorTest {
+ /** The minimum height of the bottom sheet. */
+ private static final float MIN_SHEET_OFFSET = 100;
+
+ /** An arbitrary screen height. */
+ private static final float SCREEN_HEIGHT = 1000;
+
+ /** An instance of the mock swipable sheet. */
+ private MockSwipeableBottomSheet mSwipeableBottomSheet;
+
+ /** The swipe detector to process motion events. */
+ private BottomSheetSwipeDetector mSwipeDetector;
+
+ /** A mock implementation of a swipeable bottom sheet. */
+ private static class MockSwipeableBottomSheet implements SwipeableBottomSheet {
+ /** The minimum offset of the sheet. */
+ private final float mMinOffset;
+
+ /** The maximum offset of the sheet. */
+ private final float mMaxOffset;
+
+ /** Whether the content in the sheet is currently scrolled to the top. */
+ public boolean isContentScrolledToTop;
+
+ /** Whether the sheet should currently be animating. */
+ public boolean shouldBeAnimating;
+
+ /** The current offset of the bottom sheet. */
+ private float mCurrentSheetOffset;
+
+ public MockSwipeableBottomSheet(float minOffset, float maxOffset) {
+ mMinOffset = minOffset;
+ mMaxOffset = maxOffset;
+
+ // The sheet should be initialized at the minimum state.
+ mCurrentSheetOffset = mMinOffset;
+
+ isContentScrolledToTop = true;
+ }
+
+ @Override
+ public boolean isContentScrolledToTop() {
+ return isContentScrolledToTop;
+ }
+
+ @Override
+ public float getCurrentOffsetPx() {
+ return mCurrentSheetOffset;
+ }
+
+ @Override
+ public float getMinOffsetPx() {
+ return mMinOffset;
+ }
+
+ @Override
+ public float getMaxOffsetPx() {
+ return mMaxOffset;
+ }
+
+ @Override
+ public boolean isTouchEventInToolbar(MotionEvent event) {
+ // This will be implementation specific in practice. This checks that the motion event
+ // occured above the bottom of the toolbar.
+ return event.getRawY() < (mMaxOffset - mCurrentSheetOffset) + mMinOffset;
+ }
+
+ @Override
+ public boolean shouldGestureMoveSheet(
+ MotionEvent initialDownEvent, MotionEvent currentEvent) {
+ return true;
+ }
+
+ @Override
+ public void setSheetOffset(float offset, boolean shouldAnimate) {
+ mCurrentSheetOffset = offset;
+ shouldBeAnimating = shouldAnimate;
+ }
+ }
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ mSwipeableBottomSheet = new MockSwipeableBottomSheet(MIN_SHEET_OFFSET, SCREEN_HEIGHT);
+ mSwipeDetector = new BottomSheetSwipeDetector(null, mSwipeableBottomSheet);
+ }
+
+ /**
+ * Create a list of motion events simulating a scroll event stream from (x1, y1) to (x2, y2)
+ * and apply it to the provided swipe detector.
+ * @param x1 The start x.
+ * @param y1 The start y.
+ * @param x2 The end x.
+ * @param y2 The end y.
+ * @param detector The detector to apply the swipe to.
+ * @param endScroll Whether or not to include the up event at the end of the stream.
+ * @return A list of motion events.
+ */
+ private static void performScroll(float x1, float y1, float x2, float y2,
+ BottomSheetSwipeDetector detector, boolean endScroll) {
+ int moveEventCount = 10;
+
+ ArrayList<MotionEvent> eventStream = new ArrayList<>();
+ float xInterval = (x2 - x1) / moveEventCount;
+ float yInterval = (y2 - y1) / moveEventCount;
+ eventStream.add(MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, x1, y1, 0));
+ for (int i = 0; i < moveEventCount; i++) {
+ eventStream.add(MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE,
+ x1 + ((i + 1) * xInterval), y1 + ((i + 1) * yInterval), 0));
+ }
+ if (endScroll) eventStream.add(MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, x2, y2, 0));
+
+ applyGestureStream(eventStream, detector);
+ }
+
+ /**
+ * Apply a list of events to a swipe detector.
+ * @param stream The list of motion events to apply to the detector.
+ * @param detector The detector to apply the swipe to.
+ */
+ private static void applyGestureStream(
+ List<MotionEvent> stream, BottomSheetSwipeDetector detector) {
+ for (MotionEvent e : stream) {
+ if (!detector.isScrolling()) {
+ detector.onInterceptTouchEvent(e);
+ } else {
+ detector.onTouchEvent(e);
+ }
+ }
+ }
+
+ /** Test that the sheet moves when scrolled up from min height. */
+ @Test
+ public void testScrollToolbarUp_minHeight() {
+ assertEquals("The sheet should be at the minimum state.", MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ final float halfScreenHeight = SCREEN_HEIGHT / 2f;
+
+ // Scrolling up half the screen should put the sheet at half + the min offset.
+ performScroll(0, SCREEN_HEIGHT, 0, halfScreenHeight, mSwipeDetector, true);
+
+ assertEquals("The sheet is not at the correct height.", halfScreenHeight + MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ assertTrue("The sheet should be set to animate.", mSwipeableBottomSheet.shouldBeAnimating);
+ }
+
+ /** Test that the sheet is not told to animate mid-stream. */
+ @Test
+ public void testScrollToolbarUp_minHeight_noUpEvent() {
+ final float halfScreenHeight = SCREEN_HEIGHT / 2f;
+
+ performScroll(0, SCREEN_HEIGHT, 0, halfScreenHeight, mSwipeDetector, false);
+
+ assertEquals("The sheet is not at the correct height.", halfScreenHeight + MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ assertFalse(
+ "The sheet should not be set to animate.", mSwipeableBottomSheet.shouldBeAnimating);
+ }
+
+ /** Test that the sheet does not move when scrolled up from max height. */
+ @Test
+ public void testScrollToolbarUp_maxHeight() {
+ // Init the sheet to be full height.
+ mSwipeableBottomSheet.setSheetOffset(SCREEN_HEIGHT, false);
+
+ assertEquals("The sheet should be at the maximum state.", SCREEN_HEIGHT,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+
+ performScroll(0, 0, 0, -500, mSwipeDetector, true);
+
+ assertEquals("The sheet should still be at the maximum state.", SCREEN_HEIGHT,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ assertFalse(
+ "The sheet should not be set to animate.", mSwipeableBottomSheet.shouldBeAnimating);
+ }
+
+ /** Test that the sheet does not move when scrolled down from min height. */
+ @Test
+ public void testScrollToolbarDown_minHeight() {
+ assertEquals("The sheet should be at the minimum state.", MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+
+ performScroll(0, SCREEN_HEIGHT, 0, SCREEN_HEIGHT + 500, mSwipeDetector, true);
+
+ assertEquals("The sheet should still be at the minimum state.", MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ assertFalse(
+ "The sheet should not be set to animate.", mSwipeableBottomSheet.shouldBeAnimating);
+ }
+
+ /** Test that the sheet moves when scrolled down from max height. */
+ @Test
+ public void testScrollToolbarDown_maxHeight() {
+ // Init the sheet to be full height.
+ mSwipeableBottomSheet.setSheetOffset(SCREEN_HEIGHT, false);
+
+ assertEquals("The sheet should be at the maximum state.", SCREEN_HEIGHT,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ final float halfScreenHeight = SCREEN_HEIGHT / 2f;
+
+ // Scrolling down half the screen should put the sheet at half height.
+ performScroll(0, 0, 0, halfScreenHeight, mSwipeDetector, true);
+
+ assertEquals("The sheet is not at the correct height.", halfScreenHeight,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ assertTrue("The sheet should be set to animate.", mSwipeableBottomSheet.shouldBeAnimating);
+ }
+
+ /**
+ * Test that the sheet moves when scrolled down from max height while the content has been
+ * scrolled.
+ */
+ @Test
+ public void testScrollToolbarDown_maxHeight_contentScrolled() {
+ // Init the sheet to be full height.
+ mSwipeableBottomSheet.setSheetOffset(SCREEN_HEIGHT, false);
+
+ assertEquals("The sheet should be at the maximum state.", SCREEN_HEIGHT,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ final float halfScreenHeight = SCREEN_HEIGHT / 2f;
+
+ // Scrolling down half the screen should put the sheet at half height, regardless of the
+ // state of the content.
+ performScroll(0, 0, 0, halfScreenHeight, mSwipeDetector, true);
+
+ assertEquals("The sheet is not at the correct height.", halfScreenHeight,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ assertTrue("The sheet should be set to animate.", mSwipeableBottomSheet.shouldBeAnimating);
+ }
+
+ /** Test that the sheet does not move when a scroll is not sufficiently in the up direction. */
+ @Test
+ public void testScrollToolbarDiagonal_minHeight() {
+ assertEquals("The sheet should be at the minimum state.", MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ final float halfScreenHeight = SCREEN_HEIGHT / 2f;
+
+ performScroll(
+ 0, halfScreenHeight, halfScreenHeight, halfScreenHeight, mSwipeDetector, true);
+
+ assertEquals("The sheet should still be at the minimum state.", MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ assertFalse(
+ "The sheet should not be set to animate.", mSwipeableBottomSheet.shouldBeAnimating);
+ }
+
+ /**
+ * Test that the sheet does not move when the content is scrolled up and the sheet is at max
+ * height.
+ */
+ @Test
+ public void testScrollContent_maxHeight() {
+ // Init the sheet to be full height.
+ mSwipeableBottomSheet.setSheetOffset(SCREEN_HEIGHT, false);
+
+ // Content is scrolled some amount.
+ mSwipeableBottomSheet.isContentScrolledToTop = false;
+
+ final float halfScreenHeight = SCREEN_HEIGHT / 2f;
+
+ // Scroll down half the screen. The sheet should not move since the content is scrolled.
+ performScroll(0, halfScreenHeight, 0, SCREEN_HEIGHT, mSwipeDetector, true);
+
+ assertEquals("The sheet should still be at the maximum state.", SCREEN_HEIGHT,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ }
+
+ /**
+ * Test that the sheet moves when a scroll occurs on the body of the sheet. Content should only
+ * scroll if the sheet is at max height.
+ */
+ @Test
+ public void testScrollContent_halfHeight() {
+ final float halfScreenHeight = SCREEN_HEIGHT / 2f;
+
+ // Init the sheet to be half height.
+ mSwipeableBottomSheet.setSheetOffset(halfScreenHeight, false);
+
+ // Content is scrolled some amount.
+ mSwipeableBottomSheet.isContentScrolledToTop = false;
+
+ // Scroll down on the content, the sheet should move.
+ performScroll(0, halfScreenHeight / 2f, 0, SCREEN_HEIGHT, mSwipeDetector, true);
+
+ assertEquals("The sheet should be at the minimum state.", MIN_SHEET_OFFSET,
+ mSwipeableBottomSheet.getCurrentOffsetPx(), MathUtils.EPSILON);
+ }
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/TouchRestrictingFrameLayout.java b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/TouchRestrictingFrameLayout.java
new file mode 100644
index 00000000000..45d7f47cc8e
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/internal/java/src/org/chromium/components/browser_ui/bottomsheet/TouchRestrictingFrameLayout.java
@@ -0,0 +1,50 @@
+// Copyright 2018 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.components.browser_ui.bottomsheet;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.FrameLayout;
+
+/**
+ * A specialized FrameLayout that is capable of ignoring all user input based on the state of
+ * the bottom sheet.
+ */
+class TouchRestrictingFrameLayout extends FrameLayout {
+ /** A handle to the bottom sheet. */
+ private BottomSheet mBottomSheet;
+
+ public TouchRestrictingFrameLayout(Context context, AttributeSet atts) {
+ super(context, atts);
+ }
+
+ /**
+ * @param sheet The bottom sheet.
+ */
+ public void setBottomSheet(BottomSheet sheet) {
+ mBottomSheet = sheet;
+ }
+
+ /**
+ * @return Whether touch is enabled.
+ */
+ private boolean isTouchDisabled() {
+ return mBottomSheet == null
+ || mBottomSheet.getSheetState() == BottomSheetController.SheetState.SCROLLING;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ if (isTouchDisabled()) return false;
+ return super.onInterceptTouchEvent(event);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (isTouchDisabled()) return false;
+ return super.onTouchEvent(event);
+ }
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/java/res/layout/bottom_sheet.xml b/chromium/components/browser_ui/android/bottomsheet/java/res/layout/bottom_sheet.xml
new file mode 100644
index 00000000000..37ce7fcab57
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/java/res/layout/bottom_sheet.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2018 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. -->
+
+<org.chromium.components.browser_ui.bottomsheet.BottomSheet
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/bottom_sheet"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" >
+
+ <org.chromium.components.browser_ui.bottomsheet.TouchRestrictingFrameLayout
+ android:id="@+id/bottom_sheet_content"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@drawable/top_round" />
+
+ <FrameLayout
+ android:id="@+id/bottom_sheet_control_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" >
+
+ <view
+ class="org.chromium.components.browser_ui.bottomsheet.TouchRestrictingFrameLayout"
+ android:id="@+id/bottom_sheet_toolbar_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" >
+
+ <View
+ android:id="@+id/bottom_sheet_toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/bottom_sheet_peek_height" />
+ </view>
+
+ </FrameLayout>
+
+ <FrameLayout
+ android:id="@+id/bottom_sheet_snackbar_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="start|bottom" />
+
+</org.chromium.components.browser_ui.bottomsheet.BottomSheet>
diff --git a/chromium/components/browser_ui/android/bottomsheet/java/res/values/dimens.xml b/chromium/components/browser_ui/android/bottomsheet/java/res/values/dimens.xml
new file mode 100644
index 00000000000..d795be2ea38
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/java/res/values/dimens.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2020 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. -->
+
+<resources xmlns:tools="http://schemas.android.com/tools">
+ <dimen name="bottom_sheet_min_full_half_distance">140dp</dimen>
+ <dimen name="bottom_sheet_peek_height">56dp</dimen>
+ <dimen name="bottom_sheet_toolbar_shadow_height">16dp</dimen>
+ <dimen name="bottom_sheet_shadow_top_offset">10dp</dimen>
+</resources>
+
diff --git a/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetController.java b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetController.java
new file mode 100644
index 00000000000..d24e41afe7b
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetController.java
@@ -0,0 +1,158 @@
+// Copyright 2020 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.components.browser_ui.bottomsheet;
+
+import androidx.annotation.IntDef;
+
+import org.chromium.components.browser_ui.widget.scrim.ScrimCoordinator;
+import org.chromium.ui.modelutil.PropertyModel;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * The public interface for the bottom sheet's controller. Features wishing to show content in the
+ * sheet UI must implement {@link BottomSheetContent} and call
+ * {@link #requestShowContent(BottomSheetContent, boolean)} which will return true if the content
+ * was actually shown (see full doc on method).
+ */
+public interface BottomSheetController {
+ /** The different states that the bottom sheet can have. */
+ @IntDef({SheetState.NONE, SheetState.HIDDEN, SheetState.PEEK, SheetState.HALF, SheetState.FULL,
+ SheetState.SCROLLING})
+ @Retention(RetentionPolicy.SOURCE)
+ @interface SheetState {
+ /**
+ * NONE is for internal use only and indicates the sheet is not currently
+ * transitioning between states.
+ */
+ int NONE = -1;
+ // Values are used for indexing mStateRatios, should start from 0
+ // and can't have gaps. Additionally order is important for these,
+ // they go from smallest to largest.
+ int HIDDEN = 0;
+ int PEEK = 1;
+ int HALF = 2;
+ int FULL = 3;
+
+ int SCROLLING = 4;
+ }
+
+ /**
+ * The different reasons that the sheet's state can change.
+ *
+ * Needs to stay in sync with BottomSheet.StateChangeReason in enums.xml. These values are
+ * persisted to logs. Entries should not be renumbered and numeric values should never be
+ * reused.
+ */
+ @IntDef({StateChangeReason.NONE, StateChangeReason.SWIPE, StateChangeReason.BACK_PRESS,
+ StateChangeReason.TAP_SCRIM, StateChangeReason.NAVIGATION,
+ StateChangeReason.COMPOSITED_UI, StateChangeReason.VR, StateChangeReason.PROMOTE_TAB,
+ StateChangeReason.OMNIBOX_FOCUS, StateChangeReason.MAX_VALUE})
+ @Retention(RetentionPolicy.SOURCE)
+ @interface StateChangeReason {
+ int NONE = 0;
+ int SWIPE = 1;
+ int BACK_PRESS = 2;
+ int TAP_SCRIM = 3;
+ int NAVIGATION = 4;
+ int COMPOSITED_UI = 5;
+ int VR = 6;
+ int PROMOTE_TAB = 7;
+ int OMNIBOX_FOCUS = 8;
+ int MAX_VALUE = OMNIBOX_FOCUS;
+ }
+
+ /**
+ * Request that some content be shown in the bottom sheet.
+ * @param content The content to be shown in the bottom sheet.
+ * @param animate Whether the appearance of the bottom sheet should be animated.
+ * @return True if the content was shown, false if it was suppressed. Content is suppressed if
+ * higher priority content is in the sheet, the sheet is expanded beyond the peeking
+ * state, or the browser is in a mode that does not support showing the sheet.
+ */
+ boolean requestShowContent(BottomSheetContent content, boolean animate);
+
+ /**
+ * Hide content shown in the bottom sheet. If the content is not showing, this call retracts the
+ * request to show it.
+ * @param content The content to be hidden.
+ * @param animate Whether the sheet should animate when hiding.
+ * @param hideReason The reason that the content is being hidden.
+ */
+ void hideContent(
+ BottomSheetContent content, boolean animate, @StateChangeReason int hideReason);
+
+ void hideContent(BottomSheetContent content, boolean animate);
+
+ /** @param observer The observer to add. */
+ void addObserver(BottomSheetObserver observer);
+
+ /** @param observer The observer to remove. */
+ void removeObserver(BottomSheetObserver observer);
+
+ /**
+ * Expand the sheet. If there is no content in the sheet, this is a noop.
+ */
+ void expandSheet();
+
+ /**
+ * Collapse the current sheet to peek state. Sheet may not change the state if the state
+ * is not allowed.
+ * @param animate {@code true} for animation effect.
+ * @return {@code true} if the sheet could go to the peek state.
+ */
+ boolean collapseSheet(boolean animate);
+
+ /** @return The content currently showing in the bottom sheet. */
+ BottomSheetContent getCurrentSheetContent();
+
+ /** @return The current state of the bottom sheet. */
+ @SheetState
+ int getSheetState();
+
+ /** @return The target state of the bottom sheet (usually during animations). */
+ @SheetState
+ int getTargetSheetState();
+
+ /** @return Whether the bottom sheet is currently open (expanded beyond peek state). */
+ boolean isSheetOpen();
+
+ /** @return Whether the bottom sheet is in the process of hiding. */
+ boolean isSheetHiding();
+
+ /** @return The current offset from the bottom of the screen that the sheet is in px. */
+ int getCurrentOffset();
+
+ /**
+ * @return The height of the bottom sheet's container in px. This will return 0 if the sheet has
+ * not been initialized (content has not been requested).
+ */
+ int getContainerHeight();
+
+ /** @return The height of the shadow above the bottom sheet in px. */
+ int getTopShadowHeight();
+
+ /**
+ * @return The srcim's coordinator. This can be used to customize the bottom sheet's interaction
+ * with the scrim if the default behavior is not desired -- fading in behind the sheet
+ * as the sheet is expanded.
+ */
+ ScrimCoordinator getScrimCoordinator();
+
+ /**
+ * This method provides a property model that can be used to show the scrim behind the bottom
+ * sheet. This can be used in conjunction with {@link #getScrimCoordinator()} to customize the
+ * scrim's behavior. While this method is not required to show the scrim, this method returns
+ * a model set up to appear behnind the sheet. Common usage is the following:
+ *
+ * PropertyModel params = controller.createScrimParams();
+ * // further modify params
+ * controller.getScrimCoordinator().showScrim(params);
+ *
+ * @return A property model used to show the scrim behind the bottom sheet.
+ */
+ PropertyModel createScrimParams();
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetObserver.java b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetObserver.java
new file mode 100644
index 00000000000..e5c2ae2687c
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetObserver.java
@@ -0,0 +1,57 @@
+// 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.components.browser_ui.bottomsheet;
+
+import androidx.annotation.Nullable;
+
+import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.SheetState;
+import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.StateChangeReason;
+
+/**
+ * An interface for notifications about the state of the bottom sheet.
+ */
+public interface BottomSheetObserver {
+ /**
+ * A notification that the sheet has been opened, meaning the sheet is any height greater
+ * than its peeking state.
+ * @param reason The {@link StateChangeReason} that the sheet was opened.
+ */
+ void onSheetOpened(@StateChangeReason int reason);
+
+ /**
+ * A notification that the sheet has closed, meaning the sheet has reached its peeking state.
+ * @param reason The {@link StateChangeReason} that the sheet was closed.
+ */
+ void onSheetClosed(@StateChangeReason int reason);
+
+ /**
+ * An event for when the sheet's offset from the bottom of the screen changes.
+ *
+ * @param heightFraction The fraction of the way to the fully expanded state that the sheet
+ * is. This will be 0.0f when the sheet is hidden or scrolled off-screen
+ * and 1.0f when the sheet is completely expanded.
+ * @param offsetPx The offset of the top of the sheet from the bottom of the screen in pixels.
+ */
+ void onSheetOffsetChanged(float heightFraction, float offsetPx);
+
+ /**
+ * An event for when the sheet changes state.
+ * @param newState The new sheet state. See {@link SheetState}.
+ */
+ void onSheetStateChanged(@SheetState int newState);
+
+ /**
+ * An event for when the sheet reaches its full peeking height. This is called when the sheet
+ * is finished being scrolled back on-screen or finishes animating to its peeking state. This
+ * is also called when going back to the peeking state after the sheet has been opened.
+ */
+ void onSheetFullyPeeked();
+
+ /**
+ * An event for when the sheet content changes.
+ * @param newContent The new {@link BottomSheetContent}, or null if the sheet has no content.
+ */
+ void onSheetContentChanged(@Nullable BottomSheetContent newContent);
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/EmptyBottomSheetObserver.java b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/EmptyBottomSheetObserver.java
new file mode 100644
index 00000000000..c758da5acf5
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/EmptyBottomSheetObserver.java
@@ -0,0 +1,30 @@
+// 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.components.browser_ui.bottomsheet;
+
+import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.StateChangeReason;
+
+/**
+ * An empty base implementation of the {@link BottomSheetObserver} interface.
+ */
+public class EmptyBottomSheetObserver implements BottomSheetObserver {
+ @Override
+ public void onSheetOpened(@StateChangeReason int reason) {}
+
+ @Override
+ public void onSheetClosed(@StateChangeReason int reason) {}
+
+ @Override
+ public void onSheetOffsetChanged(float heightFraction, float offsetPx) {}
+
+ @Override
+ public void onSheetStateChanged(int newState) {}
+
+ @Override
+ public void onSheetFullyPeeked() {}
+
+ @Override
+ public void onSheetContentChanged(BottomSheetContent newContent) {}
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/ManagedBottomSheetController.java b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/ManagedBottomSheetController.java
new file mode 100644
index 00000000000..aa045e501f5
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/java/src/org/chromium/components/browser_ui/bottomsheet/ManagedBottomSheetController.java
@@ -0,0 +1,54 @@
+// Copyright 2020 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.components.browser_ui.bottomsheet;
+
+import org.chromium.ui.util.AccessibilityUtil;
+
+/**
+ * An interface for the owning object to manage interaction between the bottom sheet and the rest
+ * of the system.
+ */
+public interface ManagedBottomSheetController extends BottomSheetController {
+ /**
+ * Temporarily suppress the bottom sheet while other UI is showing. This will not itself change
+ * the content displayed by the sheet.
+ * @param reason The reason the sheet was suppressed.
+ * @return A token to unsuppress the sheet with.
+ */
+ int suppressSheet(@StateChangeReason int reason);
+
+ /**
+ * Unsuppress the bottom sheet. This may or may not affect the sheet depending on the state of
+ * the browser (i.e. the tab switcher may be showing).
+ * @param token The token that was received from suppressing the sheet.
+ */
+ void unsuppressSheet(int token);
+
+ /**
+ * For all contents that don't have a custom lifecycle, we remove them from show requests or
+ * hide it if it is currently shown.
+ */
+ void clearRequestsAndHide();
+
+ /**
+ * Handle a back press event. By default this will return the bottom sheet to it's minimum /
+ * peeking state if it is open. However, the sheet's content has the opportunity to intercept
+ * this event and block the default behavior {@see BottomSheetContent#handleBackPress()}.
+ * @return {@code true} if the sheet or content handled the back press.
+ */
+ boolean handleBackPress();
+
+ /**
+ * Set the hidden ratio of the browser controls.
+ * @param ratio The hidden ratio of the browser controls in range [0, 1].
+ */
+ void setBrowserControlsHiddenRatio(float ratio);
+
+ /** @param accessibilityUtil A mechanism for testing whether accessibility is enabled. */
+ void setAccssibilityUtil(AccessibilityUtil accessibilityUtil);
+
+ /** Clean up any state maintained by the controller. */
+ void destroy();
+}
diff --git a/chromium/components/browser_ui/android/bottomsheet/test/BUILD.gn b/chromium/components/browser_ui/android/bottomsheet/test/BUILD.gn
new file mode 100644
index 00000000000..f8c7f8b22fc
--- /dev/null
+++ b/chromium/components/browser_ui/android/bottomsheet/test/BUILD.gn
@@ -0,0 +1,16 @@
+# Copyright 2020 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.
+
+import("//build/config/android/rules.gni")
+
+android_library("java") {
+ sources = [ "java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetTestSupport.java" ]
+
+ deps = [
+ "..:java",
+ "..:manager_java",
+ "../internal:java",
+ "//third_party/android_deps:androidx_annotation_annotation_java",
+ ]
+}