| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.components.browser_ui.bottomsheet; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.ValueAnimator; |
| import android.content.Context; |
| import android.graphics.Color; |
| import android.graphics.Rect; |
| import android.util.AttributeSet; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.Window; |
| import android.view.animation.DecelerateInterpolator; |
| import android.view.animation.Interpolator; |
| import android.widget.FrameLayout; |
| |
| import androidx.annotation.DimenRes; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| |
| import org.chromium.base.Callback; |
| import org.chromium.base.Log; |
| import org.chromium.base.MathUtils; |
| import org.chromium.base.ObserverList; |
| import org.chromium.base.task.PostTask; |
| import org.chromium.base.task.TaskTraits; |
| import org.chromium.components.browser_ui.bottomsheet.BottomSheetContent.HeightMode; |
| import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.SheetState; |
| import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.StateChangeReason; |
| import org.chromium.components.browser_ui.bottomsheet.internal.R; |
| import org.chromium.ui.KeyboardVisibilityDelegate; |
| import org.chromium.ui.util.AccessibilityUtil; |
| |
| /** |
| * This class defines the bottom sheet that has multiple states and a persistently showing toolbar. |
| * Namely, the states are: |
| * - PEEK: Only the toolbar is visible at the bottom of the screen. |
| * - HALF: The sheet is expanded to consume around half of the screen. |
| * - FULL: The sheet is expanded to its full height. |
| * |
| * All the computation in this file is based off of the bottom of the screen instead of the top |
| * for simplicity. This means that the bottom of the screen is 0 on the Y axis. |
| */ |
| class BottomSheet extends FrameLayout |
| implements BottomSheetSwipeDetector.SwipeableBottomSheet, View.OnLayoutChangeListener { |
| private static final String TAG = "BottomSheet"; |
| |
| /** |
| * The base duration of the settling animation of the sheet. 218 ms is a spec for material |
| * design (this is the minimum time a user is guaranteed to pay attention to something). |
| */ |
| private static final int BASE_ANIMATION_DURATION_MS = 218; |
| |
| /** |
| * The fraction of the way to the next state the sheet must be swiped to animate there when |
| * released. This is the value used when there are 3 active states. A smaller value here means |
| * a smaller swipe is needed to move the sheet around. |
| */ |
| private static final float THRESHOLD_TO_NEXT_STATE_3 = 0.4f; |
| |
| /** This is similar to {@link #THRESHOLD_TO_NEXT_STATE_3} but for 2 states instead of 3. */ |
| private static final float THRESHOLD_TO_NEXT_STATE_2 = 0.3f; |
| |
| /** The height ratio for the sheet in the SheetState.HALF state. */ |
| private static final float HALF_HEIGHT_RATIO = 0.75f; |
| |
| /** The desired height of a content that has just been shown or whose height was invalidated. */ |
| private static final float HEIGHT_UNSPECIFIED = -1.0f; |
| |
| /** A means of reporting an exception/stack without crashing. */ |
| private static Callback<Throwable> sExceptionReporter; |
| |
| /** A flag to force the small screen state of the bottom sheet. */ |
| private static Boolean sIsSmallScreenForTesting; |
| |
| /** The interpolator that the height animator uses. */ |
| private final Interpolator mInterpolator = new DecelerateInterpolator(1.0f); |
| |
| /** The list of observers of this sheet. */ |
| private final ObserverList<BottomSheetObserver> mObservers = new ObserverList<>(); |
| |
| /** The visible rect for the screen taking the keyboard into account. */ |
| private final Rect mVisibleViewportRect = new Rect(); |
| |
| /** An out-array for use with getLocationInWindow to prevent constant allocations. */ |
| private final int[] mCachedLocation = new int[2]; |
| |
| /** The minimum distance between half and full states to allow the half state. */ |
| private final float mMinHalfFullDistance; |
| |
| /** The height of the shadow that sits above the toolbar. */ |
| private final int mToolbarShadowHeight; |
| |
| /** The view that contains the sheet. */ |
| private ViewGroup mSheetContainer; |
| |
| /** For detecting scroll and fling events on the bottom sheet. */ |
| private BottomSheetSwipeDetector mGestureDetector; |
| |
| /** The animator used to move the sheet to a fixed state when released by the user. */ |
| private ValueAnimator mSettleAnimator; |
| |
| /** The width of the view that contains the bottom sheet. */ |
| private int mContainerWidth; |
| |
| /** The height of the view that contains the bottom sheet. */ |
| private int mContainerHeight; |
| |
| /** The width of the bottom sheet content view. */ |
| private int mContentWidth; |
| |
| /** The desired height of the current content view. */ |
| private float mContentDesiredHeight = HEIGHT_UNSPECIFIED; |
| |
| /** |
| * The current offset of the sheet from the bottom of the screen in px. This does not include |
| * added offset from the scrolling of the browser controls which allows the sheet's toolbar to |
| * show and hide in-sync with the top toolbar. |
| */ |
| private float mCurrentOffsetPx; |
| |
| /** The current state that the sheet is in. */ |
| @SheetState |
| private int mCurrentState = SheetState.HIDDEN; |
| |
| /** The target sheet state. This is the state that the sheet is currently moving to. */ |
| @SheetState |
| private int mTargetState = SheetState.NONE; |
| |
| /** While scrolling, this holds the state the scrolling started in. Otherwise, it's NONE. */ |
| @SheetState |
| int mScrollingStartState = SheetState.NONE; |
| |
| /** A handle to the content being shown by the sheet. */ |
| @Nullable |
| protected BottomSheetContent mSheetContent; |
| |
| /** A handle to the FrameLayout that holds the content of the bottom sheet. */ |
| private TouchRestrictingFrameLayout mBottomSheetContentContainer; |
| |
| /** |
| * The last offset ratio sent to observers of onSheetOffsetChanged(). This is used to ensure the |
| * min and max values are provided at least once (0 and 1). |
| */ |
| private float mLastOffsetRatioSent; |
| |
| /** The FrameLayout used to hold the bottom sheet toolbar. */ |
| private TouchRestrictingFrameLayout mToolbarHolder; |
| |
| /** |
| * The default toolbar view. This is shown when the current bottom sheet content doesn't have |
| * its own toolbar and when the bottom sheet is closed. |
| */ |
| protected View mDefaultToolbarView; |
| |
| /** Whether the {@link BottomSheet} and its children should react to touch events. */ |
| private boolean mIsTouchEnabled; |
| |
| /** Whether the sheet is currently open. */ |
| private boolean mIsSheetOpen; |
| |
| /** Whether {@link #destroy()} has been called. **/ |
| private boolean mIsDestroyed; |
| |
| /** The ratio in the range [0, 1] that the browser controls are hidden. */ |
| private float mBrowserControlsHiddenRatio; |
| |
| /** A means of checking whether accessibility is currently enabled. */ |
| private AccessibilityUtil mAccessibilityUtil; |
| |
| /** |
| * This is the height that the sheet is capable of scrolling through. It extends past the top of |
| * the screen so that the opaque part of the sheet reaches the top of the screen as the shadow |
| * scrolls off. |
| */ |
| private int mScrollableHeight; |
| |
| @Override |
| public boolean shouldGestureMoveSheet(MotionEvent initialEvent, MotionEvent currentEvent) { |
| // If the sheet is scrolling off-screen or in the process of hiding, gestures should not |
| // affect it. |
| if (getCurrentOffsetPx() < getSheetHeightForState(SheetState.PEEK) |
| || getOffsetFromBrowserControls() > 0) { |
| return false; |
| } |
| |
| // If the sheet is already open, the experiment is not enabled, or accessibility is enabled |
| // there is no need to restrict the swipe area. |
| if (isSheetOpen() || mAccessibilityUtil.isAccessibilityEnabled()) { |
| return true; |
| } |
| |
| float startX = mVisibleViewportRect.left; |
| float endX = getToolbarView().getWidth() + mVisibleViewportRect.left; |
| return currentEvent.getRawX() > startX && currentEvent.getRawX() < endX; |
| } |
| |
| /** |
| * Constructor for inflation from XML. |
| * @param context An Android context. |
| * @param atts The XML attributes. |
| */ |
| public BottomSheet(Context context, AttributeSet atts) { |
| super(context, atts); |
| |
| mMinHalfFullDistance = |
| getResources().getDimensionPixelSize(R.dimen.bottom_sheet_min_full_half_distance); |
| mToolbarShadowHeight = getResources().getDimensionPixelOffset(getTopShadowResourceId()); |
| |
| mGestureDetector = new BottomSheetSwipeDetector(context, this); |
| mIsTouchEnabled = true; |
| } |
| |
| /** @param reporter A means of reporting an exception without crashing. */ |
| static void setExceptionReporter(Callback<Throwable> reporter) { |
| sExceptionReporter = reporter; |
| } |
| |
| /** @return The dimen describing the height of the shadow above the bottom sheet. */ |
| static @DimenRes int getTopShadowResourceId() { |
| return R.dimen.bottom_sheet_toolbar_shadow_height; |
| } |
| |
| static @DimenRes int getShadowTopOffsetResourceId() { |
| return R.dimen.bottom_sheet_shadow_top_offset; |
| } |
| |
| /** |
| * Called when the activity containing the {@link BottomSheet} is destroyed. |
| */ |
| void destroy() { |
| Log.i(TAG, |
| "Sheet destroyed: state: " + mCurrentState |
| + ", content null: " + (getCurrentSheetContent() == null)); |
| mIsDestroyed = true; |
| mIsTouchEnabled = false; |
| mObservers.clear(); |
| endAnimations(); |
| } |
| |
| /** @param accessibilityUtil A mechanism for testing whether accessibility is enabled. */ |
| void setAccssibilityUtil(AccessibilityUtil accessibilityUtil) { |
| mAccessibilityUtil = accessibilityUtil; |
| } |
| |
| /** Immediately end all animations and null the animators. */ |
| void endAnimations() { |
| if (mSettleAnimator != null) mSettleAnimator.end(); |
| mSettleAnimator = null; |
| } |
| |
| /** @return Whether the sheet is in the process of hiding. */ |
| boolean isHiding() { |
| return mSettleAnimator != null && mTargetState == SheetState.HIDDEN; |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent e) { |
| if (!isTouchEventInUsableArea(e) && e.getActionMasked() == MotionEvent.ACTION_DOWN) { |
| return false; |
| } |
| |
| // If touch is disabled, act like a black hole and consume touch events without doing |
| // anything with them. |
| if (!mIsTouchEnabled) return true; |
| |
| if (isHiding()) return false; |
| |
| return mGestureDetector.onInterceptTouchEvent(e); |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent e) { |
| if (!isTouchEventInUsableArea(e) && e.getActionMasked() == MotionEvent.ACTION_DOWN) { |
| return false; |
| } |
| |
| // If touch is disabled, act like a black hole and consume touch events without doing |
| // anything with them. |
| if (!mIsTouchEnabled) return true; |
| |
| mGestureDetector.onTouchEvent(e); |
| |
| return true; |
| } |
| |
| @Override |
| public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| int heightSize = MeasureSpec.getSize(heightMeasureSpec); |
| assert heightSize != 0; |
| int height = heightSize + mToolbarShadowHeight; |
| int mode = isFullHeightWrapContent() ? MeasureSpec.AT_MOST : MeasureSpec.EXACTLY; |
| super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, mode)); |
| } |
| |
| /** |
| * Adds layout change listeners to the views that the bottom sheet depends on. Namely the |
| * heights of the root view and control container are important as they are used in many of the |
| * calculations in this class. |
| * @param window Android window for getting insets. |
| * @param keyboardDelegate Delegate for hiding the keyboard. |
| */ |
| public void init(Window window, KeyboardVisibilityDelegate keyboardDelegate) { |
| View root = (View) getParent(); |
| |
| mToolbarHolder = |
| (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_toolbar_container); |
| mToolbarHolder.setBackgroundResource(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); |
| |
| mContainerWidth = root.getWidth(); |
| mContainerHeight = root.getHeight(); |
| mScrollableHeight = mContainerHeight + mToolbarShadowHeight; |
| |
| mContentWidth = mContainerWidth; |
| |
| // Listen to height changes on the root. |
| root.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { |
| private int mPreviousKeyboardHeight; |
| |
| @Override |
| public void onLayoutChange(View v, int left, int top, int right, int bottom, |
| int oldLeft, int oldTop, int oldRight, int oldBottom) { |
| // Compute the new height taking the keyboard into account. |
| // TODO(mdjones): Share this logic with LocationBarLayout: crbug.com/725725. |
| int previousWidth = mContainerWidth; |
| int previousHeight = mContainerHeight; |
| mContainerWidth = right - left; |
| mContainerHeight = bottom - top; |
| mScrollableHeight = mContainerHeight + mToolbarShadowHeight; |
| |
| if (previousWidth != mContainerWidth || previousHeight != mContainerHeight) { |
| if (mCurrentState == SheetState.HALF && !isHalfStateEnabled()) { |
| setSheetState(SheetState.FULL, false); |
| } |
| invalidateContentDesiredHeight(); |
| } |
| |
| int heightMinusKeyboard = (int) mContainerHeight; |
| int keyboardHeight = 0; |
| |
| // Reset mVisibleViewportRect regardless of sheet open state as it is used outside |
| // of calculating the keyboard height. |
| window.getDecorView().getWindowVisibleDisplayFrame(mVisibleViewportRect); |
| if (isSheetOpen()) { |
| int decorHeight = window.getDecorView().getHeight(); |
| heightMinusKeyboard = Math.min(decorHeight, mVisibleViewportRect.height()); |
| keyboardHeight = (int) (mContainerHeight - heightMinusKeyboard); |
| } |
| |
| if (keyboardHeight != mPreviousKeyboardHeight) { |
| // If the keyboard height changed, recompute the padding for the content area. |
| // This shrinks the content size while retaining the default background color |
| // where the keyboard is appearing. If the sheet is not showing, resize the |
| // sheet to its default state. |
| mBottomSheetContentContainer.setPadding( |
| mBottomSheetContentContainer.getPaddingLeft(), |
| mBottomSheetContentContainer.getPaddingTop(), |
| mBottomSheetContentContainer.getPaddingRight(), keyboardHeight); |
| } |
| |
| if (previousHeight != mContainerHeight |
| || mPreviousKeyboardHeight != keyboardHeight) { |
| // If we are in the middle of a touch event stream (i.e. scrolling while |
| // keyboard is up) don't set the sheet state. Instead allow the gesture detector |
| // to position the sheet and make sure the keyboard hides. |
| if (mGestureDetector.isScrolling() && keyboardDelegate != null) { |
| keyboardDelegate.hideKeyboard(BottomSheet.this); |
| } else { |
| cancelAnimation(); |
| setSheetState(mCurrentState, false); |
| } |
| } |
| |
| mPreviousKeyboardHeight = keyboardHeight; |
| } |
| }); |
| |
| // Listen to height changes on the toolbar. |
| mToolbarHolder.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { |
| @Override |
| public void onLayoutChange(View v, int left, int top, int right, int bottom, |
| int oldLeft, int oldTop, int oldRight, int oldBottom) { |
| // Make sure the size of the layout actually changed. |
| if (bottom - top == oldBottom - oldTop && right - left == oldRight - oldLeft) { |
| return; |
| } |
| |
| if (!mGestureDetector.isScrolling() && isRunningSettleAnimation()) return; |
| |
| setSheetState(mCurrentState, false); |
| } |
| }); |
| |
| mSheetContainer = (ViewGroup) this.getParent(); |
| mSheetContainer.removeView(this); |
| } |
| |
| /** @param ratio The current browser controls hidden ratio. */ |
| void setBrowserControlsHiddenRatio(float ratio) { |
| mBrowserControlsHiddenRatio = ratio; |
| |
| if (getSheetState() == SheetState.HIDDEN) return; |
| if (getCurrentOffsetPx() > getSheetHeightForState(SheetState.PEEK)) return; |
| |
| // Updating the offset will automatically account for the browser controls. |
| setSheetOffsetFromBottom(getCurrentOffsetPx(), StateChangeReason.SWIPE); |
| } |
| |
| @Override |
| public void onWindowFocusChanged(boolean hasWindowFocus) { |
| super.onWindowFocusChanged(hasWindowFocus); |
| |
| // Trigger a relayout on window focus to correct any positioning issues when leaving Chrome |
| // previously. This is required as a layout is not triggered when coming back to Chrome |
| // with the keyboard previously shown. |
| if (hasWindowFocus) requestLayout(); |
| } |
| |
| @Override |
| public boolean isContentScrolledToTop() { |
| return mSheetContent == null || mSheetContent.getVerticalScrollOffset() <= 0; |
| } |
| |
| @Override |
| public float getCurrentOffsetPx() { |
| return mCurrentOffsetPx; |
| } |
| |
| @Override |
| public float getMinOffsetPx() { |
| return (swipeToDismissEnabled() ? getHiddenRatio() : getPeekRatio()) * mScrollableHeight; |
| } |
| |
| /** |
| * Test whether a motion event is in the area of the sheet considered to be usable (i.e. not |
| * on the shadow shown above the sheet or some other decorative part of the view). |
| * @param e The motion event relative to the bottom sheet view. |
| * @return Whether the event is considered to be in the usable area of the sheet. |
| */ |
| public boolean isTouchEventInUsableArea(MotionEvent event) { |
| return event.getY() > getToolbarShadowHeight(); |
| } |
| |
| @Override |
| public boolean isTouchEventInToolbar(MotionEvent event) { |
| mToolbarHolder.getLocationInWindow(mCachedLocation); |
| // This check only tests for collision for the Y component since the sheet is the full width |
| // of the screen. We only care if the touch event is above the bottom of the toolbar since |
| // we won't receive an event if the touch is outside the sheet. |
| return mCachedLocation[1] + mToolbarHolder.getHeight() > event.getRawY(); |
| } |
| |
| /** |
| * @return Whether flinging down hard enough will close the sheet. |
| */ |
| private boolean swipeToDismissEnabled() { |
| return mSheetContent != null ? mSheetContent.swipeToDismissEnabled() : true; |
| } |
| |
| /** |
| * @return Whether the half state should be skipped when moving the sheet down. |
| */ |
| private boolean shouldSkipHalfStateOnScrollingDown() { |
| return mSheetContent == null || mSheetContent.skipHalfStateOnScrollingDown(); |
| } |
| |
| /** |
| * @return The minimum sheet state that the user can swipe to. i.e. flinging down will either |
| * close the sheet or peek it. |
| */ |
| @SheetState |
| int getMinSwipableSheetState() { |
| return swipeToDismissEnabled() || !isPeekStateEnabled() ? SheetState.HIDDEN |
| : SheetState.PEEK; |
| } |
| |
| /** |
| * Get the state that the bottom sheet should open to with the provided content. |
| * @return The minimum opened state for the current content. |
| */ |
| @SheetState |
| int getOpeningState() { |
| if (mSheetContent == null) { |
| return SheetState.HIDDEN; |
| } else if (isPeekStateEnabled()) { |
| return SheetState.PEEK; |
| } else if (isHalfStateEnabled()) { |
| return SheetState.HALF; |
| } |
| return SheetState.FULL; |
| } |
| |
| @Override |
| public float getMaxOffsetPx() { |
| return getFullRatio() * mScrollableHeight; |
| } |
| |
| /** |
| * Show content in the bottom sheet's content area. |
| * @param content The {@link BottomSheetContent} to show, or null if no content should be shown. |
| */ |
| void showContent(@Nullable final BottomSheetContent content) { |
| // If the desired content is already showing, do nothing. |
| if (mSheetContent == content) return; |
| |
| Log.i(TAG, |
| "Setting sheet content: state: " + mCurrentState |
| + ", content null: " + (content == null)); |
| if (content == null) Thread.dumpStack(); |
| |
| // Remove this as listener from previous content layout and size changes. |
| if (mSheetContent != null) { |
| mSheetContent.setContentSizeListener(null); |
| mSheetContent.getContentView().removeOnLayoutChangeListener(this); |
| } |
| |
| if (content != null && getParent() == null) { |
| mSheetContainer.addView(this); |
| } else if (content == null) { |
| if (mSheetContainer.getParent() == null) { |
| throw new RuntimeException( |
| "Attempting to detach sheet that was not in the hierarchy!"); |
| } |
| mSheetContainer.removeView(this); |
| } |
| |
| swapViews(content != null ? content.getContentView() : null, |
| mSheetContent != null ? mSheetContent.getContentView() : null, |
| mBottomSheetContentContainer); |
| |
| View newToolbar = content != null ? content.getToolbarView() : null; |
| swapViews(newToolbar, mSheetContent != null ? mSheetContent.getToolbarView() : null, |
| mToolbarHolder); |
| |
| // We hide the default toolbar if the new content has its own. |
| mDefaultToolbarView.setVisibility(newToolbar != null ? GONE : VISIBLE); |
| |
| onSheetContentChanged(content); |
| } |
| |
| /** |
| * Removes the oldView (or sets it to invisible) and adds the new view to the specified parent. |
| * @param newView The new view to transition to. |
| * @param oldView The old view to transition from. |
| * @param parent The parent for newView and oldView. |
| */ |
| private void swapViews(final View newView, final View oldView, final ViewGroup parent) { |
| if (oldView != null && oldView.getParent() != null) parent.removeView(oldView); |
| if (newView != null && parent != newView.getParent()) parent.addView(newView); |
| } |
| |
| /** |
| * A notification that the sheet is exiting the peek state into one that shows content. |
| * @param reason The reason the sheet was opened, if any. |
| */ |
| private void onSheetOpened(@StateChangeReason int reason) { |
| if (mIsSheetOpen) return; |
| |
| mIsSheetOpen = true; |
| |
| for (BottomSheetObserver o : mObservers) o.onSheetOpened(reason); |
| } |
| |
| /** |
| * A notification that the sheet has returned to the peeking state. |
| * @param reason The {@link StateChangeReason} that the sheet was closed, |
| * if any. |
| */ |
| private void onSheetClosed(@StateChangeReason int reason) { |
| if (!mIsSheetOpen) return; |
| mIsSheetOpen = false; |
| |
| for (BottomSheetObserver o : mObservers) o.onSheetClosed(reason); |
| // If the sheet contents are cleared out before #onSheetClosed is called, do not try to |
| // retrieve the accessibility string. |
| if (getCurrentSheetContent() != null) { |
| announceForAccessibility(getResources().getString( |
| getCurrentSheetContent().getSheetClosedAccessibilityStringId())); |
| } |
| clearFocus(); |
| |
| setFocusable(false); |
| setFocusableInTouchMode(false); |
| setContentDescription(null); |
| } |
| |
| /** |
| * Cancels and nulls the height animation if it exists. |
| */ |
| private void cancelAnimation() { |
| if (mSettleAnimator == null) return; |
| mSettleAnimator.cancel(); |
| mSettleAnimator = null; |
| } |
| |
| /** |
| * Creates the sheet's animation to a target state. |
| * @param targetState The target state. |
| * @param reason The reason the sheet started animation. |
| */ |
| private void createSettleAnimation( |
| @SheetState final int targetState, @StateChangeReason final int reason) { |
| mTargetState = targetState; |
| mSettleAnimator = |
| ValueAnimator.ofFloat(getCurrentOffsetPx(), getSheetHeightForState(targetState)); |
| mSettleAnimator.setDuration(BASE_ANIMATION_DURATION_MS); |
| mSettleAnimator.setInterpolator(mInterpolator); |
| |
| // When the animation is canceled or ends, reset the handle to null. |
| mSettleAnimator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animator) { |
| if (mIsDestroyed) return; |
| |
| mSettleAnimator = null; |
| Log.i(TAG, |
| "Ending settle animation: target: " + targetState |
| + ", content null: " + (getCurrentSheetContent() == null)); |
| setInternalCurrentState(targetState, reason); |
| mTargetState = SheetState.NONE; |
| } |
| }); |
| |
| mSettleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { |
| @Override |
| public void onAnimationUpdate(ValueAnimator animator) { |
| // Cancelled animation on M seem to continue updating, block them. |
| if (animator != mSettleAnimator) return; |
| |
| setSheetOffsetFromBottom((Float) animator.getAnimatedValue(), reason); |
| } |
| }); |
| |
| Log.i(TAG, |
| "Starting settle animation: target: " + targetState |
| + ", content null: " + (getCurrentSheetContent() == null)); |
| |
| setInternalCurrentState(SheetState.SCROLLING, reason); |
| mSettleAnimator.start(); |
| } |
| |
| /** |
| * @return Get the height in px that the peeking bar is offset due to the browser controls. |
| */ |
| private float getOffsetFromBrowserControls() { |
| if (mSheetContent == null || !mSheetContent.hideOnScroll() || !isPeekStateEnabled()) { |
| return 0; |
| } |
| |
| return getPeekRatio() * mScrollableHeight * mBrowserControlsHiddenRatio; |
| } |
| |
| /** |
| * Sets the sheet's offset relative to the bottom of the screen. |
| * @param offset The offset that the sheet should be. |
| */ |
| void setSheetOffsetFromBottom(float offset, @StateChangeReason int reason) { |
| mCurrentOffsetPx = offset; |
| |
| // The browser controls offset is added here so that the sheet's toolbar behaves like the |
| // browser controls do. |
| float translationY = (mContainerHeight - mCurrentOffsetPx) + getOffsetFromBrowserControls(); |
| |
| if (isSheetOpen() && MathUtils.areFloatsEqual(translationY, getTranslationY())) return; |
| |
| setTranslationY(translationY); |
| |
| // Do open/close computation based on the minimum allowed state by the sheet's content. |
| // Note that when transitioning from hidden to peek, even dismissable sheets may want |
| // to have a peek state. |
| @SheetState |
| int minSwipableState = getMinSwipableSheetState(); |
| if (isPeekStateEnabled() && (!isSheetOpen() || mTargetState == SheetState.PEEK)) { |
| minSwipableState = SheetState.PEEK; |
| } |
| |
| float minScrollableHeight = getSheetHeightForState(minSwipableState); |
| boolean isAtMinHeight = MathUtils.areFloatsEqual(getCurrentOffsetPx(), minScrollableHeight); |
| boolean heightLessThanPeek = getCurrentOffsetPx() < minScrollableHeight; |
| // Trigger the onSheetClosed event when the sheet is moving toward the hidden state if peek |
| // is disabled. This should be fine since touch is disabled when the sheet's target is |
| // hidden. |
| boolean triggerCloseWithHidden = !isPeekStateEnabled() && mTargetState == SheetState.HIDDEN; |
| |
| if (isSheetOpen() && (heightLessThanPeek || isAtMinHeight || triggerCloseWithHidden)) { |
| onSheetClosed(reason); |
| } else if (!isSheetOpen() && mTargetState != SheetState.HIDDEN |
| && getCurrentOffsetPx() > minScrollableHeight) { |
| onSheetOpened(reason); |
| } |
| |
| sendOffsetChangeEvents(); |
| } |
| |
| @Override |
| public void setSheetOffset(float offset, boolean shouldAnimate) { |
| cancelAnimation(); |
| if (mSheetContent == null) return; |
| |
| if (shouldAnimate) { |
| float velocityY = getCurrentOffsetPx() - offset; |
| |
| @SheetState |
| int targetState = getTargetSheetState(offset, -velocityY); |
| |
| setSheetState(targetState, true, StateChangeReason.SWIPE); |
| } else { |
| setInternalCurrentState(SheetState.SCROLLING, StateChangeReason.SWIPE); |
| setSheetOffsetFromBottom(offset, StateChangeReason.SWIPE); |
| } |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the hidden state is. |
| */ |
| @VisibleForTesting |
| float getHiddenRatio() { |
| return 0; |
| } |
| |
| /** @return Whether the peeking state for the sheet's content is enabled. */ |
| boolean isPeekStateEnabled() { |
| return mSheetContent != null && mSheetContent.getPeekHeight() != HeightMode.DISABLED; |
| } |
| |
| /** @return Whether the half-height of the sheet is enabled. */ |
| private boolean isHalfStateEnabled() { |
| if (mSheetContent == null) return false; |
| |
| // Half state is invalid on small screens, when wrapping content at full height, and when |
| // explicitly disabled. |
| return !isSmallScreen() && mSheetContent.getHalfHeightRatio() != HeightMode.DISABLED |
| && mSheetContent.getFullHeightRatio() != HeightMode.WRAP_CONTENT; |
| } |
| |
| /** @return Whether the height mode for the full state is WRAP_CONTENT. */ |
| private boolean isFullHeightWrapContent() { |
| return mSheetContent != null |
| && mSheetContent.getFullHeightRatio() == HeightMode.WRAP_CONTENT; |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the peeking state is. |
| */ |
| public float getPeekRatio() { |
| if (mContainerHeight <= 0 || !isPeekStateEnabled()) return 0; |
| |
| // If the content has a custom peek ratio set, use that instead of computing one. |
| if (mSheetContent != null && mSheetContent.getPeekHeight() != HeightMode.DEFAULT) { |
| assert mSheetContent.getPeekHeight() |
| != HeightMode.WRAP_CONTENT : "The peek mode can't wrap content."; |
| float ratio = mSheetContent.getPeekHeight() / (float) mScrollableHeight; |
| assert ratio > 0 && ratio <= 1 : "Custom peek ratios must be in the range of (0, 1]."; |
| return ratio; |
| } |
| assert getToolbarView() != null : "Using default peek height requires a non-null toolbar"; |
| |
| View toolbarView = getToolbarView(); |
| int toolbarHeight = toolbarView.getHeight(); |
| if (toolbarHeight == 0) { |
| // If the toolbar is not laid out yet and has a fixed height layout parameter, we assume |
| // that the toolbar will have this height in the future. |
| ViewGroup.LayoutParams layoutParams = toolbarView.getLayoutParams(); |
| if (layoutParams != null) { |
| if (layoutParams.height > 0) { |
| toolbarHeight = layoutParams.height; |
| } else { |
| toolbarView.measure( |
| MeasureSpec.makeMeasureSpec(mContainerWidth, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec( |
| getMaxContentHeight(), MeasureSpec.AT_MOST)); |
| toolbarHeight = toolbarView.getMeasuredHeight(); |
| } |
| } |
| } |
| return (toolbarHeight + mToolbarShadowHeight) / (float) mScrollableHeight; |
| } |
| |
| private View getToolbarView() { |
| return mSheetContent != null && mSheetContent.getToolbarView() != null |
| ? mSheetContent.getToolbarView() |
| : mDefaultToolbarView; |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the half expanded state is. |
| */ |
| @VisibleForTesting |
| float getHalfRatio() { |
| if (mContainerHeight <= 0 || !isHalfStateEnabled()) return 0; |
| |
| float customHalfRatio = mSheetContent.getHalfHeightRatio(); |
| assert customHalfRatio |
| != HeightMode.WRAP_CONTENT |
| : "Half-height cannot be WRAP_CONTENT. This is only supported for full-height."; |
| |
| return customHalfRatio == HeightMode.DEFAULT ? HALF_HEIGHT_RATIO : customHalfRatio; |
| } |
| |
| /** |
| * @return The ratio of the height of the screen that the fully expanded state is. |
| */ |
| @VisibleForTesting |
| float getFullRatio() { |
| if (mContainerHeight <= 0 || mSheetContent == null) return 0; |
| |
| float customFullRatio = mSheetContent.getFullHeightRatio(); |
| assert customFullRatio != HeightMode.DISABLED : "The full height cannot be DISABLED."; |
| |
| if (isFullHeightWrapContent()) { |
| ensureContentDesiredHeightIsComputed(); |
| float heightPx = |
| Math.min(getMaxContentHeight(), mContentDesiredHeight) + mToolbarShadowHeight; |
| return heightPx / mScrollableHeight; |
| } |
| |
| return customFullRatio == HeightMode.DEFAULT ? 1 : customFullRatio; |
| } |
| |
| /** |
| * @return The height of the container that the bottom sheet exists in. |
| */ |
| public float getSheetContainerHeight() { |
| return mContainerHeight; |
| } |
| |
| /** |
| * Sends notifications if the sheet is transitioning from the peeking to half expanded state and |
| * from the peeking to fully expanded state. The peek to half events are only sent when the |
| * sheet is between the peeking and half states. |
| */ |
| private void sendOffsetChangeEvents() { |
| float offsetWithBrowserControls = getCurrentOffsetPx() - getOffsetFromBrowserControls(); |
| |
| // Do not send events for states less than the hidden state unless 0 has not been sent. |
| if (offsetWithBrowserControls <= getSheetHeightForState(SheetState.HIDDEN) |
| && mLastOffsetRatioSent <= 0) { |
| return; |
| } |
| |
| float screenRatio = |
| mContainerHeight > 0 ? offsetWithBrowserControls / (float) mContainerHeight : 0; |
| |
| // This ratio is relative to the peek and full positions of the sheet. |
| float maxHiddenFullRatio = getFullRatio() - getHiddenRatio(); |
| float hiddenFullRatio = maxHiddenFullRatio == 0 |
| ? 0 |
| : MathUtils.clamp((screenRatio - getHiddenRatio()) / maxHiddenFullRatio, 0, 1); |
| |
| if (offsetWithBrowserControls < getSheetHeightForState(SheetState.HIDDEN)) { |
| mLastOffsetRatioSent = 0; |
| } else { |
| mLastOffsetRatioSent = |
| MathUtils.areFloatsEqual(hiddenFullRatio, 0) ? 0 : hiddenFullRatio; |
| } |
| |
| for (BottomSheetObserver o : mObservers) { |
| o.onSheetOffsetChanged(mLastOffsetRatioSent, getCurrentOffsetPx()); |
| } |
| |
| if (isPeekStateEnabled() |
| && MathUtils.areFloatsEqual( |
| offsetWithBrowserControls, getSheetHeightForState(SheetState.PEEK))) { |
| for (BottomSheetObserver o : mObservers) o.onSheetFullyPeeked(); |
| } |
| } |
| |
| /** @see #setSheetState(int, boolean, int) */ |
| void setSheetState(@SheetState int state, boolean animate) { |
| setSheetState(state, animate, StateChangeReason.NONE); |
| } |
| |
| /** |
| * Moves the sheet to the provided state. |
| * @param state The state to move the panel to. This cannot be SheetState.SCROLLING or |
| * SheetState.NONE. |
| * @param animate If true, the sheet will animate to the provided state, otherwise it will |
| * move there instantly. |
| * @param reason The reason the sheet state is changing. This can be specified to indicate to |
| * observers that a more specific event has occurred, otherwise |
| * STATE_CHANGE_REASON_NONE can be used. |
| */ |
| void setSheetState(@SheetState int state, boolean animate, @StateChangeReason int reason) { |
| assert state != SheetState.NONE; |
| |
| Log.i(TAG, |
| "Setting sheet state: state: " + mCurrentState |
| + ", content null: " + (getCurrentSheetContent() == null)); |
| |
| // Setting state to SCROLLING is not a valid operation. This can happen only when |
| // we're already in the scrolling state. Make it no-op. |
| if (state == SheetState.SCROLLING) { |
| assert mCurrentState == SheetState.SCROLLING && isRunningSettleAnimation(); |
| return; |
| } |
| |
| if (state == SheetState.HALF && !isHalfStateEnabled()) state = SheetState.FULL; |
| |
| cancelAnimation(); |
| mTargetState = state; |
| |
| if (animate && state != mCurrentState) { |
| createSettleAnimation(state, reason); |
| } else { |
| setSheetOffsetFromBottom(getSheetHeightForState(state), reason); |
| setInternalCurrentState(mTargetState, reason); |
| mTargetState = SheetState.NONE; |
| } |
| } |
| |
| /** |
| * @return The target state that the sheet is moving to during animation. If the sheet is |
| * stationary or a target state has not been determined, SheetState.NONE will be |
| * returned. |
| */ |
| int getTargetSheetState() { |
| return mTargetState; |
| } |
| |
| /** |
| * @return The current state of the bottom sheet. If the sheet is animating, this will be the |
| * state the sheet is animating to. |
| */ |
| @SheetState |
| int getSheetState() { |
| return mCurrentState; |
| } |
| |
| /** @return Whether the sheet is currently open. */ |
| boolean isSheetOpen() { |
| return mIsSheetOpen; |
| } |
| |
| /** |
| * Set the current state of the bottom sheet. This is for internal use to notify observers of |
| * state change events. |
| * @param state The current state of the sheet. |
| * @param reason The reason the state is changing if any. |
| */ |
| private void setInternalCurrentState(@SheetState int state, @StateChangeReason int reason) { |
| if (state == mCurrentState) return; |
| |
| // If we somehow got here with null content, force the sheet to close without animation. |
| // See https://crbug.com/1126872 for more information. |
| if (getCurrentSheetContent() == null && state != SheetState.HIDDEN) { |
| Log.i(TAG, "Content null while open! "); |
| |
| Throwable throwable = new Throwable( |
| "This is not a crash. See https://crbug.com/1126872 for details."); |
| PostTask.postTask( |
| TaskTraits.BEST_EFFORT_MAY_BLOCK, () -> sExceptionReporter.onResult(throwable)); |
| |
| setSheetState(SheetState.HIDDEN, false); |
| return; |
| } |
| |
| // TODO(mdjones): This shouldn't be able to happen, but does occasionally during layout. |
| // Fix the race condition that is making this happen. |
| if (state == SheetState.NONE) { |
| setSheetState(getTargetSheetState(getCurrentOffsetPx(), 0), false); |
| return; |
| } |
| |
| // Remember which state precedes the scrolling. |
| mScrollingStartState = state == SheetState.SCROLLING |
| ? mCurrentState != SheetState.SCROLLING ? mCurrentState : SheetState.NONE |
| : SheetState.NONE; // Not scrolling anymore. |
| mCurrentState = state; |
| |
| if (mCurrentState == SheetState.HALF || mCurrentState == SheetState.FULL) { |
| int resId = mCurrentState == SheetState.FULL |
| ? getCurrentSheetContent().getSheetFullHeightAccessibilityStringId() |
| : getCurrentSheetContent().getSheetHalfHeightAccessibilityStringId(); |
| announceForAccessibility(getResources().getString(resId)); |
| |
| // TalkBack will announce the content description if it has changed, so wait to set the |
| // content description until after announcing full/half height. |
| setFocusable(true); |
| setFocusableInTouchMode(true); |
| String contentDescription = getResources().getString( |
| getCurrentSheetContent().getSheetContentDescriptionStringId()); |
| |
| if (getCurrentSheetContent().swipeToDismissEnabled()) { |
| contentDescription += ". " |
| + getResources().getString(R.string.bottom_sheet_accessibility_description); |
| } |
| |
| setContentDescription(contentDescription); |
| if (getFocusedChild() == null) requestFocus(); |
| } |
| |
| for (BottomSheetObserver o : mObservers) { |
| o.onSheetStateChanged(mCurrentState); |
| } |
| } |
| |
| /** |
| * If the animation to settle the sheet in one of its states is running. |
| * @return True if the animation is running. |
| */ |
| private boolean isRunningSettleAnimation() { |
| return mSettleAnimator != null; |
| } |
| |
| /** @return The current sheet content, or null if there is no content. */ |
| @Nullable |
| BottomSheetContent getCurrentSheetContent() { |
| return mSheetContent; |
| } |
| |
| /** |
| * Gets the height of the bottom sheet based on a provided state. |
| * @param state The state to get the height from. |
| * @return The height of the sheet at the provided state. |
| */ |
| private float getSheetHeightForState(@SheetState int state) { |
| if (isFullHeightWrapContent() && state == SheetState.FULL) { |
| ensureContentDesiredHeightIsComputed(); |
| } |
| |
| return getRatioForState(state) * mScrollableHeight; |
| } |
| |
| /** @return The max possible height that the content can be. */ |
| private int getMaxContentHeight() { |
| return mContainerHeight; |
| } |
| |
| private void ensureContentDesiredHeightIsComputed() { |
| if (mContentDesiredHeight != HEIGHT_UNSPECIFIED) { |
| return; |
| } |
| mSheetContent.getContentView().measure( |
| MeasureSpec.makeMeasureSpec(mContentWidth, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec(getMaxContentHeight(), MeasureSpec.AT_MOST)); |
| mContentDesiredHeight = mSheetContent.getContentView().getMeasuredHeight(); |
| } |
| |
| private float getRatioForState(int state) { |
| switch (state) { |
| case SheetState.HIDDEN: |
| return getHiddenRatio(); |
| case SheetState.PEEK: |
| return getPeekRatio(); |
| case SheetState.HALF: |
| return getHalfRatio(); |
| case SheetState.FULL: |
| return getFullRatio(); |
| } |
| |
| throw new IllegalArgumentException("Invalid state: " + state); |
| } |
| |
| /** |
| * Adds an observer to the bottom sheet. |
| * @param observer The observer to add. |
| */ |
| void addObserver(BottomSheetObserver observer) { |
| mObservers.addObserver(observer); |
| } |
| |
| /** |
| * Removes an observer to the bottom sheet. |
| * @param observer The observer to remove. |
| */ |
| void removeObserver(BottomSheetObserver observer) { |
| mObservers.removeObserver(observer); |
| } |
| |
| /** |
| * Gets the target state of the sheet based on the sheet's height and velocity. |
| * @param sheetHeight The current height of the sheet. |
| * @param yVelocity The current Y velocity of the sheet. If this value is positive, the movement |
| * is from bottom to top. |
| * @return The target state of the bottom sheet. |
| */ |
| @SheetState |
| private int getTargetSheetState(float sheetHeight, float yVelocity) { |
| if (sheetHeight <= getMinOffsetPx()) return getMinSwipableSheetState(); |
| if (sheetHeight >= getMaxOffsetPx()) return SheetState.FULL; |
| |
| boolean isMovingDownward = yVelocity < 0; |
| |
| // If velocity shouldn't affect dismissing the sheet, reverse effect on the sheet height. |
| if (isMovingDownward && !swipeToDismissEnabled()) sheetHeight -= yVelocity; |
| |
| // Find the two states that the sheet height is between. |
| @SheetState |
| int prevState = mScrollingStartState; |
| @SheetState |
| int nextState = isMovingDownward ? getLargestCollapsingState(isMovingDownward, sheetHeight) |
| : getSmallestExpandingState(isMovingDownward, sheetHeight); |
| |
| // Go into the next state only if the threshold for minimal change has been cleared. |
| return hasCrossedThresholdToNextState(prevState, nextState, sheetHeight, isMovingDownward) |
| ? nextState |
| : prevState; |
| } |
| |
| /** |
| * Returns whether the sheet was scrolled far enough to transition into the next state. |
| * @param prev The state before the scrolling transition happened. |
| * @param next The state before the scrolling transitions into. |
| * @param sheetMovesDown True if the sheet moves down. |
| * @param sheetHeight The current sheet height in flux. |
| * @return True, iff the sheet was scrolled far enough to transition from |prev| to |next|. |
| */ |
| private boolean hasCrossedThresholdToNextState( |
| @SheetState int prev, @SheetState int next, float sheetHeight, boolean sheetMovesDown) { |
| if (next == prev) return false; |
| // Moving from an internal/temporary state always works: |
| if (prev == SheetState.NONE || prev == SheetState.SCROLLING) return true; |
| float lowerBound = getSheetHeightForState(prev); |
| float distance = getSheetHeightForState(next) - lowerBound; |
| return Math.abs((sheetHeight - lowerBound) / distance) |
| > getThresholdToNextState(prev, next, sheetMovesDown); |
| } |
| |
| /** |
| * The threshold to enter a state depends on whether a transition skips the half state. The more |
| * states to cross, the smaller the (percentual) threshold. A small threshold is used iff: |
| * * It doesn't move into the HALF state, |
| * * Skipping the HALF state is allowed, and |
| * * The is large enough to skip the HALF state |
| * @param prev The state before the scrolling transition happened. |
| * @param next The state before the scrolling transitions into. |
| * @param sheetMovesDown True if the sheet is being moved down. |
| * @return a threshold (as percentage of the scroll distance covered). |
| */ |
| private float getThresholdToNextState( |
| @SheetState int prev, @SheetState int next, boolean sheetMovesDown) { |
| if (next == SheetState.HALF) return THRESHOLD_TO_NEXT_STATE_3; |
| boolean crossesHalf = sheetMovesDown && prev > SheetState.HALF && next < SheetState.HALF |
| || !sheetMovesDown && prev < SheetState.HALF && next > SheetState.HALF; |
| if (!crossesHalf) return THRESHOLD_TO_NEXT_STATE_3; |
| if (!shouldSkipHalfStateOnScrollingDown()) return THRESHOLD_TO_NEXT_STATE_3; |
| return THRESHOLD_TO_NEXT_STATE_2; |
| } |
| |
| /** |
| * Returns the largest, acceptable state whose height is smaller than the given sheet height. |
| * E.g. if a sheet is between FULL and HALF, collapsing states are PEEK and HALF. Although HALF |
| * is closer to the sheet's height, it might have to be skipped. Then, PEEK is returned instead. |
| * @param sheetMovesDown If the sheet moves down, some smaller states might be skipped. |
| * @param sheetHeight The current sheet height in flux. |
| * @return The largest, acceptable, collapsing state. |
| */ |
| private @SheetState int getLargestCollapsingState(boolean sheetMovesDown, float sheetHeight) { |
| @SheetState |
| int largestCollapsingState = getMinSwipableSheetState(); |
| boolean skipHalfState = !isHalfStateEnabled() || shouldSkipHalfStateOnScrollingDown(); |
| for (@SheetState int i = largestCollapsingState + 1; i < SheetState.FULL; i++) { |
| if (i == SheetState.PEEK && !isPeekStateEnabled()) continue; |
| if (i == SheetState.HALF && skipHalfState) continue; |
| |
| if (sheetHeight > getSheetHeightForState(i) |
| || sheetHeight == getSheetHeightForState(i) && !sheetMovesDown) { |
| largestCollapsingState = i; |
| } |
| } |
| return largestCollapsingState; |
| } |
| |
| /** |
| * Returns the smallest, acceptable state whose height is larger than the given sheet height. |
| * E.g. if the sheet is between PEEK and HALF, expanding states are HALF and FULL. Although HALF |
| * is closer to the sheet's height, it might not be enabled. Then, FULL is returned instead. |
| * @param sheetMovesDown If the sheet moves down, some collapsing states might be skipped. This |
| * affects the smallest possible expanding state as well. |
| * @param sheetHeight The current sheet height in flux. |
| * @return The smallest, acceptable, expanding state. |
| */ |
| private @SheetState int getSmallestExpandingState(boolean sheetMovesDown, float sheetHeight) { |
| @SheetState |
| int largestCollapsingState = getLargestCollapsingState(sheetMovesDown, sheetHeight); |
| @SheetState |
| int smallestExpandingState = SheetState.FULL; |
| for (@SheetState int i = smallestExpandingState - 1; i > largestCollapsingState + 1; i--) { |
| if (i == SheetState.HALF && !isHalfStateEnabled()) continue; |
| if (i == SheetState.PEEK && !isPeekStateEnabled()) continue; |
| |
| if (sheetHeight <= getSheetHeightForState(i)) { |
| smallestExpandingState = i; |
| } |
| } |
| |
| return smallestExpandingState; |
| } |
| |
| @VisibleForTesting |
| public static void setSmallScreenForTesting(boolean isSmallScreen) { |
| sIsSmallScreenForTesting = isSmallScreen; |
| } |
| |
| public boolean isSmallScreen() { |
| if (sIsSmallScreenForTesting != null) return sIsSmallScreenForTesting; |
| |
| // A small screen is defined by there being less than 160dp between half and full states. |
| float fullToHalfDiff = (1 - HALF_HEIGHT_RATIO) * mScrollableHeight; |
| return fullToHalfDiff < mMinHalfFullDistance; |
| } |
| |
| /** |
| * @return The height of the toolbar shadow. |
| */ |
| public int getToolbarShadowHeight() { |
| return mToolbarShadowHeight; |
| } |
| |
| /** |
| * Called when the sheet content has changed, to update dependent state and notify observers. |
| * @param content The new sheet content, or null if the sheet has no content. |
| */ |
| protected void onSheetContentChanged(@Nullable final BottomSheetContent content) { |
| mSheetContent = content; |
| |
| if (content != null && isFullHeightWrapContent()) { |
| // Listen for layout/size changes. |
| if (!content.setContentSizeListener(this::onContentSizeChanged)) { |
| content.getContentView().addOnLayoutChangeListener(this); |
| } |
| |
| invalidateContentDesiredHeight(); |
| ensureContentIsWrapped(/* animate= */ true); |
| |
| // HALF state is forbidden when wrapping the content. |
| if (mCurrentState == SheetState.HALF) { |
| setSheetState(SheetState.FULL, /* animate= */ true); |
| } |
| } |
| |
| for (BottomSheetObserver o : mObservers) { |
| o.onSheetContentChanged(content); |
| } |
| mToolbarHolder.setBackgroundColor(Color.TRANSPARENT); |
| } |
| |
| /** |
| * Called when the sheet content layout changed. |
| */ |
| @Override |
| public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, |
| int oldTop, int oldRight, int oldBottom) { |
| // When there is a device rotation, mContentWidth needs to be updated before the new |
| // view is drawn. |
| mContentWidth = right - left; |
| invalidateContentDesiredHeight(); |
| ensureContentIsWrapped(/* animate= */ true); |
| } |
| |
| /** |
| * Called when the sheet content size changed. |
| */ |
| private void onContentSizeChanged(int width, int height, int oldWidth, int oldHeight) { |
| boolean heightChanged = mContentDesiredHeight != height; |
| mContentDesiredHeight = height; |
| mContentWidth = width; |
| |
| if (heightChanged && mCurrentState == SheetState.SCROLLING) { |
| endAnimations(); |
| return; |
| } |
| |
| ensureContentIsWrapped(/* animate= */ false); |
| } |
| |
| private void ensureContentIsWrapped(boolean animate) { |
| if (mCurrentState == SheetState.HIDDEN || mCurrentState == SheetState.PEEK) return; |
| |
| // The SCROLLING state is used when animating the sheet height or when the user is swiping |
| // the sheet. If it is the latter, we should not change the sheet height. |
| if (!isRunningSettleAnimation() && mCurrentState == SheetState.SCROLLING) return; |
| setSheetState(mCurrentState, animate); |
| } |
| |
| private void invalidateContentDesiredHeight() { |
| mContentDesiredHeight = HEIGHT_UNSPECIFIED; |
| } |
| |
| /** |
| * WARNING: This destroys the state of the BottomSheet. Only use in tests and only use once. |
| * Puts the sheet into a scrolling state that can't be reached in tests otherwise. |
| * |
| * @param sheetHeightInPx The height in px that the sheet should be "scrolled" to. |
| * @param yUpwardsVelocity The sheet's upwards y velocity when reaching the scrolled height. |
| * @return The state the bottom sheet would target when the scrolling ends. |
| */ |
| @VisibleForTesting |
| @SheetState |
| int forceScrollingStateForTesting(float sheetHeightInPx, float yUpwardsVelocity) { |
| mScrollingStartState = mCurrentState; |
| mCurrentState = SheetState.SCROLLING; |
| return getTargetSheetState(sheetHeightInPx, yUpwardsVelocity); |
| } |
| } |