From 2b9b4c1db64ef38164680037883d7056fc95e1be Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:09:59 -0400 Subject: [PATCH] fix(android): NowPlaying drag-down dismiss via NestedScroll (#2) 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). --- .../minstrel/player/ui/NowPlayingScreen.kt | 69 +++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index a1a7af98..074a8e3e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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