| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.chrome.browser.widget.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.os.Build; |
| 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.IntDef; |
| import androidx.annotation.Nullable; |
| |
| import org.chromium.base.ObserverList; |
| import org.chromium.base.Supplier; |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.chrome.R; |
| import org.chromium.chrome.browser.ActivityTabProvider; |
| import org.chromium.chrome.browser.ChromeActivity; |
| import org.chromium.chrome.browser.TabLoadStatus; |
| import org.chromium.chrome.browser.fullscreen.ChromeFullscreenManager; |
| import org.chromium.chrome.browser.fullscreen.ChromeFullscreenManager.FullscreenListener; |
| import org.chromium.chrome.browser.gesturenav.HistoryNavigationDelegate; |
| import org.chromium.chrome.browser.native_page.NativePageHost; |
| import org.chromium.chrome.browser.tab.Tab; |
| import org.chromium.chrome.browser.tab.TabBrowserControlsState; |
| import org.chromium.chrome.browser.util.AccessibilityUtil; |
| import org.chromium.chrome.browser.util.MathUtils; |
| import org.chromium.content_public.browser.LoadUrlParams; |
| import org.chromium.content_public.browser.SelectionPopupController; |
| import org.chromium.content_public.browser.WebContents; |
| import org.chromium.content_public.common.BrowserControlsState; |
| import org.chromium.ui.KeyboardVisibilityDelegate; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| |
| /** |
| * 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. |
| */ |
| public class BottomSheet |
| extends FrameLayout implements BottomSheetSwipeDetector.SwipeableBottomSheet, |
| NativePageHost, View.OnLayoutChangeListener { |
| /** 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) |
| public @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. */ |
| @IntDef({StateChangeReason.NONE, StateChangeReason.SWIPE, StateChangeReason.BACK_PRESS, |
| StateChangeReason.TAP_SCRIM, StateChangeReason.NAVIGATION, |
| StateChangeReason.COMPOSITED_UI, StateChangeReason.VR}) |
| @Retention(RetentionPolicy.SOURCE) |
| public @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; |
| } |
| |
| /** The different priorities that the sheet's content can have. */ |
| @IntDef({ContentPriority.HIGH, ContentPriority.LOW}) |
| @Retention(RetentionPolicy.SOURCE) |
| public @interface ContentPriority { |
| int HIGH = 0; |
| int LOW = 1; |
| } |
| |
| /** |
| * 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; |
| |
| /** |
| * 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.5f; |
| |
| /** 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; |
| |
| /** Invalid height ratio. When specified, a default value is used. */ |
| private static final float INVALID_HEIGHT_RATIO = -1.0f; |
| |
| /** 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 float mContainerWidth; |
| |
| /** The height of the view that contains the bottom sheet. */ |
| private float 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; |
| |
| /** Used for getting the current tab. */ |
| protected Supplier<Tab> mTabSupplier; |
| |
| /** The fullscreen manager for information about toolbar offsets. */ |
| private ChromeFullscreenManager mFullscreenManager; |
| |
| /** A handle to the content being shown by the sheet. */ |
| @Nullable |
| protected BottomSheetContent mSheetContent; |
| |
| /** A handle to the find-in-page toolbar. */ |
| private View mFindInPageView; |
| |
| /** 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 token used to enable browser controls persistence. */ |
| private int mPersistentControlsToken; |
| |
| /** |
| * An interface defining content that can be displayed inside of the bottom sheet for Chrome |
| * Home. |
| */ |
| public interface BottomSheetContent { |
| /** |
| * Gets the {@link View} that holds the content to be displayed in the Chrome Home bottom |
| * sheet. |
| * @return The content view. |
| */ |
| View getContentView(); |
| |
| /** |
| * Get the {@link View} that contains the toolbar specific to the content being |
| * displayed. If null is returned, the omnibox is used. |
| * |
| * @return The toolbar view. |
| */ |
| @Nullable |
| View getToolbarView(); |
| |
| /** |
| * @return The vertical scroll offset of the content view. |
| */ |
| int getVerticalScrollOffset(); |
| |
| /** |
| * Called to destroy the {@link BottomSheetContent} when it is no longer in use. |
| */ |
| void destroy(); |
| |
| /** |
| * @return The priority of this content. |
| */ |
| @ContentPriority |
| int getPriority(); |
| |
| /** |
| * @return Whether swiping the sheet down hard enough will cause the sheet to be dismissed. |
| */ |
| boolean swipeToDismissEnabled(); |
| |
| /** |
| * @return Whether the peek state is enabled. |
| */ |
| boolean isPeekStateEnabled(); |
| |
| /** |
| * @return Whether the bottom sheet should wrap its content, i.e. its height in the FULL |
| * state is the minimum height required such that the content is visible. If this |
| * behavior is enabled, the HALF state of the sheet is disabled. |
| */ |
| default boolean wrapContentEnabled() { |
| return false; |
| } |
| |
| /** |
| * @return Whether this content owns its lifecycle. If false, the content will be hidden |
| * when the user navigates away from the page or switches tab. |
| */ |
| default boolean hasCustomLifecycle() { |
| return false; |
| } |
| |
| /** |
| * @return Whether this content owns the scrim lifecycle. If false, a default scrim will |
| * be displayed behind the sheet when this content is shown. |
| */ |
| default boolean hasCustomScrimLifecycle() { |
| return false; |
| } |
| |
| /** |
| * TODO(jinsukkim): Revise the API in favor of those specifying the height and its behavior |
| * for each state. |
| * @return Height of the sheet in half state with respect to the container height. |
| * This is INVALID_HEIGHT_RATIO by default, which lets the BottomSheet use |
| * a predefined value ({@link #HALF_HEIGHT_RATIO}). |
| */ |
| default float getCustomHalfRatio() { |
| return INVALID_HEIGHT_RATIO; |
| } |
| |
| /** |
| * @return Height of the sheet in full state with respect to container height. |
| * This is -1 by default, which lets the BottomSheet use the container height |
| * minus the top shadow height. |
| */ |
| default float getCustomFullRatio() { |
| return INVALID_HEIGHT_RATIO; |
| } |
| |
| /** |
| * Set a {@link ContentSizeListener} that should be notified when the size of the content |
| * has changed. This will be called only if {@link #wrapContentEnabled()} returns {@code |
| * true}. Note that you need to implement this method only if the content view height |
| * changes are animated. |
| * |
| * @return Whether the listener was correctly set. |
| */ |
| default boolean setContentSizeListener(@Nullable ContentSizeListener listener) { |
| return false; |
| } |
| |
| /** |
| * @return Whether the sheet should be hidden when it is in the PEEK state and the user |
| * scrolls down the page. |
| */ |
| default boolean hideOnScroll() { |
| return true; |
| } |
| |
| /** |
| * @return The resource id of the content description for the bottom sheet. This is |
| * generally the name of the feature/content that is showing. 'Swipe down to close.' |
| * will be automatically appended after the content description. |
| */ |
| int getSheetContentDescriptionStringId(); |
| |
| /** |
| * @return The resource id of the string announced when the sheet is opened at half height. |
| * This is typically the name of your feature followed by 'opened at half height'. |
| */ |
| int getSheetHalfHeightAccessibilityStringId(); |
| |
| /** |
| * @return The resource id of the string announced when the sheet is opened at full height. |
| * This is typically the name of your feature followed by 'opened at full height'. |
| */ |
| int getSheetFullHeightAccessibilityStringId(); |
| |
| /** |
| * @return The resource id of the string announced when the sheet is closed. This is |
| * typically the name of your feature followed by 'closed'. |
| */ |
| int getSheetClosedAccessibilityStringId(); |
| } |
| |
| /** Interface to listen when the size of a BottomSheetContent changes. */ |
| public interface ContentSizeListener { |
| /** Called when the size of the view has changed. */ |
| void onSizeChanged(int width, int height, int oldWidth, int oldHeight); |
| } |
| |
| @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() || AccessibilityUtil.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(R.dimen.bottom_sheet_toolbar_shadow_height); |
| |
| mGestureDetector = new BottomSheetSwipeDetector(context, this); |
| mIsTouchEnabled = true; |
| } |
| |
| /** |
| * Called when the activity containing the {@link BottomSheet} is destroyed. |
| */ |
| void destroy() { |
| mIsDestroyed = true; |
| mIsTouchEnabled = false; |
| mObservers.clear(); |
| endAnimations(); |
| } |
| |
| /** |
| * Handle a back press event. |
| * - If the navigation stack is empty, the sheet will be opened to the half state. |
| * - If the tab switcher is visible, {@link ChromeActivity} will handle the event. |
| * - If the sheet is open it will be closed unless it was opened by a back press. |
| * @return True if the sheet handled the back press. |
| */ |
| public boolean handleBackPress() { |
| if (isSheetOpen()) { |
| int sheetState = |
| mSheetContent.isPeekStateEnabled() ? SheetState.PEEK : SheetState.HIDDEN; |
| setSheetState(sheetState, true, StateChangeReason.BACK_PRESS); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Sets whether the {@link BottomSheet} and its children should react to touch events. |
| */ |
| public void setTouchEnabled(boolean enabled) { |
| mIsTouchEnabled = enabled; |
| } |
| |
| /** Immediately end all animations and null the animators. */ |
| public void endAnimations() { |
| if (mSettleAnimator != null) mSettleAnimator.end(); |
| mSettleAnimator = null; |
| } |
| |
| /** |
| * @return Whether the sheet is in the process of hiding. |
| */ |
| public boolean isHiding() { |
| return mSettleAnimator != null && mTargetState == SheetState.HIDDEN; |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent e) { |
| // If touch is disabled, act like a black hole and consume touch events without doing |
| // anything with them. |
| if (!mIsTouchEnabled) return true; |
| |
| if (!canMoveSheet()) return false; |
| |
| return mGestureDetector.onInterceptTouchEvent(e); |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent e) { |
| // If touch is disabled, act like a black hole and consume touch events without doing |
| // anything with them. |
| if (!mIsTouchEnabled) return true; |
| |
| if (isToolbarAndroidViewHidden()) return false; |
| |
| mGestureDetector.onTouchEvent(e); |
| |
| return true; |
| } |
| |
| /** |
| * @return Whether or not the toolbar Android View is hidden due to being scrolled off-screen. |
| */ |
| @VisibleForTesting |
| boolean isToolbarAndroidViewHidden() { |
| return mFullscreenManager == null || mFullscreenManager.getBottomControlOffset() > 0 |
| || mToolbarHolder.getVisibility() != VISIBLE; |
| } |
| |
| @Override |
| public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| int heightSize = MeasureSpec.getSize(heightMeasureSpec); |
| assert heightSize != 0; |
| int height = heightSize + mToolbarShadowHeight; |
| int mode = mSheetContent != null && mSheetContent.wrapContentEnabled() |
| ? 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 root The container of the bottom sheet. |
| * @param tabProvider A means of accessing the active tab. |
| * @param fullscreenManager A fullscreen manager for persisting browser controls and |
| * determining\ |
| * their offset. |
| * @param window Android window for getting insets. |
| * @param keyboardDelegate Delegate for hiding the keyboard. |
| */ |
| public void init(View root, ActivityTabProvider tabProvider, |
| ChromeFullscreenManager fullscreenManager, Window window, |
| KeyboardVisibilityDelegate keyboardDelegate) { |
| mTabSupplier = tabProvider; |
| mFullscreenManager = fullscreenManager; |
| |
| mToolbarHolder = |
| (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_toolbar_container); |
| mToolbarHolder.setBackgroundResource(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(R.drawable.top_round); |
| |
| // 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. |
| float previousWidth = mContainerWidth; |
| float previousHeight = mContainerHeight; |
| mContainerWidth = right - left; |
| mContainerHeight = bottom - top; |
| |
| if (previousWidth != mContainerWidth || previousHeight != mContainerHeight) { |
| if (mCurrentState == SheetState.HALF && shouldSkipHalfState()) { |
| 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. |
| // Setting the padding is posted in a runnable for the sake of Android J. |
| // See crbug.com/751013. |
| final int finalPadding = keyboardHeight; |
| post(new Runnable() { |
| @Override |
| public void run() { |
| mBottomSheetContentContainer.setPadding(0, 0, 0, finalPadding); |
| |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { |
| // A layout on the toolbar holder is requested so that the toolbar |
| // doesn't disappear under certain scenarios on Android J. |
| // See crbug.com/751013. |
| mToolbarHolder.requestLayout(); |
| } |
| } |
| }); |
| } |
| |
| 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()) { |
| |
| // This onLayoutChange() will be called after the user enters fullscreen video |
| // mode. Ensure the sheet state is reset to peek so that the sheet does not |
| // open over the fullscreen video. See crbug.com/740499. |
| if (mFullscreenManager != null |
| && mFullscreenManager.getPersistentFullscreenMode() && isSheetOpen()) { |
| setSheetState(SheetState.PEEK, false); |
| } else { |
| if (isRunningSettleAnimation()) return; |
| setSheetState(mCurrentState, false); |
| } |
| } |
| } |
| }); |
| |
| mFullscreenManager.addListener(new FullscreenListener() { |
| @Override |
| public void onToggleOverlayVideoMode(boolean enabled) { |
| if (isSheetOpen()) setSheetState(SheetState.PEEK, false); |
| } |
| |
| @Override |
| public void onControlsOffsetChanged( |
| int topOffset, int bottomOffset, boolean needsAnimate) { |
| 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 onContentOffsetChanged(int offset) {} |
| |
| @Override |
| public void onBottomControlsHeightChanged(int bottomControlsHeight) {} |
| }); |
| |
| mSheetContainer = (ViewGroup) this.getParent(); |
| mSheetContainer.removeView(this); |
| } |
| |
| @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 int loadUrl(LoadUrlParams params, boolean incognito) { |
| for (BottomSheetObserver o : mObservers) o.onLoadUrl(params.getUrl()); |
| |
| int tabLoadStatus = TabLoadStatus.DEFAULT_PAGE_LOAD; |
| |
| if (getActiveTab() != null) tabLoadStatus = getActiveTab().loadUrl(params); |
| |
| return tabLoadStatus; |
| } |
| |
| @Override |
| public boolean isIncognito() { |
| if (getActiveTab() == null) return false; |
| return getActiveTab().isIncognito(); |
| } |
| |
| @Override |
| public int getParentId() { |
| return Tab.INVALID_TAB_ID; |
| } |
| |
| @Override |
| public Tab getActiveTab() { |
| return mTabSupplier != null ? mTabSupplier.get() : null; |
| } |
| |
| @Override |
| public boolean isVisible() { |
| return mCurrentState != SheetState.PEEK; |
| } |
| |
| @Override |
| public HistoryNavigationDelegate createHistoryNavigationDelegate() { |
| assert false : "BottomSheet does not need HistoryNavigationDelegate"; |
| return null; |
| } |
| |
| @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; |
| } |
| |
| @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 The minimum sheet state that the user can swipe to. i.e. flinging down will either |
| * close the sheet or peek it. |
| */ |
| private @SheetState int getMinSwipableSheetState() { |
| return swipeToDismissEnabled() || !mSheetContent.isPeekStateEnabled() ? SheetState.HIDDEN |
| : SheetState.PEEK; |
| } |
| |
| @Override |
| public float getMaxOffsetPx() { |
| float maxOffset = getFullRatio() * mContainerHeight; |
| if (mSheetContent != null && mSheetContent.wrapContentEnabled()) { |
| ensureContentDesiredHeightIsComputed(); |
| return Math.min(maxOffset, mContentDesiredHeight + mToolbarShadowHeight); |
| } |
| |
| return maxOffset; |
| } |
| |
| /** |
| * 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; |
| |
| // Make sure the toolbar is visible before expanding the sheet. |
| if (isToolbarAndroidViewHidden()) { |
| TabBrowserControlsState.update(getActiveTab(), BrowserControlsState.SHOWN, false); |
| } |
| |
| // Browser controls should stay visible until the sheet is closed. |
| mPersistentControlsToken = |
| mFullscreenManager.getBrowserVisibilityDelegate().showControlsPersistent(); |
| |
| dismissSelectedText(); |
| 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; |
| |
| // Update the browser controls since they are permanently shown while the sheet is open. |
| mFullscreenManager.getBrowserVisibilityDelegate().releasePersistentShowingToken( |
| mPersistentControlsToken); |
| |
| 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()) return 0; |
| |
| float peekHeight = getPeekRatio() * mContainerHeight; |
| return peekHeight * mFullscreenManager.getBrowserControlHiddenRatio(); |
| } |
| |
| /** |
| * Sets the sheet's offset relative to the bottom of the screen. |
| * @param offset The offset that the sheet should be. |
| */ |
| private void setSheetOffsetFromBottom(float offset, @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 (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); |
| } |
| |
| float peekHeight = getSheetHeightForState(SheetState.PEEK); |
| boolean isAtPeekingHeight = MathUtils.areFloatsEqual(getCurrentOffsetPx(), peekHeight); |
| if (isSheetOpen() && (getCurrentOffsetPx() < peekHeight || isAtPeekingHeight)) { |
| onSheetClosed(reason); |
| } else if (!isSheetOpen() && getCurrentOffsetPx() > peekHeight) { |
| onSheetOpened(reason); |
| } |
| |
| sendOffsetChangeEvents(); |
| } |
| |
| @Override |
| public void setSheetOffset(float offset, boolean shouldAnimate) { |
| cancelAnimation(); |
| if (mSheetContent == null) return; |
| |
| if (shouldAnimate) { |
| float velocityY = getCurrentOffsetPx() - offset; |
| |
| @BottomSheet.SheetState |
| int targetState = getTargetSheetState(offset, -velocityY); |
| |
| setSheetState(targetState, true, BottomSheet.StateChangeReason.SWIPE); |
| } else { |
| setInternalCurrentState( |
| BottomSheet.SheetState.SCROLLING, BottomSheet.StateChangeReason.SWIPE); |
| setSheetOffsetFromBottom(offset, BottomSheet.StateChangeReason.SWIPE); |
| } |
| } |
| |
| /** |
| * Deselects any text in the active tab's web contents and dismisses the text controls. |
| */ |
| private void dismissSelectedText() { |
| Tab activeTab = getActiveTab(); |
| if (activeTab == null) return; |
| |
| WebContents webContents = activeTab.getWebContents(); |
| if (webContents == null) return; |
| SelectionPopupController.fromWebContents(webContents).clearSelection(); |
| } |
| |
| /** |
| * This is the same as {@link #setSheetOffsetFromBottom(float, int)} but exclusively for |
| * testing. |
| * @param offset The offset to set the sheet to. |
| */ |
| @VisibleForTesting |
| public void setSheetOffsetFromBottomForTesting(float offset) { |
| setSheetOffsetFromBottom(offset, StateChangeReason.NONE); |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the hidden state is. |
| */ |
| @VisibleForTesting |
| float getHiddenRatio() { |
| return 0; |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the peeking state is. |
| */ |
| public float getPeekRatio() { |
| if (mContainerHeight <= 0) return 0; |
| 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((int) mContainerWidth, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec( |
| (int) mContainerHeight, MeasureSpec.AT_MOST)); |
| toolbarHeight = toolbarView.getMeasuredHeight(); |
| } |
| } |
| } |
| return (toolbarHeight + mToolbarShadowHeight) / 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) return 0; |
| float customHalfRatio = |
| mSheetContent != null ? mSheetContent.getCustomHalfRatio() : INVALID_HEIGHT_RATIO; |
| return customHalfRatio < 0 ? 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) return 0; |
| float customFullRatio = |
| mSheetContent != null ? mSheetContent.getCustomFullRatio() : INVALID_HEIGHT_RATIO; |
| return customFullRatio < 0 ? (mContainerHeight + mToolbarShadowHeight) / 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 / mContainerHeight : 0; |
| |
| // This ratio is relative to the peek and full positions of the sheet. |
| float hiddenFullRatio = MathUtils.clamp( |
| (screenRatio - getHiddenRatio()) / (getFullRatio() - getHiddenRatio()), 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 (MathUtils.areFloatsEqual( |
| offsetWithBrowserControls, getSheetHeightForState(SheetState.PEEK))) { |
| for (BottomSheetObserver o : mObservers) o.onSheetFullyPeeked(); |
| } |
| } |
| |
| /** |
| * @see #setSheetState(int, boolean, int) |
| */ |
| public 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. |
| */ |
| public 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 && shouldSkipHalfState()) { |
| 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. |
| */ |
| public 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 |
| public int getSheetState() { |
| return mCurrentState; |
| } |
| |
| /** @return Whether the sheet is currently open. */ |
| public 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; |
| } |
| |
| 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(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. |
| */ |
| public boolean isRunningSettleAnimation() { |
| return mSettleAnimator != null; |
| } |
| |
| /** |
| * @return The current sheet content, or null if there is no content. |
| */ |
| @VisibleForTesting |
| public @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 (mSheetContent != null && mSheetContent.wrapContentEnabled() && state == SheetState.FULL |
| && mSheetContent.getCustomFullRatio() == INVALID_HEIGHT_RATIO) { |
| ensureContentDesiredHeightIsComputed(); |
| return mContentDesiredHeight + mToolbarShadowHeight; |
| } |
| |
| return getRatioForState(state) * mContainerHeight; |
| } |
| |
| private void ensureContentDesiredHeightIsComputed() { |
| if (mContentDesiredHeight != HEIGHT_UNSPECIFIED) { |
| return; |
| } |
| |
| mSheetContent.getContentView().measure( |
| MeasureSpec.makeMeasureSpec((int) mContainerWidth, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec((int) 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. |
| */ |
| public void addObserver(BottomSheetObserver observer) { |
| mObservers.addObserver(observer); |
| } |
| |
| /** |
| * Removes an observer to the bottom sheet. |
| * @param observer The observer to remove. |
| */ |
| public 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. This is only used for determining the |
| * scroll or fling direction. 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; |
| boolean shouldSkipHalfState = isMovingDownward || shouldSkipHalfState(); |
| |
| // First, find the two states that the sheet height is between. |
| @SheetState |
| int nextState = getMinSwipableSheetState(); |
| |
| @SheetState |
| int prevState = nextState; |
| for (@SheetState int i = getMinSwipableSheetState(); i <= SheetState.FULL; i++) { |
| if (i == SheetState.HALF && shouldSkipHalfState) continue; |
| if (i == SheetState.PEEK && !mSheetContent.isPeekStateEnabled()) continue; |
| prevState = nextState; |
| nextState = i; |
| // The values in PanelState are ascending, they should be kept that way in order for |
| // this to work. |
| if (sheetHeight >= getSheetHeightForState(prevState) |
| && sheetHeight < getSheetHeightForState(nextState)) { |
| break; |
| } |
| } |
| |
| // If the desired height is close enough to a certain state, depending on the direction of |
| // the velocity, move to that state. |
| float lowerBound = getSheetHeightForState(prevState); |
| float distance = getSheetHeightForState(nextState) - lowerBound; |
| |
| float threshold = |
| shouldSkipHalfState ? THRESHOLD_TO_NEXT_STATE_2 : THRESHOLD_TO_NEXT_STATE_3; |
| float thresholdToNextState = yVelocity < 0.0f ? 1 - threshold : threshold; |
| |
| if ((sheetHeight - lowerBound) / distance > thresholdToNextState) { |
| return nextState; |
| } |
| return prevState; |
| } |
| |
| private boolean shouldSkipHalfState() { |
| // Half state is neither valid on small screens nor when wrapping the sheet content. |
| return isSmallScreen() || (mSheetContent != null && mSheetContent.wrapContentEnabled()); |
| } |
| |
| public boolean isSmallScreen() { |
| // A small screen is defined by there being less than 160dp between half and full states. |
| float fullHeightRatio = (mContainerHeight + mToolbarShadowHeight) / mContainerHeight; |
| float fullToHalfDiff = (fullHeightRatio - HALF_HEIGHT_RATIO) * mContainerHeight; |
| return fullToHalfDiff < mMinHalfFullDistance; |
| } |
| |
| /** |
| * @return The default toolbar view. |
| */ |
| @VisibleForTesting |
| public @Nullable View getDefaultToolbarView() { |
| return mDefaultToolbarView; |
| } |
| |
| /** |
| * @return The height of the toolbar shadow. |
| */ |
| public int getToolbarShadowHeight() { |
| return mToolbarShadowHeight; |
| } |
| |
| /** |
| * Checks whether the sheet can be moved. It cannot be moved when the activity is in overview |
| * mode, when "find in page" is visible, when the toolbar is in the animation to hide, or when |
| * the toolbar is hidden. |
| */ |
| protected boolean canMoveSheet() { |
| if (mFindInPageView == null) mFindInPageView = findViewById(R.id.find_toolbar); |
| boolean isFindInPageVisible = |
| mFindInPageView != null && mFindInPageView.getVisibility() == View.VISIBLE; |
| |
| return !isToolbarAndroidViewHidden() && !isFindInPageVisible |
| && mTargetState != SheetState.HIDDEN; |
| } |
| |
| /** |
| * 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 (content != null && content.wrapContentEnabled()) { |
| // 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) { |
| mContentDesiredHeight = height; |
| 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; |
| } |
| } |