| // 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.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.animation.ValueAnimator; |
| import android.content.Context; |
| import android.graphics.Color; |
| import android.graphics.Rect; |
| import android.os.Build; |
| import android.support.annotation.IntDef; |
| import android.support.annotation.Nullable; |
| import android.util.AttributeSet; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.animation.DecelerateInterpolator; |
| import android.view.animation.Interpolator; |
| import android.widget.FrameLayout; |
| |
| import org.chromium.base.ApiCompatibilityUtils; |
| import org.chromium.base.ObserverList; |
| import org.chromium.base.SysUtils; |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.chrome.R; |
| 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.native_page.NativePageHost; |
| import org.chromium.chrome.browser.tab.Tab; |
| import org.chromium.chrome.browser.tabmodel.TabLaunchType; |
| import org.chromium.chrome.browser.tabmodel.TabModelSelector; |
| import org.chromium.chrome.browser.toolbar.top.ActionModeController.ActionBarDelegate; |
| import org.chromium.chrome.browser.toolbar.top.ViewShiftingActionBarDelegate; |
| 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 java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * 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 { |
| /** 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 amount of time it takes to transition sheet content in or out. */ |
| private static final long TRANSITION_DURATION_MS = 150; |
| |
| /** |
| * 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 fraction of the width of the screen that, when swiped, will cause the sheet to move. */ |
| private static final float SWIPE_ALLOWED_FRACTION = 0.2f; |
| |
| /** |
| * The minimum swipe velocity (dp/ms) that should be considered as a user opening the bottom |
| * sheet intentionally. This is specifically for the 'velocity' swipe logic. |
| */ |
| private static final float SHEET_SWIPE_MIN_DP_PER_MS = 0.2f; |
| |
| /** |
| * Information about the different scroll states of the sheet. Order is important for these, |
| * they go from smallest to largest. |
| */ |
| private final float[] mStateRatios = new float[4]; |
| |
| /** 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 {@link BottomSheetMetrics} used to record user actions and histograms. */ |
| private final BottomSheetMetrics mMetrics; |
| |
| /** 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 animator set responsible for swapping the bottom sheet content. */ |
| private AnimatorSet mContentSwapAnimatorSet; |
| |
| /** The height of the toolbar. */ |
| private float mToolbarHeight; |
| |
| /** 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 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 TabModelSelector mTabModelSelector; |
| |
| /** 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 ratio sent to observers of onTransitionPeekToHalf(). This is used to ensure the |
| * final value sent to these observers is 1.0f. |
| */ |
| private float mLastPeekToHalfRatioSent; |
| |
| /** |
| * 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; |
| |
| /** The activity displaying the bottom sheet. */ |
| protected ChromeActivity mActivity; |
| |
| /** A delegate for when the action bar starts showing. */ |
| private ViewShiftingActionBarDelegate mActionBarDelegate; |
| |
| /** Whether {@link #destroy()} has been called. **/ |
| private boolean mIsDestroyed; |
| |
| /** The token used to enable browser controls persistence. */ |
| private int mPersistentControlsToken; |
| |
| /** Conversion ratio of dp to px. */ |
| private float mDpToPx; |
| |
| /** |
| * 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 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(); |
| } |
| |
| /** |
| * Returns whether the provided bottom sheet state is in one of the stable open or closed |
| * states: {@link #SheetState.FULL}, {@link #SheetState.PEEK} or {@link #SheetState.HALF} |
| * @param sheetState A {@link SheetState} to test. |
| */ |
| public static boolean isStateStable(@SheetState int sheetState) { |
| switch (sheetState) { |
| case SheetState.HIDDEN: |
| case SheetState.PEEK: |
| case SheetState.HALF: |
| case SheetState.FULL: |
| return true; |
| case SheetState.SCROLLING: |
| return false; |
| case SheetState.NONE: // Should never be tested, internal only value. |
| default: |
| assert false; |
| return false; |
| } |
| } |
| |
| @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 (mActivity == null || isSheetOpen() || AccessibilityUtil.isAccessibilityEnabled()) { |
| return true; |
| } |
| |
| float startX = mVisibleViewportRect.left; |
| float endX = mDefaultToolbarView.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.toolbar_shadow_height); |
| |
| mMetrics = new BottomSheetMetrics(); |
| addObserver(mMetrics); |
| |
| mGestureDetector = new BottomSheetSwipeDetector(context, this); |
| mIsTouchEnabled = true; |
| } |
| |
| /** |
| * Called when the activity containing the {@link BottomSheet} is destroyed. |
| */ |
| public 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()) { |
| setSheetState(SheetState.PEEK, 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; |
| endTransitionAnimations(); |
| } |
| |
| /** |
| * Immediately end the bottom sheet content transition animations and null the animator. |
| */ |
| public void endTransitionAnimations() { |
| if (mContentSwapAnimatorSet == null || !mContentSwapAnimatorSet.isRunning()) return; |
| mContentSwapAnimatorSet.end(); |
| mContentSwapAnimatorSet = null; |
| } |
| |
| /** |
| * @return An action bar delegate that appropriately moves the sheet when the action bar is |
| * shown. |
| */ |
| public ActionBarDelegate getActionBarDelegate() { |
| return mActionBarDelegate; |
| } |
| |
| @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 |
| public 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; |
| super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); |
| } |
| |
| /** |
| * 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 activity The activity displaying the bottom sheet. |
| */ |
| public void init(View root, ChromeActivity activity) { |
| mTabModelSelector = activity.getTabModelSelector(); |
| mFullscreenManager = activity.getFullscreenManager(); |
| |
| mToolbarHolder = |
| (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_toolbar_container); |
| mDefaultToolbarView = mToolbarHolder.findViewById(R.id.bottom_sheet_toolbar); |
| mToolbarHeight = |
| activity.getResources().getDimensionPixelSize(R.dimen.bottom_sheet_peek_height); |
| |
| mActivity = activity; |
| mActionBarDelegate = new ViewShiftingActionBarDelegate(mActivity, this); |
| |
| getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; |
| |
| mBottomSheetContentContainer = |
| (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_content); |
| mBottomSheetContentContainer.setBottomSheet(this); |
| mBottomSheetContentContainer.setBackgroundColor( |
| ApiCompatibilityUtils.getColor(getResources(), R.color.modern_primary_color)); |
| |
| mDpToPx = mActivity.getResources().getDisplayMetrics().density; |
| |
| // 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) { |
| updateSheetStateRatios(); |
| } |
| |
| int heightMinusKeyboard = (int) mContainerHeight; |
| int keyboardHeight = 0; |
| |
| // Reset mVisibleViewportRect regardless of sheet open state as it is used outside |
| // of calculating the keyboard height. |
| mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame( |
| mVisibleViewportRect); |
| if (isSheetOpen()) { |
| int decorHeight = mActivity.getWindow().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() && mActivity.getWindowAndroid() != null) { |
| mActivity.getWindowAndroid().getKeyboardDelegate().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()) { |
| cancelAnimation(); |
| |
| // 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 { |
| 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()); |
| |
| assert mTabModelSelector != null; |
| |
| int tabLoadStatus = TabLoadStatus.DEFAULT_PAGE_LOAD; |
| |
| if (getActiveTab() != null && getActiveTab().isIncognito() == incognito) { |
| tabLoadStatus = getActiveTab().loadUrl(params); |
| } else { |
| // If no compatible tab is active behind the sheet, open a new one. |
| mTabModelSelector.openNewTab( |
| params, TabLaunchType.FROM_CHROME_UI, getActiveTab(), incognito); |
| } |
| |
| 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 mTabModelSelector != null ? mTabModelSelector.getCurrentTab() : null; |
| } |
| |
| @Override |
| public boolean isVisible() { |
| return mCurrentState != SheetState.PEEK; |
| } |
| |
| @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() { |
| 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. |
| */ |
| public void showContent(@Nullable final BottomSheetContent content) { |
| // If an animation is already running, end it. |
| if (mContentSwapAnimatorSet != null) mContentSwapAnimatorSet.end(); |
| |
| // If the desired content is already showing, do nothing. |
| if (mSheetContent == content) return; |
| |
| List<Animator> animators = new ArrayList<>(); |
| mContentSwapAnimatorSet = new AnimatorSet(); |
| mContentSwapAnimatorSet.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| onContentSwapAnimationEnd(content); |
| } |
| }); |
| |
| // Add an animator for the toolbar transition if needed. |
| View newToolbar = content != null && content.getToolbarView() != null |
| ? content.getToolbarView() |
| : mDefaultToolbarView; |
| View oldToolbar = mSheetContent != null && mSheetContent.getToolbarView() != null |
| ? mSheetContent.getToolbarView() |
| : mDefaultToolbarView; |
| if (newToolbar != oldToolbar) { |
| // For the toolbar transition, make sure we don't detach the default toolbar view. |
| Animator transitionAnimator = getViewTransitionAnimator( |
| newToolbar, oldToolbar, mToolbarHolder, mDefaultToolbarView != oldToolbar); |
| if (transitionAnimator != null) animators.add(transitionAnimator); |
| } |
| |
| // Add an animator for the content transition if needed. |
| View oldContent = mSheetContent != null ? mSheetContent.getContentView() : null; |
| if (content == null) { |
| if (oldContent != null) mBottomSheetContentContainer.removeView(oldContent); |
| } else { |
| View contentView = content.getContentView(); |
| Animator transitionAnimator = getViewTransitionAnimator( |
| contentView, oldContent, mBottomSheetContentContainer, true); |
| if (transitionAnimator != null) animators.add(transitionAnimator); |
| } |
| |
| // Temporarily make the background of the toolbar holder a solid color so the transition |
| // doesn't appear to show a hole in the toolbar. |
| int colorId = R.color.modern_primary_color; |
| if (!mIsSheetOpen) { |
| // If the sheet is closed, the bottom sheet content container is invisible, so |
| // background color is needed on the toolbar holder to prevent a blank rectangle from |
| // appearing during the content transition. |
| mToolbarHolder.setBackgroundColor( |
| ApiCompatibilityUtils.getColor(getResources(), colorId)); |
| } |
| mBottomSheetContentContainer.setBackgroundColor( |
| ApiCompatibilityUtils.getColor(getResources(), colorId)); |
| |
| // Set color on the content view to compensate for a JellyBean bug (crbug.com/766237). |
| if (content != null) { |
| content.getContentView().setBackgroundColor( |
| ApiCompatibilityUtils.getColor(getResources(), colorId)); |
| } |
| |
| // Return early if there are no animators to run. |
| if (animators.isEmpty()) { |
| onContentSwapAnimationEnd(content); |
| return; |
| } |
| |
| mContentSwapAnimatorSet.playTogether(animators); |
| mContentSwapAnimatorSet.start(); |
| |
| // If the existing content is null or the tab switcher assets are showing, end the animation |
| // immediately. |
| if (mSheetContent == null || isInOverviewMode() || SysUtils.isLowEndDevice()) { |
| mContentSwapAnimatorSet.end(); |
| } |
| } |
| |
| /** |
| * Called when the animation to swap BottomSheetContent ends. |
| * @param content The BottomSheetContent showing at the end of the animation. |
| */ |
| private void onContentSwapAnimationEnd(BottomSheetContent content) { |
| if (mIsDestroyed) return; |
| |
| onSheetContentChanged(content); |
| mContentSwapAnimatorSet = null; |
| } |
| |
| /** |
| * Creates a transition animation between two views. The old view is faded out completely |
| * before the new view is faded in. There is an option to detach the old view or not. |
| * @param newView The new view to transition to. |
| * @param oldView The old view to transition from. |
| * @param parent The parent for newView and oldView. |
| * @param detachOldView Whether or not to detach the old view once faded out. |
| * @return An animator that runs the specified animation or null if no animation should be run. |
| */ |
| @Nullable |
| private Animator getViewTransitionAnimator(final View newView, final View oldView, |
| final ViewGroup parent, final boolean detachOldView) { |
| if (newView == oldView) return null; |
| |
| AnimatorSet animatorSet = new AnimatorSet(); |
| List<Animator> animators = new ArrayList<>(); |
| |
| newView.setVisibility(View.VISIBLE); |
| |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O |
| && !ValueAnimator.areAnimatorsEnabled()) { |
| if (oldView != null) { |
| // Post a runnable to remove the old view to prevent issues related to the keyboard |
| // showing while swapping contents. See https://crbug.com/799252. |
| post(() -> { swapViews(newView, oldView, parent, detachOldView); }); |
| } else { |
| if (parent != newView.getParent()) parent.addView(newView); |
| } |
| |
| newView.setAlpha(1); |
| |
| return null; |
| } |
| |
| // Fade out the old view. |
| if (oldView != null) { |
| ValueAnimator fadeOutAnimator = ObjectAnimator.ofFloat(oldView, View.ALPHA, 0); |
| fadeOutAnimator.setDuration(TRANSITION_DURATION_MS); |
| fadeOutAnimator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| swapViews(newView, oldView, parent, detachOldView); |
| } |
| }); |
| animators.add(fadeOutAnimator); |
| } else { |
| // Normally the new view is added at the end of the fade-out animation of the old view, |
| // if there is no old view, attach the new one immediately. |
| if (parent != newView.getParent()) parent.addView(newView); |
| } |
| |
| // Fade in the new view. |
| newView.setAlpha(0); |
| ValueAnimator fadeInAnimator = ObjectAnimator.ofFloat(newView, View.ALPHA, 1); |
| fadeInAnimator.setDuration(TRANSITION_DURATION_MS); |
| animators.add(fadeInAnimator); |
| |
| animatorSet.playSequentially(animators); |
| |
| return animatorSet; |
| } |
| |
| /** |
| * 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. |
| * @param detachOldView Whether or not to detach the old view once faded out. |
| */ |
| private void swapViews(final View newView, final View oldView, final ViewGroup parent, |
| final boolean detachOldView) { |
| if (detachOldView && oldView.getParent() != null) { |
| parent.removeView(oldView); |
| } else { |
| oldView.setVisibility(View.INVISIBLE); |
| } |
| if (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. |
| Tab tab = getActiveTab(); |
| if (isToolbarAndroidViewHidden() && tab != null) { |
| tab.updateBrowserControlsState(BrowserControlsState.SHOWN, false); |
| } |
| |
| mBottomSheetContentContainer.setVisibility(View.VISIBLE); |
| |
| // Browser controls should stay visible until the sheet is closed. |
| mPersistentControlsToken = |
| mFullscreenManager.getBrowserVisibilityDelegate().showControlsPersistent(); |
| |
| dismissSelectedText(); |
| for (BottomSheetObserver o : mObservers) o.onSheetOpened(reason); |
| mActivity.addViewObscuringAllTabs(this); |
| } |
| |
| /** |
| * 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; |
| mBottomSheetContentContainer.setVisibility(View.INVISIBLE); |
| 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(); |
| mActivity.removeViewObscuringAllTabs(this); |
| |
| setFocusable(false); |
| setFocusableInTouchMode(false); |
| setContentDescription(null); |
| } |
| |
| /** |
| * Updates the bottom sheet's state ratios and adjusts the sheet's state if necessary. |
| */ |
| private void updateSheetStateRatios() { |
| if (mContainerHeight <= 0) return; |
| |
| // Though mStateRatios is a static constant, the peeking ratio is computed here because |
| // the correct toolbar height and container height are not know until those views are |
| // inflated. The other views are a specific DP distance from the top and bottom and are |
| // also updated. |
| mStateRatios[SheetState.HIDDEN] = 0; |
| mStateRatios[SheetState.PEEK] = (mToolbarHeight + mToolbarShadowHeight) / mContainerHeight; |
| mStateRatios[SheetState.HALF] = HALF_HEIGHT_RATIO; |
| // The max height ratio will be greater than 1 to account for the toolbar shadow. |
| mStateRatios[SheetState.FULL] = |
| (mContainerHeight + mToolbarShadowHeight) / mContainerHeight; |
| |
| if (mCurrentState == SheetState.HALF && isSmallScreen()) { |
| setSheetState(SheetState.FULL, false); |
| } |
| } |
| |
| /** |
| * 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); |
| } |
| }); |
| |
| if (targetState != SheetState.HIDDEN) { |
| 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() { |
| 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 (shouldAnimate) { |
| float velocityY = getCurrentOffsetPx() - offset; |
| |
| @BottomSheet.SheetState |
| int targetState = getTargetSheetState(offset, -velocityY); |
| |
| setSheetState(targetState, true, BottomSheet.StateChangeReason.SWIPE); |
| |
| for (BottomSheetObserver o : mObservers) o.onSheetReleased(); |
| } 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 mStateRatios[SheetState.HIDDEN]; |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the peeking state is. |
| */ |
| public float getPeekRatio() { |
| return mStateRatios[SheetState.PEEK]; |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the half expanded state is. |
| */ |
| @VisibleForTesting |
| float getHalfRatio() { |
| return mStateRatios[SheetState.HALF]; |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the fully expanded state is. |
| */ |
| @VisibleForTesting |
| float getFullRatio() { |
| return mStateRatios[SheetState.FULL]; |
| } |
| |
| /** |
| * @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(); |
| } |
| |
| // This ratio is relative to the peek and half positions of the sheet. |
| float peekHalfRatio = MathUtils.clamp( |
| (screenRatio - getPeekRatio()) / (getHalfRatio() - getPeekRatio()), 0, 1); |
| |
| // If the ratio is close enough to zero, just set it to zero. |
| if (MathUtils.areFloatsEqual(peekHalfRatio, 0f)) peekHalfRatio = 0f; |
| |
| if (peekHalfRatio != mLastPeekToHalfRatioSent |
| && (mLastPeekToHalfRatioSent < 1f || peekHalfRatio < 1f)) { |
| mLastPeekToHalfRatioSent = peekHalfRatio; |
| for (BottomSheetObserver o : mObservers) { |
| o.onTransitionPeekToHalf(peekHalfRatio); |
| } |
| } |
| } |
| |
| /** |
| * @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.SCROLLING && state != SheetState.NONE; |
| |
| // Half state is not valid on small screens. |
| if (state == SheetState.HALF && isSmallScreen()) 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. A target state will be set when the user releases the sheet from drag |
| * ({@link BottomSheetObserver#onSheetReleased()}) and has begun animation to the next |
| * state. |
| */ |
| 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 swipeToClose = ". " |
| + getResources().getString(R.string.bottom_sheet_accessibility_description); |
| setContentDescription( |
| getResources().getString( |
| getCurrentSheetContent().getSheetContentDescriptionStringId()) |
| + swipeToClose); |
| 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 Whether a content swap animation is in progress. |
| */ |
| public boolean isRunningContentSwapAnimation() { |
| return mContentSwapAnimatorSet != null && mContentSwapAnimatorSet.isRunning(); |
| } |
| |
| /** |
| * @return The current sheet content, or null if there is no content. |
| */ |
| @VisibleForTesting |
| public @Nullable BottomSheetContent getCurrentSheetContent() { |
| return mSheetContent; |
| } |
| |
| /** |
| * @return The {@link BottomSheetMetrics} used to record user actions and histograms. |
| */ |
| public BottomSheetMetrics getBottomSheetMetrics() { |
| return mMetrics; |
| } |
| |
| /** |
| * 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. |
| */ |
| public float getSheetHeightForState(@SheetState int state) { |
| return mStateRatios[state] * mContainerHeight; |
| } |
| |
| /** |
| * 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 || isSmallScreen(); |
| |
| // 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; |
| } |
| |
| public boolean isSmallScreen() { |
| // A small screen is defined by there being less than 160dp between half and full states. |
| float fullToHalfDiff = (getFullRatio() - getHalfRatio()) * mContainerHeight; |
| return fullToHalfDiff < mMinHalfFullDistance; |
| } |
| |
| /** |
| * @return The default toolbar view. |
| */ |
| @VisibleForTesting |
| public @Nullable View getDefaultToolbarView() { |
| return mDefaultToolbarView; |
| } |
| |
| /** |
| * @return The height of the toolbar holder. |
| */ |
| public int getToolbarContainerHeight() { |
| return mToolbarHolder != null ? mToolbarHolder.getHeight() : 0; |
| } |
| |
| /** |
| * @return The height of the toolbar shadow. |
| */ |
| public int getToolbarShadowHeight() { |
| return mToolbarShadowHeight; |
| } |
| |
| /** |
| * @return Whether or not the browser is in overview mode. |
| */ |
| protected boolean isInOverviewMode() { |
| return mActivity != null && mActivity.isInOverviewMode(); |
| } |
| |
| /** |
| * Checks whether the sheet can be moved. It cannot be moved when the activity is in overview |
| * mode, when "find in page" is visible, 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; |
| } |
| |
| /** |
| * 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; |
| for (BottomSheetObserver o : mObservers) { |
| o.onSheetContentChanged(content); |
| } |
| mToolbarHolder.setBackgroundColor(Color.TRANSPARENT); |
| } |
| } |