feat(nav): swipeable pager for the 4 tabs (Spotify-style)
- New TabsScreen: HorizontalPager (Stations · Library · Streams · Playlists) synced with a shared bottom NavigationBar; swipes follow the finger, tab taps animate to page, neighbors pre-composed so pages and their ViewModels survive page changes - Light haptic tick when a new page settles (skipped on first frame) - Mini-player sits above the nav bar inside the pager surface: playback controls reachable from every tab - Hub tab buttons navigate to tabs?index=N; playlist selection from the Playlists page animates to Library and deep-links the playlist - All previous routes preserved (stations/library/streams/playlists still resolve); detail transitions now direction-aware via Motion.kt forward/backward params
This commit is contained in:
parent
eb32aed07c
commit
d612ba6f49
4 changed files with 171 additions and 12 deletions
|
|
@ -38,6 +38,25 @@ fun NavGraph(navController: NavHostController) {
|
||||||
startDestination = Screen.Player.route,
|
startDestination = Screen.Player.route,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
|
composable(
|
||||||
|
route = Screen.Tabs.route,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("index") { type = NavType.IntType; defaultValue = 0 },
|
||||||
|
navArgument("playlistId") { type = NavType.LongType; defaultValue = -1L },
|
||||||
|
),
|
||||||
|
enterTransition = { detailEnter(reduce) },
|
||||||
|
exitTransition = { detailExit(reduce) },
|
||||||
|
popEnterTransition = { detailPopEnter(reduce) },
|
||||||
|
popExitTransition = { detailPopExit(reduce) },
|
||||||
|
) { backStackEntry ->
|
||||||
|
val args = backStackEntry.arguments
|
||||||
|
TabsScreen(
|
||||||
|
initialIndex = args?.getInt("index") ?: 0,
|
||||||
|
initialPlaylistId = args?.getLong("playlistId")?.takeIf { it > 0 },
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(
|
composable(
|
||||||
Screen.Player.route,
|
Screen.Player.route,
|
||||||
enterTransition = { hubEnter(reduce) },
|
enterTransition = { hubEnter(reduce) },
|
||||||
|
|
@ -46,10 +65,10 @@ fun NavGraph(navController: NavHostController) {
|
||||||
popExitTransition = { hubExit(reduce) },
|
popExitTransition = { hubExit(reduce) },
|
||||||
) {
|
) {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
|
onNavigateToStations = { navController.navigate(Screen.Tabs.createRoute(0)) },
|
||||||
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
|
onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(1)) },
|
||||||
onNavigateToStreams = { navController.navigate(Screen.Streams.route) },
|
onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(2)) },
|
||||||
onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) },
|
onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(3)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,8 @@ sealed class Screen(val route: String) {
|
||||||
data object Stations : Screen("stations")
|
data object Stations : Screen("stations")
|
||||||
data object Streams : Screen("streams")
|
data object Streams : Screen("streams")
|
||||||
data object Playlists : Screen("playlists")
|
data object Playlists : Screen("playlists")
|
||||||
|
data object Tabs : Screen("tabs?index={index}&playlistId={playlistId}") {
|
||||||
|
fun createRoute(index: Int = 0, playlistId: Long? = null): String =
|
||||||
|
"tabs?index=$index" + (playlistId?.let { "&playlistId=$it" } ?: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
136
app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
Normal file
136
app/src/main/java/com/rada/ui/navigation/TabsScreen.kt
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
package com.rada.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||||
|
import androidx.compose.material.icons.filled.LibraryMusic
|
||||||
|
import androidx.compose.material.icons.filled.Podcasts
|
||||||
|
import androidx.compose.material.icons.filled.Radio
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import com.rada.ui.library.LibraryScreen
|
||||||
|
import com.rada.ui.player.NowPlayingBar
|
||||||
|
import com.rada.ui.playlists.PlaylistManagerScreen
|
||||||
|
import com.rada.ui.stations.StationBrowserScreen
|
||||||
|
import com.rada.ui.streams.StreamsScreen
|
||||||
|
import com.rada.ui.theme.LocalTokens
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four peer tabs as a swipeable pager (Spotify's horizontal structure):
|
||||||
|
* Stations · Library · Streams · Playlists. Synced with the bottom bar;
|
||||||
|
* swiping follows the finger, tab taps animate. The mini-player sits above
|
||||||
|
* the nav bar so playback controls are always reachable.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TabsScreen(
|
||||||
|
initialIndex: Int,
|
||||||
|
initialPlaylistId: Long? = null,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onHome: () -> Unit,
|
||||||
|
) {
|
||||||
|
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 3)) { 4 }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
// Deep-link target for Library (playlist picker → open that playlist).
|
||||||
|
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
|
||||||
|
|
||||||
|
// Light tick as a new page settles — tab changes are tactile, not silent.
|
||||||
|
// Skipped on first composition (nothing actually changed).
|
||||||
|
var firstSettle by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(pagerState.settledPage) {
|
||||||
|
if (firstSettle) firstSettle = false
|
||||||
|
else haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
bottomBar = {
|
||||||
|
Column {
|
||||||
|
NowPlayingBar(
|
||||||
|
onNavigateToPlayer = onHome,
|
||||||
|
)
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
|
||||||
|
) {
|
||||||
|
val items = listOf(
|
||||||
|
Triple("Stations", Icons.Default.Radio, 0),
|
||||||
|
Triple("Library", Icons.Default.LibraryMusic, 1),
|
||||||
|
Triple("Streams", Icons.Default.Podcasts, 2),
|
||||||
|
Triple("Playlists", Icons.AutoMirrored.Filled.PlaylistAdd, 3),
|
||||||
|
)
|
||||||
|
items.forEach { (label, icon, page) ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = pagerState.settledPage == page,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(page) }
|
||||||
|
},
|
||||||
|
icon = { Icon(icon, contentDescription = label) },
|
||||||
|
label = { Text(label) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||||
|
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = padding.calculateBottomPadding()),
|
||||||
|
// Keep neighbors composed so swiping shows real content instantly
|
||||||
|
// and their ViewModels survive page changes.
|
||||||
|
beyondViewportPageCount = 1,
|
||||||
|
) { page ->
|
||||||
|
when (page) {
|
||||||
|
0 -> StationBrowserScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
onStationSelected = { /* stay on tab; bar shows the station */ },
|
||||||
|
)
|
||||||
|
1 -> LibraryScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
initialPlaylistId = libraryPlaylistId,
|
||||||
|
)
|
||||||
|
2 -> StreamsScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
onStreamSelected = { },
|
||||||
|
)
|
||||||
|
3 -> PlaylistManagerScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
onHome = onHome,
|
||||||
|
onPlaylistSelected = { playlistId ->
|
||||||
|
libraryPlaylistId = playlistId
|
||||||
|
scope.launch { pagerState.animateScrollToPage(1) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,24 +61,24 @@ fun hubExit(reduce: Boolean): ExitTransition =
|
||||||
// and pop back to the left, so direction teaches the hierarchy. Slide is a
|
// and pop back to the left, so direction teaches the hierarchy. Slide is a
|
||||||
// quarter-travel, not a full page wipe — fast screens stay legible mid-motion.
|
// quarter-travel, not a full page wipe — fast screens stay legible mid-motion.
|
||||||
|
|
||||||
fun detailEnter(reduce: Boolean): EnterTransition =
|
fun detailEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
|
||||||
if (reduce) fadeIn(tween(ExitFastMs))
|
if (reduce) fadeIn(tween(ExitFastMs))
|
||||||
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { it / 4 } +
|
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) it / 4 else -it / 4 } +
|
||||||
fadeIn(tween(StateMs, easing = MotionArrival))
|
fadeIn(tween(StateMs, easing = MotionArrival))
|
||||||
|
|
||||||
fun detailExit(reduce: Boolean): ExitTransition =
|
fun detailExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
|
||||||
if (reduce) fadeOut(tween(ExitFastMs))
|
if (reduce) fadeOut(tween(ExitFastMs))
|
||||||
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { -it / 8 } +
|
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) -it / 8 else it / 8 } +
|
||||||
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
||||||
|
|
||||||
fun detailPopEnter(reduce: Boolean): EnterTransition =
|
fun detailPopEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
|
||||||
if (reduce) fadeIn(tween(ExitFastMs))
|
if (reduce) fadeIn(tween(ExitFastMs))
|
||||||
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { -it / 4 } +
|
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) -it / 8 else it / 8 } +
|
||||||
fadeIn(tween(StateMs, easing = MotionArrival))
|
fadeIn(tween(StateMs, easing = MotionArrival))
|
||||||
|
|
||||||
fun detailPopExit(reduce: Boolean): ExitTransition =
|
fun detailPopExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
|
||||||
if (reduce) fadeOut(tween(ExitFastMs))
|
if (reduce) fadeOut(tween(ExitFastMs))
|
||||||
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { it / 8 } +
|
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) it / 4 else -it / 4 } +
|
||||||
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
fadeOut(tween(ExitFastMs, easing = MotionExit))
|
||||||
|
|
||||||
// ── Now-playing bar ───────────────────────────────────────────────────────
|
// ── Now-playing bar ───────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue