fix(android): NowPlaying drag-down dismiss via NestedScroll (#2)
android / Build + lint + test (push) Failing after 1m25s

The previous detectVerticalDragGestures modifier on the Scaffold
never fired because the body Column applies verticalScroll, which
wins the touch-slop competition for every vertical drag delta
before the outer detector sees it. User-visible symptom: swiping
down on the NowPlaying screen did nothing.

Replace with a NestedScrollConnection. verticalScroll offers its
over-scroll deltas to the nearest ancestor connection via the
standard nested-scroll protocol; when the column is at the top of
its scroll range, downward drag deltas surface in onPostScroll
unconsumed (available.y > 0). Accumulate those toward a 200 px
threshold and pop the back stack.

- Upward over-scroll or any consumed delta resets the accumulator
  so a partial drag-down followed by drag-up doesn't latch.
- onPreFling resets on lift-off so a lazy swipe doesn't dismiss
  later after the user has released.
- Slider thumb retains its own pointerInput and is not affected.
- Source filter (UserInput) ignores nested-scroll-driven
  animations (e.g. flings from inner scrollables).
This commit is contained in:
2026-06-01 19:09:59 -04:00
parent 5366cd5634
commit 2b9b4c1db6
@@ -22,7 +22,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.TopAppBar
@@ -30,7 +34,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -122,8 +125,11 @@ fun NowPlayingScreen(
return
}
val dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() },
)
Scaffold(
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController),
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
@@ -161,27 +167,50 @@ private const val DOMINANT_TINT_MID_STOP = 0.45f
/**
* Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a
* downward drag past the threshold pops the screen, returning the
* user to whichever shell route launched NowPlaying. Tap, swipe-up,
* and horizontal drags pass through to the underlying content
* (slider scrub, etc.).
* downward over-scroll past the threshold pops the screen.
*
* Implementation note: the body Column uses verticalScroll, which
* eats every vertical drag delta before a top-level
* detectVerticalDragGestures can see it. NestedScroll is the
* right primitive here — verticalScroll offers its over-scroll
* deltas to the nearest ancestor connection, so we accumulate
* downward over-scroll (available.y > 0 means the scrollable
* couldn't consume it because it's at the top) and pop on
* threshold. The Slider keeps its own pointerInput and is
* unaffected.
*/
@Composable
private fun Modifier.nowPlayingDragDismiss(navController: NavHostController): Modifier =
this.pointerInput(Unit) {
var totalDragY = 0f
detectVerticalDragGestures(
onDragStart = { totalDragY = 0f },
onDragEnd = {
if (totalDragY > DRAG_DISMISS_THRESHOLD_PX) {
navController.popBackStack()
private fun rememberDragDismissConnection(
onDismiss: () -> Unit,
thresholdPx: Float = DRAG_DISMISS_THRESHOLD_PX,
): NestedScrollConnection = remember(onDismiss, thresholdPx) {
object : NestedScrollConnection {
private var accumulated = 0f
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
if (available.y > 0f) {
accumulated += available.y
if (accumulated >= thresholdPx) {
accumulated = 0f
onDismiss()
}
totalDragY = 0f
},
onDragCancel = { totalDragY = 0f },
onVerticalDrag = { _, delta -> totalDragY += delta },
)
return Offset(0f, available.y)
}
if (consumed.y != 0f || available.y < 0f) accumulated = 0f
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
accumulated = 0f
return Velocity.Zero
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable