fix(android): NowPlaying drag-down dismiss via NestedScroll (#2)
android / Build + lint + test (push) Failing after 1m25s
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user