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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -30,7 +34,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -122,8 +125,11 @@ fun NowPlayingScreen(
return return
} }
val dominant = rememberDominantColor(track.coverUrl) val dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() },
)
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController), modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) }, topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent, 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 * Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a
* downward drag past the threshold pops the screen, returning the * downward over-scroll past the threshold pops the screen.
* user to whichever shell route launched NowPlaying. Tap, swipe-up, *
* and horizontal drags pass through to the underlying content * Implementation note: the body Column uses verticalScroll, which
* (slider scrub, etc.). * 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 @Composable
private fun Modifier.nowPlayingDragDismiss(navController: NavHostController): Modifier = private fun rememberDragDismissConnection(
this.pointerInput(Unit) { onDismiss: () -> Unit,
var totalDragY = 0f thresholdPx: Float = DRAG_DISMISS_THRESHOLD_PX,
detectVerticalDragGestures( ): NestedScrollConnection = remember(onDismiss, thresholdPx) {
onDragStart = { totalDragY = 0f }, object : NestedScrollConnection {
onDragEnd = { private var accumulated = 0f
if (totalDragY > DRAG_DISMISS_THRESHOLD_PX) {
navController.popBackStack() 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 return Offset(0f, available.y)
}, }
onDragCancel = { totalDragY = 0f }, if (consumed.y != 0f || available.y < 0f) accumulated = 0f
onVerticalDrag = { _, delta -> totalDragY += delta }, return Offset.Zero
) }
override suspend fun onPreFling(available: Velocity): Velocity {
accumulated = 0f
return Velocity.Zero
}
} }
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable