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,
|
||||
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(
|
||||
Screen.Player.route,
|
||||
enterTransition = { hubEnter(reduce) },
|
||||
|
|
@ -46,10 +65,10 @@ fun NavGraph(navController: NavHostController) {
|
|||
popExitTransition = { hubExit(reduce) },
|
||||
) {
|
||||
PlayerScreen(
|
||||
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
|
||||
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
|
||||
onNavigateToStreams = { navController.navigate(Screen.Streams.route) },
|
||||
onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) },
|
||||
onNavigateToStations = { navController.navigate(Screen.Tabs.createRoute(0)) },
|
||||
onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(1)) },
|
||||
onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(2)) },
|
||||
onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(3)) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
|
|
|||
|
|
@ -10,4 +10,8 @@ sealed class Screen(val route: String) {
|
|||
data object Stations : Screen("stations")
|
||||
data object Streams : Screen("streams")
|
||||
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
|
||||
// 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))
|
||||
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))
|
||||
|
||||
fun detailExit(reduce: Boolean): ExitTransition =
|
||||
fun detailExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
|
||||
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))
|
||||
|
||||
fun detailPopEnter(reduce: Boolean): EnterTransition =
|
||||
fun detailPopEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
|
||||
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))
|
||||
|
||||
fun detailPopExit(reduce: Boolean): ExitTransition =
|
||||
fun detailPopExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
|
||||
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))
|
||||
|
||||
// ── Now-playing bar ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue