diff options
author | Allan Sandfeld Jensen <allan.jensen@qt.io> | 2020-10-12 14:27:29 +0200 |
---|---|---|
committer | Allan Sandfeld Jensen <allan.jensen@qt.io> | 2020-10-13 09:35:20 +0000 |
commit | c30a6232df03e1efbd9f3b226777b07e087a1122 (patch) | |
tree | e992f45784689f373bcc38d1b79a239ebe17ee23 /chromium/components/browser_ui/android | |
parent | 7b5b123ac58f58ffde0f4f6e488bcd09aa4decd3 (diff) | |
download | qtwebengine-chromium-85-based.tar.gz |
BASELINE: Update Chromium to 85.0.4183.14085-based
Change-Id: Iaa42f4680837c57725b1344f108c0196741f6057
Reviewed-by: Allan Sandfeld Jensen <allan.jensen@qt.io>
Diffstat (limited to 'chromium/components/browser_ui/android')
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", + ] +} |