material-components / material-components/material-components-android

[BottomSheet] Velocity-based dismiss does not work when fling originates from nested scrolling child

Open
#5,076 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Java
Stars
17.4k
Forks
3.2k
PR merge metrics
No merged PRs in 30d

Description

**Description:**

`BottomSheetBehavior` fling-to-dismiss doesn't work when the sheet contains a nested scrolling child (RecyclerView, ScrollView, etc.). Works fine for direct drags. `isHideable` is true in both cases.

There are two bugs here and they're independent of each other.

**1. `activePointerId` gets reset before `onStopNestedScroll` reads it**

`onInterceptTouchEvent(ACTION_UP)` resets `activePointerId` to `INVALID_POINTER_ID` ([line 665](https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java#L665)). This fires before the child processes `ACTION_UP` and calls `stopNestedScroll()`. So when `onStopNestedScroll` eventually calls `getYVelocity()` ([line 887](https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java#L887)), it queries `velocityTracker.getYVelocity(-1)` and gets 0.

**2. `onNestedPreFling` discards velocity**

`onNestedPreFling` ([line 948](https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java#L948)) gets the real fling velocity as a parameter, consumes it by returning `true`, and never stores it anywhere.

Between these two, `shouldHide()` ([line 1707](https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java#L1707)) always sees velocity 0 on the nested scroll path. It falls back to position only (`Math.abs(child.getTop() - collapsedOffset) / peek > 0.5`). Fast short flings don't move the sheet far enough to pass that, so it snaps back to collapsed.

For direct touches, `ViewDragHelper` handles everything and passes velocity to `onViewReleased()` directly. That path works.

### Code paths

Direct touch (works):

```
onTouchEvent
→ ViewDragHelper.processTouchEvent
→ dragCallback.onViewReleased(child, xvel, yvel) // real velocity
→ shouldHide(child, yvel)
→ STATE_HIDDEN
```

Nested scroll (broken):

```
onNestedPreScroll // moves sheet, STATE_DRAGGING
→ onInterceptTouchEvent(ACTION_UP) // activePointerId = -1
→ onNestedPreFling(velocityY) // real velocity, discarded
→ onStopNestedScroll
→ getYVelocity() // pointer -1 → returns 0
→ shouldHide(child, 0.0) // position-only → false
→ STATE_COLLAPSED
```

| Gesture | Direct touch | Nested scroll |
|---|---|---|
| Slow drag past midpoint | Hides | Hides |
| Fast short fling down | Hides | Settles to collapsed |
| `skipCollapsed = true` | Hides | Hides |

Position-based dismiss still works. Only velocity-based dismiss is broken through nested scroll.

**Expected behavior:**

A fast downward fling on scrollable content (scrolled to top) should dismiss the sheet the same way it does on non-scrollable content. `shouldHide()` projects position using `child.getTop() + yvel * hideFriction`.

**Source code:**

`onInterceptTouchEvent` resets `activePointerId` before `onStopNestedScroll` runs:

```java
// line 661
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
touchingScrollingChild = false;
currentTouchedScrollChildRef = null;
activePointerId = MotionEvent.INVALID_POINTER_ID; // reset too early
```

`onNestedPreFling` receives velocity but doesn't keep it:

```java
// line 948
public boolean onNestedPreFling(..., float velocityX, float velocityY) {
if (isNestedScrollingCheckEnabled() && hasScrollingChild()) {
return isViewScrollingChild(target)
&& ((state != STATE_EXPANDED && !draggableOnNestedScrollLastDragIgnored)
|| super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY));
// velocityY not stored
} else {
return false;
}
}
```

`onStopNestedScroll` tries to use the velocity:

```java
// line 887
} else if (hideable && shouldHide(child, getYVelocity())) {
targetState = STATE_HIDDEN;
```

`getYVelocity` queries with the dead pointer ID:

```java
// line 1954
private float getYVelocity() {
if (velocityTracker == null) {
return 0;
}
velocityTracker.computeCurrentVelocity(1000, maximumVelocity);
return velocityTracker.getYVelocity(activePointerId); // -1 → 0
}
```

Debug logs from a subclassed `BottomSheetBehavior` with logging in `onNestedPreFling` and `onStopNestedScroll`:

```
Nested scroll path (fling on scrollable content at top of scroll):
[onNestedPreFling] velocity: 2121 dp/s DOWN, state: STATE_DRAGGING, isHideable: true
[onNestedPreFling] super returned: true (consumed)
[onStopNestedScroll] state: STATE_DRAGGING, sheetTop: 699
[onStopNestedScroll] after super — state: STATE_SETTLING ← not STATE_HIDDEN

Direct touch path (same gesture on non-scrollable content):
[onTouchEvent] ACTION_UP velocity: 3328 dp/s DOWN, state: STATE_DRAGGING, isHideable: true, sheetTop: 1353
[onTouchEvent] STATE_DRAGGING → STATE_SETTLING → STATE_HIDDEN ← works
```

**Android API version:** 35

**Material Library version:** 1.12.0

**Device:** Samsung S21+

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in BottomSheetBehavior.java with onInterceptTouchEvent, onNestedPreFling, onStopNestedScroll, getYVelocity, and shouldHide; trace the nested-scroll fling path and compare it with the direct-touch path. Done means a fast downward fling from a nested scrolling child reaches the hidden state like a direct drag, while position-based dismiss behavior remains unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
mobile-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.