feat(android): NowPlaying drag-down-to-dismiss + close button
Audit v2 missing UX: full player had no way to escape except system back. Two additions: - Chevron-down close button as the Scaffold topBar navigation icon. Transparent background so the gradient/cover behind shows through. - Vertical drag-down on the whole screen pops back past a 200px threshold, mirroring Flutter's modal-page gesture. Horizontal scrubs on the slider still work because the gesture detector is specifically vertical-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,14 @@ 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.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.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
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
|
||||||
@@ -38,6 +44,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.ChevronDown
|
||||||
import com.composables.icons.lucide.ListMusic
|
import com.composables.icons.lucide.ListMusic
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
@@ -64,6 +71,12 @@ private const val MS_PER_SECOND = 1000L
|
|||||||
private const val SECONDS_PER_MINUTE = 60L
|
private const val SECONDS_PER_MINUTE = 60L
|
||||||
private const val POP_GRACE_MS = 500L
|
private const val POP_GRACE_MS = 500L
|
||||||
|
|
||||||
|
// Vertical drag-down threshold (in pixels) past which the gesture
|
||||||
|
// pops the player. Matches Flutter's 80px threshold in spirit;
|
||||||
|
// using pointer-input pixels not dp because gesture velocity also
|
||||||
|
// participates in the dismiss decision.
|
||||||
|
private const val DRAG_DISMISS_THRESHOLD_PX = 200f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen player. Shows the cover (placeholder until AlbumRef join
|
* Full-screen player. Shows the cover (placeholder until AlbumRef join
|
||||||
* lands), title + artist + album, a scrubber, and a transport row
|
* lands), title + artist + album, a scrubber, and a transport row
|
||||||
@@ -99,7 +112,8 @@ fun NowPlayingScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController),
|
||||||
|
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
) { inner ->
|
) { inner ->
|
||||||
@@ -113,6 +127,50 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.).
|
||||||
|
*/
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
totalDragY = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = { totalDragY = 0f },
|
||||||
|
onVerticalDrag = { _, delta -> totalDragY += delta },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NowPlayingTopBar(onClose: () -> Unit) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.ChevronDown,
|
||||||
|
contentDescription = "Close player",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NowPlayingBody(
|
private fun NowPlayingBody(
|
||||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||||
|
|||||||
Reference in New Issue
Block a user