Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Open bottom sheet when sibling scrolling reaches the end?

Is there any way to "forward" scroll events from one scrolling view to my bottom sheet, so that my bottom sheet begins to expand when I over-scroll the first scrolling view?

Consider this tiny app:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        int peekHeight = getResources().getDimensionPixelSize(R.dimen.bottom_sheet_peek_height); // 96dp

        View bottomSheet = findViewById(R.id.bottomSheet);
        BottomSheetBehavior<View> behavior = BottomSheetBehavior.from(bottomSheet);
        behavior.setPeekHeight(peekHeight);
    }
}
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <!-- LinearLayout holding children to scroll through -->

    </android.support.v4.widget.NestedScrollView>

    <View
        android:id="@+id/bottomSheet"
        android:layout_width="300dp"
        android:layout_height="400dp"
        android:layout_gravity="center_horizontal"
        app:layout_behavior="android.support.design.widget.BottomSheetBehavior"/>

</android.support.design.widget.CoordinatorLayout>

Out of the box, this works just fine. I see 96dp worth of my bottom sheet, and I can swipe it up and down as normal. Additionally, I can see my scrolling content, and I can scroll it up and down as normal.

enter image description here enter image description here enter image description here

Let's assume I'm at the state shown in the second image. My NestedScrollView is scrolled all the way to the bottom and my bottom sheet is collapsed. I'd like to be able to swipe upwards on the NestedScrollView (not on the bottom sheet) and, since it can't scroll any farther, have that swipe gesture instead be sent to the bottom sheet, so that it begins to expand. Basically, have the app behave as though my gesture had been performed on the bottom sheet, not the scroll view.

My first thought was to look at NestedScrollView.OnScrollChangeListener, but I couldn't get that to work since it stops being triggered at the boundaries of the scrolling content (after all, it listens for scroll changes, and nothing's changing when you're at the edges).

I also took a look at creating my own subclass of BottomSheetBehavior and trying to override onInterceptTouchEvent(), but ran into trouble in two places. First, I only want to capture events when the sibling scroll view is at the bottom, and I could do that, but I was now capturing all events (making it impossible to scroll the sibling back up). Second, the private field mIgnoreEvents inside BottomSheetBehavior was blocking the bottom sheet from actually expanding. I can use reflection to access this field and prevent it from blocking me, but that feels evil.

Edit: I spent some more time looking into AppBarLayout.ScrollingViewBehavior, since that seemed to be pretty close to what I wanted (it converts swipes on one view into resizing on another), but that appears to manually set the offset pixel by pixel, and bottom sheets don't quite behave that way.

like image 827
Ben P. Avatar asked Feb 23 '18 01:02

Ben P.


1 Answers

This is an update with a more general solution. It now handles being hidden and "skip collapsed" of the standard bottom view behavior.

The following solution uses a custom BottomSheetBehavior. Here is a quick video of a small app based upon your posted app with the custom behavior in place:

enter image description here

MyBottomSheetBehavior extends BottomSheetBehavior and does the heavy lifting for the desired behavior. MyBottomSheetBehavior is passive until the NestedScrollView reaches its bottom scroll limit. onNestedScroll() identifies that the limit has been reached and offsets the bottom sheet by the amount of the scroll until the offset for the fully expanded bottom sheet is reached. This is the expansion logic.

Once the bottom sheet is released from the bottom, the bottom sheet is considered "captured" until the user lifts a finger from the screen. While the bottom sheet is captured, onNestPreScroll() handles moving the bottom sheet toward the bottom of the screen. This is the collapsing logic.

BottomSheetBehavior doesn't provide a means to manipulate the bottom sheet other than to completely collapse or expand it. Other functionality that is needed is locked up in package-private functions of the base behavior. To get around this, I created a new class called BottomSheetBehaviorAccessors that shares a package (android.support.design.widget) with the stock behavior. This class provides access to some package-private methods that are used in the new behavior.

MyBottomSheetBehavior also accommodates the callbacks of BottomSheetBehavior.BottomSheetCallback and other general functionality.

MyBottomSheetBehavior.java

public class MyBottomSheetBehavior<V extends View> extends BottomSheetBehaviorAccessors<V> {

    // The bottom sheet that interests us.
    private View mBottomSheet;

    // Offset when sheet is expanded.
    private int mMinOffset;

    // Offset when sheet is collapsed.
    private int mMaxOffset;

    // This is the  bottom of the bottom sheet's parent.
    private int mParentBottom;

    // True if the bottom sheet is being moved through nested scrolls from NestedScrollView.
    private boolean mSheetCaptured = false;

    // True if the bottom sheet is touched directly and being dragged.
    private boolean mIsheetTouched = false;

    // Set to true on ACTION_DOWN on the NestedScrollView
    private boolean mScrollStarted = false;

    @SuppressWarnings("unused")
    public MyBottomSheetBehavior() {
    }

    @SuppressWarnings("unused")
    public MyBottomSheetBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
        if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
            mSheetCaptured = false;
            mIsheetTouched = parent.isPointInChildBounds(child, (int) ev.getX(), (int) ev.getY());
            mScrollStarted = !mIsheetTouched;
        }
        return super.onInterceptTouchEvent(parent, child, ev);
    }

    @Override
    public boolean onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection) {
        mMinOffset = Math.max(0, parent.getHeight() - child.getHeight());
        mMaxOffset = Math.max(parent.getHeight() - getPeekHeight(), mMinOffset);
        mBottomSheet = child;
        mParentBottom = parent.getBottom();
        return super.onLayoutChild(parent, child, layoutDirection);
    }

    @Override
    public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout,
                                  @NonNull V child, @NonNull View target, int dx, int dy,
                                  @NonNull int[] consumed, int type) {
        if (dy >= 0 || !mSheetCaptured || type != ViewCompat.TYPE_TOUCH
            || !(target instanceof NestedScrollView)) {
            super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
            return;
        }
        // Pointer moving downward (dy < 0: scrolling toward top of data)
        if (child.getTop() - dy <= mMaxOffset) {
            // Dragging...
            ViewCompat.offsetTopAndBottom(child, -dy);
            setStateInternalAccessor(STATE_DRAGGING);
            consumed[1] = dy;
        } else if (isHideable()) {
            // Hide...
            ViewCompat.offsetTopAndBottom(child, Math.min(-dy, mParentBottom - child.getTop()));
            consumed[1] = dy;
        } else if (mMaxOffset - child.getTop() > 0) {
            // Collapsed...
            ViewCompat.offsetTopAndBottom(child, mMaxOffset - child.getTop());
            consumed[1] = dy;
        }
        if (consumed[1] != 0) {
            dispatchOnSlideAccessor(child.getTop());
        }
    }

    @Override
    public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child,
                               @NonNull View target, int dxConsumed, int dyConsumed,
                               int dxUnconsumed, int dyUnconsumed, int type) {
        if (dyUnconsumed <= 0 || !(target instanceof NestedScrollView)
            || type != ViewCompat.TYPE_TOUCH || getState() == STATE_HIDDEN) {
            mSheetCaptured = false;
        } else if (!mSheetCaptured) {
            // Capture the bottom sheet only if it is at its collapsed height.
            mSheetCaptured = isSheetCollapsed();
        }
        if (!mSheetCaptured) {
            super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed,
                                 dxUnconsumed, dyUnconsumed, type);
            return;
        }

        /*
            If the pointer is moving upward (dyUnconsumed > 0) and the scroll view isn't
            consuming scroll (dyConsumed == 0) then the scroll view  must be at the end
            of its scroll.
        */
        if (child.getTop() - dyUnconsumed < mMinOffset) {
            // Expanded...
            ViewCompat.offsetTopAndBottom(child, mMinOffset - child.getTop());
        } else {
            // Dragging...
            ViewCompat.offsetTopAndBottom(child, -dyUnconsumed);
            setStateInternalAccessor(STATE_DRAGGING);
        }
        dispatchOnSlideAccessor(child.getTop());
    }

    @Override
    public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target) {
        if (mScrollStarted) {
            // Ignore initial call to this method before anything has happened.
            mScrollStarted = false;
        } else if (!mIsheetTouched) {
            snapBottomSheet();
        }
        super.onStopNestedScroll(coordinatorLayout, child, target);
    }

    private void snapBottomSheet() {
        if ((mMaxOffset - mBottomSheet.getTop()) > (mMaxOffset - mMinOffset) / 2) {
            setState(BottomSheetBehavior.STATE_EXPANDED);
        } else if (shouldHideAccessor(mBottomSheet, 0)) {
            setState(BottomSheetBehavior.STATE_HIDDEN);
        } else {
            setState(BottomSheetBehavior.STATE_COLLAPSED);
        }
    }

    private boolean isSheetCollapsed() {
        return mBottomSheet.getTop() == mMaxOffset;
    }

    @SuppressWarnings("unused")
    private static final String TAG = "MyBottomSheetBehavior";
}

BottomSheetBehaviorAccessors

package android.support.design.widget; // important!

// A "friend" class to provide access to some package-private methods in `BottomSheetBehavior`.
public class BottomSheetBehaviorAccessors<V extends View> extends BottomSheetBehavior<V> {

    @SuppressWarnings("unused")
    protected BottomSheetBehaviorAccessors() {
    }

    @SuppressWarnings("unused")
    public BottomSheetBehaviorAccessors(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    protected void setStateInternalAccessor(int state) {
        super.setStateInternal(state);
    }

    protected void dispatchOnSlideAccessor(int top) {
        super.dispatchOnSlide(top);
    }

    protected boolean shouldHideAccessor(View child, float yvel) {
        return mHideable && super.shouldHide(child, yvel);
    }

    @SuppressWarnings("unused")
    private static final String TAG = "BehaviorAccessor";
}

MainActivity.java

public class MainActivity extends AppCompatActivity{
    private View mBottomSheet;
    MyBottomSheetBehavior<View> mBehavior;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayShowTitleEnabled(false);

        int peekHeight = getResources().getDimensionPixelSize(R.dimen.bottom_sheet_peek_height); // 96dp
        mBottomSheet = findViewById(R.id.bottomSheet);
        mBehavior = (MyBottomSheetBehavior) MyBottomSheetBehavior.from(mBottomSheet);
        mBehavior.setPeekHeight(peekHeight);
    }
}

activity_main.xml

<android.support.design.widget.CoordinatorLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/appBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:stateListAnimator="@null"
        android:theme="@style/AppTheme.AppBarOverlay"
        app:expanded="false"
        app:layout_behavior="android.support.design.widget.AppBarLayout$Behavior">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/collapsingToolbarLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:statusBarScrim="?attr/colorPrimaryDark">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="250dp"
                android:layout_marginTop="?attr/actionBarSize"
                android:scaleType="centerCrop"
                android:src="@drawable/seascape1"
                app:layout_collapseMode="parallax"
                app:layout_collapseParallaxMultiplier="1.0"
                tools:ignore="ContentDescription" />

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin" />

        </android.support.design.widget.CollapsingToolbarLayout>

    </android.support.design.widget.AppBarLayout>

    <com.example.bottomsheetoverscroll.MyNestedScrollView
        android:id="@+id/nestedScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">

            <View
                android:layout_width="match_parent"
                android:layout_height="100dp"
                android:background="@android:color/holo_blue_light" />

            <View
                android:layout_width="match_parent"
                android:layout_height="100dp"
                android:background="@android:color/holo_red_light" />

            <View
                android:layout_width="match_parent"
                android:layout_height="100dp"
                android:background="@android:color/holo_blue_light" />

            <View
                android:layout_width="match_parent"
                android:layout_height="100dp"
                android:background="@android:color/holo_red_light" />

            <View
                android:layout_width="match_parent"
                android:layout_height="100dp"
                android:background="@android:color/holo_blue_light" />

            <View
                android:layout_width="match_parent"
                android:layout_height="100dp"
                android:background="@android:color/holo_red_light" />

            <View
                android:layout_width="match_parent"
                android:layout_height="100dp"
                android:background="@android:color/holo_green_light" />

        </LinearLayout>
    </com.example.bottomsheetoverscroll.MyNestedScrollView>

    <TextView
        android:id="@+id/bottomSheet"
        android:layout_width="300dp"
        android:layout_height="400dp"
        android:layout_gravity="center_horizontal"
        android:background="@android:color/white"
        android:text="Bottom Sheet"
        android:textAlignment="center"
        android:textSize="24sp"
        android:textStyle="bold"
        app:layout_behavior="com.example.bottomsheetoverscroll.MyBottomSheetBehavior" />
    <!--app:layout_behavior="android.support.design.widget.BottomSheetBehavior" />-->

</android.support.design.widget.CoordinatorLayout>
like image 71
Cheticamp Avatar answered Oct 13 '22 00:10

Cheticamp