chromium/components/browser_ui/bottomsheet/android/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheet.java

// Copyright 2017 The Chromium Authors
// 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.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.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.MathUtils;
import org.chromium.base.ObserverList;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.supplier.Supplier;
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.widget.animation.CancelAwareAnimatorListener;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.interpolators.Interpolators;

/**
 * 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";

    /** Duration for transition to {@link SheetState#FULL}. */
    private static final int ANIMATION_DURATION_EXPAND_MS = 350;

    /** Duration for transition from {@link SheetState#FULL}. */
    private static final int ANIMATION_DURATION_SHRINK_MS = 250;

    /**
     * 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 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 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;

    /** 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;

    /** Whether or not always use the full width of the container. */
    private boolean mAlwaysFullWidth;

    /** The supplier of the bottom inset when edge to edge is enabled. */
    private Supplier<Integer> mEdgeToEdgeBottomInsetSupplier = () -> 0;

    /**
     * A view used to render a shadow behind the sheet and extends outside the bounds of its parent
     * view.
     */
    public static class ShadowLayerView extends View {
        /** The length of the shadow in any direction. */
        private int mShadowLength;

        /** Constructor to inflate from XML. */
        public ShadowLayerView(Context context, AttributeSet atts) {
            super(context, atts);
            mShadowLength =
                    context.getResources()
                            .getDimensionPixelSize(R.dimen.bottom_sheet_shadow_length);
            setTranslationX((LocalizationUtils.isLayoutRtl() ? 1 : -1) * mShadowLength);
            setTranslationY(-mShadowLength);
        }

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(
                    MeasureSpec.makeMeasureSpec(
                            MeasureSpec.getSize(widthMeasureSpec) + 2 * mShadowLength,
                            MeasureSpec.EXACTLY),
                    MeasureSpec.makeMeasureSpec(
                            MeasureSpec.getSize(heightMeasureSpec) + mShadowLength,
                            MeasureSpec.EXACTLY));
        }
    }

    @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 (getOffsetFromBrowserControls() > 0 || isHiding()) {
            return false;
        }

        // If the sheet is already open, or an accessibility service that can perform gestures or
        // uses touch exploration is enabled, there is no need to restrict the swipe area.
        if (isSheetOpen()
                || AccessibilityState.isPerformGesturesEnabled()
                || AccessibilityState.isTouchExplorationEnabled()) {
            return true;
        }

        float startX = mVisibleViewportRect.left;
        float endX = 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);

        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;
    }

    /** Called when the activity containing the {@link BottomSheet} is destroyed. */
    void destroy() {
        mIsDestroyed = true;
        mIsTouchEnabled = false;
        mObservers.clear();
        endAnimations();
    }

    /** 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 boolean onHoverEvent(MotionEvent event) {
        // https://crbug.com/1297267 Consume hover events to prevent talkback from reading items
        // behind the bottom sheet, in particular when the client has its own scrim lifecycle.
        super.onHoverEvent(event);
        return true;
    }

    /**
     * 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.
     * @param alwaysFullWidth Whether bottom sheet is always full-width.
     * @param edgeToEdgeBottomInsetSupplier The supplier of the bottom inset in DP when e2e is on.
     */
    public void init(
            Window window,
            KeyboardVisibilityDelegate keyboardDelegate,
            boolean alwaysFullWidth,
            @NonNull Supplier<Integer> edgeToEdgeBottomInsetSupplier) {
        mEdgeToEdgeBottomInsetSupplier = edgeToEdgeBottomInsetSupplier;
        mSheetContainer = (ViewGroup) getParent();

        mToolbarHolder =
                (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_toolbar_container);

        mBottomSheetContentContainer =
                (TouchRestrictingFrameLayout) findViewById(R.id.bottom_sheet_content);
        mBottomSheetContentContainer.setBottomSheet(this);

        mContainerWidth = mSheetContainer.getWidth();
        mContainerHeight = mSheetContainer.getHeight();
        mContentWidth = mContainerWidth;
        mAlwaysFullWidth = alwaysFullWidth;

        sizeAndPositionSheetInParent();

        // Listen to height changes on the root.
        mSheetContainer.addOnLayoutChangeListener(
                new View.OnLayoutChangeListener() {
                    private int mPreviousKeyboardHeight;

                    @Override
                    public void onLayoutChange(
                            View v,
                            int left,
                            int top,
                            int right,
                            int bottom,
                            int oldLeft,
                            int oldTop,
                            int oldRight,
                            int oldBottom) {
                        // Compute the new height taking the keyboard into account.
                        // TODO(mdjones): Share this logic with LocationBarLayout: crbug.com/725725.
                        int previousWidth = mContainerWidth;
                        int previousHeight = mContainerHeight;
                        mContainerWidth = right - left;
                        mContainerHeight = bottom - top;

                        if (previousWidth != mContainerWidth
                                || previousHeight != mContainerHeight) {
                            if (!isHalfStateEnabled()) {
                                if (mCurrentState == SheetState.HALF) {
                                    setSheetState(SheetState.FULL, false);
                                } else if (mCurrentState == SheetState.SCROLLING
                                        && mTargetState == SheetState.HALF) {
                                    // Let the animation resume to the full height.
                                    mTargetState = SheetState.FULL;
                                }
                            }
                            invalidateContentDesiredHeight();
                            sizeAndPositionSheetInParent();
                        }

                        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 =
                                    Math.max(0, (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 {
                                if (mTargetState != SheetState.NONE) {
                                    cancelAnimation();
                                    createSettleAnimation(mTargetState, StateChangeReason.NONE);
                                } else {
                                    endAnimations();
                                    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.removeView(this);
    }

    /** @param ratio The current browser controls hidden ratio. */
    void setBrowserControlsHiddenRatio(float ratio) {
        mBrowserControlsHiddenRatio = ratio;

        if (getSheetState() == SheetState.HIDDEN) return;
        int state = isHalfStateEnabled() ? SheetState.HALF : SheetState.PEEK;
        if (getCurrentOffsetPx() > getSheetHeightForState(state)) 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) {
            ViewUtils.requestLayout(this, "BottomSheet.onWindowFocusChagned");
        }
    }

    @Override
    public boolean isContentScrolledToTop() {
        return mSheetContent == null || mSheetContent.getVerticalScrollOffset() <= 0;
    }

    @Override
    public float getCurrentOffsetPx() {
        return mCurrentOffsetPx;
    }

    @Override
    public float getMinOffsetPx() {
        return (swipeToDismissEnabled() ? getHiddenRatio() : getPeekRatio()) * mContainerHeight;
    }

    /**
     * Test whether a motion event is in the area of the sheet considered to be usable (i.e. not
     * on the shadow shown above the sheet or some other decorative part of the view).
     * @param event 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() > 0;
    }

    @Override
    public boolean isTouchEventInToolbar(MotionEvent event) {
        mToolbarHolder.getLocationOnScreen(mCachedLocation);

        // This check only tests for collision for the Y component since the sheet is the full width
        // of the screen. We only care if the touch event is above the bottom of the toolbar since
        // we won't receive an event if the touch is outside the sheet.
        return mCachedLocation[1] + mToolbarHolder.getHeight() > event.getRawY();
    }

    /** @return Whether flinging down hard enough will close the sheet. */
    private boolean swipeToDismissEnabled() {
        return mSheetContent != null ? mSheetContent.swipeToDismissEnabled() : true;
    }

    /** @return Whether the half state should be skipped when moving the sheet down. */
    private boolean shouldSkipHalfStateOnScrollingDown() {
        return mSheetContent == null || mSheetContent.skipHalfStateOnScrollingDown();
    }

    /**
     * @return The minimum sheet state that the user can swipe to. i.e. flinging down will either
     *         close the sheet or peek it.
     */
    @SheetState
    int getMinSwipableSheetState() {
        return swipeToDismissEnabled() || !isPeekStateEnabled()
                ? SheetState.HIDDEN
                : SheetState.PEEK;
    }

    /**
     * Get the state that the bottom sheet should open to with the provided content.
     * @return The minimum opened state for the current content.
     */
    @SheetState
    int getOpeningState() {
        if (mSheetContent == null) {
            return SheetState.HIDDEN;
        } else if (isPeekStateEnabled()) {
            return SheetState.PEEK;
        } else if (isHalfStateEnabled()) {
            return SheetState.HALF;
        }
        return SheetState.FULL;
    }

    @Override
    public float getMaxOffsetPx() {
        return getFullRatio() * mContainerHeight;
    }

    /**
     * Show content in the bottom sheet's content area.
     * @param content The {@link BottomSheetContent} to show, or null if no content should be shown.
     */
    void showContent(@Nullable final BottomSheetContent content) {
        // If the desired content is already showing, do nothing.
        if (mSheetContent == content) return;

        // Remove this as listener from previous content layout and size changes.
        if (mSheetContent != null) {
            mSheetContent.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);

        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));
        boolean isExpand = targetState == SheetState.FULL;
        long duration = isExpand ? ANIMATION_DURATION_EXPAND_MS : ANIMATION_DURATION_SHRINK_MS;
        mSettleAnimator.setDuration(duration);
        mSettleAnimator.setInterpolator(Interpolators.EMPHASIZED);

        // When the animation is canceled or ends, reset the handle to null.
        mSettleAnimator.addListener(
                new CancelAwareAnimatorListener() {
                    @Override
                    public void onEnd(Animator animator) {
                        if (mIsDestroyed) return;

                        mSettleAnimator = 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);
                    }
                });

        setInternalCurrentState(SheetState.SCROLLING, reason);
        mSettleAnimator.start();
    }

    /** @return Get the height in px that the sheet is offset due to the browser controls. */
    private float getOffsetFromBrowserControls() {
        if (mSheetContent == null || !mSheetContent.hideOnScroll()) return 0;

        // We only care about peek/half state.
        int state = getSheetState();

        // Returns non-zero offset for the opening animation. This keeps the animation running
        // below the bottom of the screen.
        if (mAlwaysFullWidth
                && state == SheetState.SCROLLING
                && mTargetState == SheetState.PEEK
                && mBrowserControlsHiddenRatio == 1.f) {
            state = mTargetState;
        }
        if (state != SheetState.PEEK && state != SheetState.HALF) return 0;
        return getSheetHeightForState(state) * mBrowserControlsHiddenRatio;
    }

    /**
     * Sets the sheet's offset relative to the bottom of the screen.
     * @param offset The offset that the sheet should be.
     * @param reason The reason for the sheet offset to change to report to listeners.
     */
    void setSheetOffsetFromBottom(float offset, @StateChangeReason int reason) {
        setSheetOffsetFromBottom(offset, reason, /* reportOpenClosed= */ true);
    }

    /**
     * Sets the sheet's offset relative to the bottom of the screen.
     * @param offset The offset that the sheet should be.
     * @param reason The reason for the sheet offset to change to report to listeners.
     * @param reportOpenClosed {@code true} to allow reporting the sheet opened or closed as a
     *         result of this change. {@code reason} is never used when this is {@code false}.
     */
    void setSheetOffsetFromBottom(
            float offset, @StateChangeReason int reason, boolean reportOpenClosed) {
        mCurrentOffsetPx = offset;

        assert mEdgeToEdgeBottomInsetSupplier.get() != null;
        int bottomInset = ViewUtils.dpToPx(getContext(), mEdgeToEdgeBottomInsetSupplier.get());

        // The browser controls offset is added here so that the sheet's toolbar behaves like the
        // browser controls do.
        float translationY =
                (mContainerHeight - mCurrentOffsetPx)
                        + getOffsetFromBrowserControls()
                        - (mTargetState == SheetState.HIDDEN ? 0 : bottomInset);

        if (isSheetOpen() && MathUtils.areFloatsEqual(translationY, getTranslationY())) return;

        setTranslationY(translationY);

        if (reportOpenClosed) {
            // 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;

            if (isSheetOpen() && (heightLessThanPeek || isAtMinHeight)) {
                onSheetClosed(reason);
            } else if (!isSheetOpen()
                    && mTargetState != SheetState.HIDDEN
                    && getCurrentOffsetPx() > minScrollableHeight) {
                onSheetOpened(reason);
            }
        }

        sendOffsetChangeEvents();
    }

    @Override
    public void setSheetOffset(float offset, boolean shouldAnimate) {
        cancelAnimation();
        if (mSheetContent == null) return;

        if (shouldAnimate) {
            float velocityY = getCurrentOffsetPx() - offset;

            @SheetState int targetState = getTargetSheetState(offset, -velocityY);

            setSheetState(targetState, true, StateChangeReason.SWIPE);
        } else {
            setInternalCurrentState(SheetState.SCROLLING, StateChangeReason.SWIPE);
            setSheetOffsetFromBottom(offset, StateChangeReason.SWIPE);
        }
    }

    /** @return The ratio of the height of the screen that the hidden state is. */
    @VisibleForTesting
    float getHiddenRatio() {
        return 0;
    }

    /** @return Whether the peeking state for the sheet's content is enabled. */
    boolean isPeekStateEnabled() {
        return mSheetContent != null && mSheetContent.getPeekHeight() != HeightMode.DISABLED;
    }

    /** @return Whether the half-height of the sheet is enabled. */
    private boolean isHalfStateEnabled() {
        if (mSheetContent == null) return false;

        // Half state is invalid on small screens, when wrapping content at full height, and when
        // explicitly disabled.
        return !isSmallScreen()
                && mSheetContent.getHalfHeightRatio() != HeightMode.DISABLED
                && mSheetContent.getFullHeightRatio() != HeightMode.WRAP_CONTENT;
    }

    /** @return Whether the height mode for the full state is WRAP_CONTENT. */
    private boolean isFullHeightWrapContent() {
        return mSheetContent != null
                && mSheetContent.getFullHeightRatio() == HeightMode.WRAP_CONTENT;
    }

    /** @return The ratio of the height of the screen that the peeking state is. */
    public float getPeekRatio() {
        if (mContainerHeight <= 0 || !isPeekStateEnabled()) return 0;

        // If the content has a custom peek ratio set, use that instead of computing one.
        if (mSheetContent != null && mSheetContent.getPeekHeight() != HeightMode.DEFAULT) {
            assert mSheetContent.getPeekHeight() != HeightMode.WRAP_CONTENT
                    : "The peek mode can't wrap content.";
            float ratio = mSheetContent.getPeekHeight() / (float) mContainerHeight;
            assert ratio > 0 && ratio <= 1 : "Custom peek ratios must be in the range of (0, 1].";
            return ratio;
        }

        View toolbarView = getToolbarView();

        int toolbarHeight;
        if (toolbarView == null) {
            toolbarHeight = getResources().getDimensionPixelSize(R.dimen.bottom_sheet_peek_height);
        } else {
            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(
                                        getMaxSheetWidth(), MeasureSpec.EXACTLY),
                                MeasureSpec.makeMeasureSpec(
                                        getMaxContentHeight(), MeasureSpec.AT_MOST));
                        toolbarHeight = toolbarView.getMeasuredHeight();
                    }
                }
            }
        }
        return toolbarHeight / (float) mContainerHeight;
    }

    private View getToolbarView() {
        return mSheetContent != null && mSheetContent.getToolbarView() != null
                ? mSheetContent.getToolbarView()
                : null;
    }

    /** @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();
            return Math.min(getMaxContentHeight(), mContentDesiredHeight) / mContainerHeight;
        }

        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());
        }
    }

    /** @see #setSheetState(int, boolean, int) */
    void setSheetState(@SheetState int state, boolean animate) {
        setSheetState(state, animate, StateChangeReason.NONE);
    }

    /**
     * Moves the sheet to the provided state.
     * @param state The state to move the panel to. This cannot be SheetState.SCROLLING or
     *              SheetState.NONE.
     * @param animate If true, the sheet will animate to the provided state, otherwise it will
     *                move there instantly.
     * @param reason The reason the sheet state is changing. This can be specified to indicate to
     *               observers that a more specific event has occurred, otherwise
     *               STATE_CHANGE_REASON_NONE can be used.
     */
    void setSheetState(@SheetState int state, boolean animate, @StateChangeReason int reason) {
        assert state != SheetState.NONE;

        // Setting state to SCROLLING is not a valid operation. This can happen only when
        // we're already in the scrolling state. Make it no-op.
        if (state == SheetState.SCROLLING) {
            // TODO(mdjones): The isRunningSettleAnimation should hold but currently doesn't.
            assert mCurrentState == SheetState.SCROLLING; // && isRunningSettleAnimation();
            return;
        }

        if (state == SheetState.HALF && !isHalfStateEnabled()) state = SheetState.FULL;

        cancelAnimation();
        mTargetState = state;

        if (animate
                && (state != mCurrentState
                        || mCurrentOffsetPx != getSheetHeightForState(mTargetState))) {
            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) {
            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, reason);
        }
    }

    /**
     * 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) * mContainerHeight;
    }

    /** @return The max possible height that the content can be. */
    private int getMaxContentHeight() {
        return mContainerHeight;
    }

    /** @return The maximum width of the bottom sheet based on its current state and container. */
    private int getMaxSheetWidth() {
        if (!mAlwaysFullWidth) {
            int narrowWidthThreshold =
                    getResources()
                            .getDimensionPixelSize(R.dimen.bottom_sheet_narrow_width_threshold);
            if (mContainerWidth > narrowWidthThreshold) {
                return getResources().getDimensionPixelSize(R.dimen.bottom_sheet_narrow_width);
            }
        }
        return mContainerWidth;
    }

    /**
     * @return Whether the sheet covers the full width of the container, or is limited to only
     *     partial width.
     */
    public boolean isFullWidth() {
        return getMaxSheetWidth() >= mContainerWidth;
    }

    /** Center and size the sheet in its container. */
    private void sizeAndPositionSheetInParent() {
        int maxSheetWidth = getMaxSheetWidth();
        getLayoutParams().width = maxSheetWidth;
        setTranslationX(
                (LocalizationUtils.isLayoutRtl() ? -1 : 1)
                        * (mContainerWidth - maxSheetWidth)
                        / 2f);
        ViewUtils.requestLayout(this, "BottomSheet.sizeAndPositionSheetInParent");
    }

    private void ensureContentDesiredHeightIsComputed() {
        if (mContentDesiredHeight != HEIGHT_UNSPECIFIED) {
            return;
        }
        mSheetContent
                .getContentView()
                .measure(
                        MeasureSpec.makeMeasureSpec(getMaxSheetWidth(), 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;
    }

    public static void setSmallScreenForTesting(boolean isSmallScreen) {
        sIsSmallScreenForTesting = isSmallScreen;
        ResettersForTesting.register(() -> sIsSmallScreenForTesting = null);
    }

    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) * mContainerHeight;
        return fullToHalfDiff < mMinHalfFullDistance;
    }

    /**
     * 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;

        boolean shouldLongPressMoveSheet =
                content == null ? false : content.shouldLongPressMoveSheet();
        mGestureDetector.setShouldLongPressMoveSheet(shouldLongPressMoveSheet);

        if (content != null && isFullHeightWrapContent()) {
            // Listen for layout/size changes.
            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);

        // If the sheet height changes mid-animation, make sure we animate to that height.
        // TODO(330357665): This animation will look rough in most cases, we should investigate a
        //                  way to smooth this.
        int newHeight = bottom - top;
        int oldHeight = oldBottom - oldTop;
        if (isRunningSettleAnimation() && isFullHeightWrapContent() && oldHeight != newHeight) {
            @SheetState int target = getTargetSheetState();
            if (target != SheetState.NONE) {
                cancelAnimation();
                setSheetState(target, /* animate= */ true);
            }
        }
    }

    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);
    }
}