Открыть нижний лист, когда одноуровневая прокрутка доходит до конца?

Есть ли способ «перемотать» события прокрутки из одного прокручиваемого представления на мой нижний лист, чтобы мой нижний лист начал расширяться, когда я прокручиваю первое прокручиваемое представление?

Рассмотрим это крошечное приложение:

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>

Из коробки это работает нормально. Я вижу нижний лист размером 96 dp, и я могу смахивать его вверх и вниз, как обычно. Кроме того, я могу видеть свой прокручиваемый контент, и я могу прокручивать его вверх и вниз как обычно.

введите здесь описание изображения  введите описание изображения здесь < img src = "https://i.stack.imgur.com/rVbABm.png" alt = "введите описание изображения здесь">

Предположим, я нахожусь в состоянии, показанном на втором изображении. Мой NestedScrollView прокручен до конца, а нижний лист свернут. Я хотел бы иметь возможность провести вверх по NestedScrollView (не на нижнем листе) и, поскольку он не может прокручиваться дальше, использовать этот жест смахивания вместо направить на нижний лист, чтобы он начал расширяться. По сути, приложение должно вести себя так, как если бы мой жест был выполнен на нижнем листе, а не в режиме прокрутки.

Моей первой мыслью было посмотреть на NestedScrollView.OnScrollChangeListener, но я не мог заставить его работать, так как он перестает запускаться на границах прокручиваемого содержимого (в конце концов, он прослушивает прокрутку изменения, и ничего не меняется когда ты на краю).

Я также попытался создать собственный подкласс BottomSheetBehavior и попытаться переопределить onInterceptTouchEvent(), но столкнулся с проблемами в двух местах. Во-первых, я хочу захватывать события только тогда, когда одноуровневое представление прокрутки находится внизу, и я мог бы это сделать, но теперь я фиксировал все события (что делало невозможным прокрутку одноуровневого объекта обратно вверх). Во-вторых, поле private mIgnoreEvents внутри BottomSheetBehavior блокировало фактическое расширение нижнего листа. Я могу использовать отражение, чтобы получить доступ к этому полю и не дать ему блокировать меня, но это плохо.

Изменить: я потратил еще немного времени на изучение AppBarLayout.ScrollingViewBehavior, так как это, казалось, было довольно близко к тому, что я хотел (он преобразует смахивания в одном представлении в изменение размера в другом), но, похоже, вручную устанавливает смещение пиксель за пикселем и нижние листы не совсем так себя вести.


person Ben P.    schedule 23.02.2018    source источник
comment
Вопрос: Когда NestedScrollView прокручивается полностью вниз и нижний лист свернут, смахивание вверх по экрану открывает нижний лист. Что вы ожидаете, когда пользователь изменит курс и проведет пальцем в нижнюю часть экрана, не отрывая пальца от NestedScrollView? Должен ли нижний лист развернуться и схлопнуться, или нужно NestedScrollView прокрутить?   -  person Cheticamp    schedule 01.03.2018
comment
@Cheticamp - при одном жесте в идеальном мире нижний лист рухнул бы. Я бы хотел, чтобы жест вел себя так, как если бы палец пользователя находился на самом нижнем листе. Если, однако, есть способ сделать это только для открытия листа, но изменение направления отменяет этот жест и вместо этого прокручивает NestedScrollView, это было бы приемлемо. Просто не оптимально.   -  person Ben P.    schedule 01.03.2018


Ответы (1)


Это обновление с более общим решением. Теперь он обрабатывает скрытие и "пропуск сворачивания" стандартного поведения при просмотре снизу.

В следующем решении используется пользовательский BottomSheetBehavior. Вот короткое видео небольшого приложения, основанного на опубликованном вами приложении с настроенным поведением:

введите описание изображения здесь

MyBottomSheetBehavior расширяет BottomSheetBehavior и выполняет тяжелую работу для достижения желаемого поведения. MyBottomSheetBehavior пассивен, пока NestedScrollView не достигнет своего нижнего предела прокрутки. onNestedScroll() указывает, что предел был достигнут, и смещает нижний лист на величину прокрутки, пока не будет достигнуто смещение для полностью развернутого нижнего листа. Это логика расширения.

Когда нижний лист отделяется от нижней части, нижний лист считается «захваченным» до тех пор, пока пользователь не уберет палец с экрана. При захвате нижнего листа onNestPreScroll() перемещает нижний лист к нижней части экрана. Это рушащаяся логика.

BottomSheetBehavior не предоставляет средств для управления нижним листом, кроме как полностью свернуть или развернуть его. Другая необходимая функциональность заблокирована частными функциями пакета базового поведения. Чтобы обойти это, я создал новый класс с именем BottomSheetBehaviorAccessors, который разделяет пакет (android.support.design.widget) со стандартным поведением. Этот класс предоставляет доступ к некоторым частным методам пакета, которые используются в новом поведении.

MyBottomSheetBehavior также поддерживает обратные вызовы BottomSheetBehavior.BottomSheetCallback и другие общие функции.

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>
person Cheticamp    schedule 02.03.2018
comment
Спасибо за ответ. Это, безусловно, отвечает на мой вопрос, как указано ... к сожалению, нарушение BottomSheetCallback неприемлемо для моего реального приложения. Однако я надеюсь, что смогу адаптировать ваш ответ. - person Ben P.; 03.03.2018
comment
@BenP. Обновленный ответ. - person Cheticamp; 04.03.2018
comment
@BenP. Последнее обновление поведения. Он перестал сообщать о состоянии прокрутки и теперь обрабатывать как обратные вызовы, так и параметры скрытия и пропуска свернуты. - person Cheticamp; 07.03.2018
comment
@Cheticamp Что такое com.example.bottomsheetoverscroll.MyNestedScrollView? - person Charles; 05.03.2020
comment
@Charles Я думаю, что это было из более ранней версии ответа. В финальной версии есть только NestedScrollView, а не пользовательский. - person Cheticamp; 05.03.2020
comment
@Cheticamp, знаете ли вы, как заставить его работать с помощью библиотеки материалов? setStateInternal и dispatchOnSlide больше не являются общедоступными. - person rewgoes; 03.09.2020