diff --git a/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java b/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java index afd716bce7f..fa1ec819cce 100644 --- a/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java +++ b/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java @@ -277,6 +277,7 @@ void onLayout(@NonNull View bottomSheet) {} boolean hideable; private boolean skipCollapsed; + private boolean collapsible = true; private boolean draggable = true; @@ -552,6 +553,7 @@ public boolean onLayoutChild( fitToContentsOffset = max(0, parentHeight - childHeight); calculateHalfExpandedOffset(); calculateCollapsedOffset(); + onOffsetsUpdated(); if (state == STATE_EXPANDED) { ViewCompat.offsetTopAndBottom(child, getExpandedOffset()); @@ -725,10 +727,12 @@ public void onNestedPreScroll( consumed[1] = dy; ViewCompat.offsetTopAndBottom(child, -dy); setStateInternal(STATE_DRAGGING); - } else { + } else if (!shouldSkipCollapsed()) { consumed[1] = currentTop - collapsedOffset; ViewCompat.offsetTopAndBottom(child, -consumed[1]); setStateInternal(STATE_COLLAPSED); + } else { + // Do nothing, the bottom sheet should be fixed. } } } @@ -871,6 +875,7 @@ public void setFitToContents(boolean fitToContents) { } // Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents. setStateInternal((this.fitToContents && state == STATE_HALF_EXPANDED) ? STATE_EXPANDED : state); + onOffsetsUpdated(); updateAccessibilityActions(); } @@ -966,6 +971,7 @@ public final void setPeekHeight(int peekHeight, boolean animate) { private void updatePeekHeight(boolean animate) { if (viewRef != null) { calculateCollapsedOffset(); + onOffsetsUpdated(); if (state == STATE_COLLAPSED) { V view = viewRef.get(); if (view != null) { @@ -1204,6 +1210,10 @@ public void setState(@StableState int state) { Log.w(TAG, "Cannot set state: " + state); return; } + if (ensureExpandedStateIfNotCollapsible(state)) { + // setState() will be called again. + return; + } final int finalState; if (state == STATE_HALF_EXPANDED && fitToContents @@ -1278,7 +1288,7 @@ void setStateInternal(@State int state) { return; } this.state = state; - if (state == STATE_COLLAPSED + if ((collapsible && state == STATE_COLLAPSED) || state == STATE_EXPANDED || state == STATE_HALF_EXPANDED || (hideable && state == STATE_HIDDEN)) { @@ -1352,6 +1362,19 @@ private void calculateCollapsedOffset() { } } + private void onOffsetsUpdated() { + collapsible = collapsedOffset != getExpandedOffset(); + ensureExpandedStateIfNotCollapsible(state); + } + + private boolean ensureExpandedStateIfNotCollapsible(@State int state) { + if (!collapsible && state == STATE_COLLAPSED) { + setState(STATE_EXPANDED); + return true; + } + return false; + } + private void calculateHalfExpandedOffset() { this.halfExpandedOffset = (int) (parentHeight * (1 - halfExpandedRatio)); } @@ -1385,7 +1408,7 @@ private void restoreOptionalState(@NonNull SavedState ss) { } boolean shouldHide(@NonNull View child, float yvel) { - if (skipCollapsed) { + if (shouldSkipCollapsed()) { return true; } if (child.getTop() < collapsedOffset) { @@ -1397,6 +1420,10 @@ boolean shouldHide(@NonNull View child, float yvel) { return Math.abs(newTop - collapsedOffset) / (float) peek > HIDE_THRESHOLD; } + private boolean shouldSkipCollapsed() { + return !collapsible || skipCollapsed; + } + @Nullable @VisibleForTesting View findScrollingChild(View view) { diff --git a/tests/javatests/com/google/android/material/bottomsheet/BottomSheetBehaviorTest.java b/tests/javatests/com/google/android/material/bottomsheet/BottomSheetBehaviorTest.java index 0a4897976b7..919cfb6fbbd 100644 --- a/tests/javatests/com/google/android/material/bottomsheet/BottomSheetBehaviorTest.java +++ b/tests/javatests/com/google/android/material/bottomsheet/BottomSheetBehaviorTest.java @@ -433,10 +433,7 @@ public void testSkipCollapsedFullyExpanded() throws Throwable { testSkipCollapsed(); } - private void testSkipCollapsed_smallSwipe(int expectedState, float swipeViewHeightPercentage) - throws Throwable { - getBehavior().setSkipCollapsed(true); - checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed()); + private void testSkipCollapsed_smallSwipe(int expectedState, float swipeViewHeightPercentage) { Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) .perform( DesignViewActions.withCustomConstraints( @@ -476,9 +473,15 @@ private void testSkipCollapsed_smallSwipe(int expectedState, float swipeViewHeig } } + private void setSkipCollapsed() throws Throwable { + getBehavior().setSkipCollapsed(true); + checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed()); + } + @Test @MediumTest public void testSkipCollapsed_smallSwipe_remainsExpanded() throws Throwable { + setSkipCollapsed(); testSkipCollapsed_smallSwipe( BottomSheetBehavior.STATE_EXPANDED, /* swipeViewHeightPercentage = */ 0.5f); } @@ -486,6 +489,7 @@ public void testSkipCollapsed_smallSwipe_remainsExpanded() throws Throwable { @Test @MediumTest public void testSkipCollapsedFullyExpanded_smallSwipe_remainsExpanded() throws Throwable { + setSkipCollapsed(); getBehavior().setFitToContents(false); testSkipCollapsed_smallSwipe( BottomSheetBehavior.STATE_HALF_EXPANDED, /* swipeViewHeightPercentage = */ 0.5f); @@ -494,6 +498,7 @@ public void testSkipCollapsedFullyExpanded_smallSwipe_remainsExpanded() throws T @Test @MediumTest public void testSkipCollapsed_smallSwipePastThreshold_getsHidden() throws Throwable { + setSkipCollapsed(); testSkipCollapsed_smallSwipe( BottomSheetBehavior.STATE_HIDDEN, /* swipeViewHeightPercentage = */ 0.75f); } @@ -501,11 +506,33 @@ public void testSkipCollapsed_smallSwipePastThreshold_getsHidden() throws Throwa @Test @MediumTest public void testSkipCollapsedFullyExpanded_smallSwipePastThreshold_getsHidden() throws Throwable { + setSkipCollapsed(); getBehavior().setFitToContents(false); testSkipCollapsed_smallSwipe( BottomSheetBehavior.STATE_HIDDEN, /* swipeViewHeightPercentage = */ 0.75f); } + private void makeBottomSheetNotCollapsible() { + // Set a peek height that equals to expanded height so it always stays EXPANDED. + getBehavior().setPeekHeight(5000); + } + + @Test + @MediumTest + public void testNotCollapsible_smallSwipe_remainsExpanded() { + makeBottomSheetNotCollapsible(); + testSkipCollapsed_smallSwipe( + BottomSheetBehavior.STATE_EXPANDED, /* swipeViewHeightPercentage = */ 0.5f); + } + + @Test + @MediumTest + public void testNotCollapsible_smallSwipePastThreshold_getsHidden() { + makeBottomSheetNotCollapsible(); + testSkipCollapsed_smallSwipe( + BottomSheetBehavior.STATE_HIDDEN, /* swipeViewHeightPercentage = */ 0.75f); + } + @Test @MediumTest public void testSwipeUpToExpand() { @@ -935,16 +962,22 @@ public void testDynamicContent() throws Throwable { @Test @MediumTest public void testExpandedPeekHeight() throws Throwable { + registerIdlingResourceCallback(); activityTestRule.runOnUiThread( () -> { // Make the peek height as tall as the bottom sheet. - BottomSheetBehavior behavior = getBehavior(); - behavior.setPeekHeight(getBottomSheet().getHeight()); - assertThat(behavior.getState(), is(BottomSheetBehavior.STATE_COLLAPSED)); + getBehavior().setPeekHeight(getBottomSheet().getHeight()); }); - // Both of these will not animate the sheet , but the state should be changed. + Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); + assertThat(getBehavior().getState(), is(BottomSheetBehavior.STATE_EXPANDED)); + unregisterIdlingResourceCallback(); + // Both of these will not animate the sheet, and the state should be fixed as EXPANDED. checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed()); - checkSetState(BottomSheetBehavior.STATE_COLLAPSED, ViewMatchers.isDisplayed()); + checkSetState( + BottomSheetBehavior.STATE_COLLAPSED, + BottomSheetBehavior.STATE_EXPANDED, + ViewMatchers.isDisplayed()); } @Test @@ -962,12 +995,17 @@ public void testFindScrollingChildEnabled() { } private void checkSetState(final int state, Matcher matcher) throws Throwable { + checkSetState(state, state, matcher); + } + + private void checkSetState( + final int stateToSet, final int stateToExpect, Matcher matcher) throws Throwable { registerIdlingResourceCallback(); try { - activityTestRule.runOnUiThread(() -> getBehavior().setState(state)); + activityTestRule.runOnUiThread(() -> getBehavior().setState(stateToSet)); Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet)) .check(ViewAssertions.matches(matcher)); - assertThat(getBehavior().getState(), is(state)); + assertThat(getBehavior().getState(), is(stateToExpect)); assertAccessibilityActions(getBehavior(), getBottomSheet()); } finally { unregisterIdlingResourceCallback(); diff --git a/tests/javatests/com/google/android/material/bottomsheet/BottomSheetDialogTest.java b/tests/javatests/com/google/android/material/bottomsheet/BottomSheetDialogTest.java index 39d0ddd8719..66c4c2cd37f 100644 --- a/tests/javatests/com/google/android/material/bottomsheet/BottomSheetDialogTest.java +++ b/tests/javatests/com/google/android/material/bottomsheet/BottomSheetDialogTest.java @@ -188,6 +188,9 @@ public void testHideThenShow() throws Throwable { final DialogInterface.OnCancelListener onCancelListener = mock(DialogInterface.OnCancelListener.class); showDialog(); + onView(ViewMatchers.withId(R.id.design_bottom_sheet)) + .perform(setShortPeekHeight()) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); dialog.setOnCancelListener(onCancelListener); onView(ViewMatchers.withId(R.id.design_bottom_sheet)) .perform(setState(BottomSheetBehavior.STATE_HIDDEN)); @@ -205,6 +208,33 @@ public void testHideThenShow() throws Throwable { .onStateChanged(any(View.class), eq(BottomSheetBehavior.STATE_COLLAPSED)); } + @Test + @MediumTest + public void testHideThenShowNotCollapsible() throws Throwable { + // Hide the bottom sheet and wait for the dialog to be canceled. + final DialogInterface.OnCancelListener onCancelListener = + mock(DialogInterface.OnCancelListener.class); + showDialog(); + onView(ViewMatchers.withId(R.id.design_bottom_sheet)) + .perform(setTallPeekHeight()) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); + dialog.setOnCancelListener(onCancelListener); + onView(ViewMatchers.withId(R.id.design_bottom_sheet)) + .perform(setState(BottomSheetBehavior.STATE_HIDDEN)); + verify(onCancelListener, timeout(3000)).onCancel(any(DialogInterface.class)); + // Reshow the same dialog instance and wait for the bottom sheet to be collapsed. + final BottomSheetBehavior.BottomSheetCallback callback = + mock(BottomSheetBehavior.BottomSheetCallback.class); + BottomSheetBehavior.from(dialog.findViewById(R.id.design_bottom_sheet)) + .addBottomSheetCallback(callback); + // Show the same dialog again. + activityTestRule.runOnUiThread(() -> dialog.show()); + verify(callback, timeout(3000)) + .onStateChanged(any(View.class), eq(BottomSheetBehavior.STATE_SETTLING)); + verify(callback, timeout(3000)) + .onStateChanged(any(View.class), eq(BottomSheetBehavior.STATE_EXPANDED)); + } + private void showDialog() throws Throwable { activityTestRule.runOnUiThread( () -> { @@ -239,6 +269,26 @@ public void perform(UiController uiController, View view) { }; } + private static ViewAction setShortPeekHeight() { + return new ViewAction() { + @Override + public Matcher getConstraints() { + return ViewMatchers.isDisplayed(); + } + + @Override + public String getDescription() { + return "set tall peek height"; + } + + @Override + public void perform(UiController uiController, View view) { + BottomSheetBehavior behavior = BottomSheetBehavior.from(view); + behavior.setPeekHeight(view.getHeight() / 2); + } + }; + } + private static ViewAction setState(@BottomSheetBehavior.State final int state) { return new ViewAction() { @Override