@@ -12,24 +12,21 @@ import androidx.compose.foundation.layout.height
1212import androidx.compose.foundation.layout.padding
1313import androidx.compose.foundation.layout.size
1414import androidx.compose.foundation.layout.width
15- import androidx.compose.foundation.focusGroup
1615import androidx.compose.foundation.shape.RoundedCornerShape
1716import androidx.compose.runtime.Composable
1817import androidx.compose.runtime.getValue
19- import androidx.compose.runtime.mutableIntStateOf
20- import androidx.compose.runtime.mutableStateOf
21- import androidx.compose.runtime.remember
22- import androidx.compose.runtime.saveable.rememberSaveable
23- import androidx.compose.runtime.setValue
2418import androidx.compose.ui.Alignment
2519import androidx.compose.ui.Modifier
2620import androidx.compose.ui.draw.clip
27- import androidx.compose.ui.focus.focusProperties
2821import androidx.compose.ui.graphics.Color
29- import androidx.compose.ui.graphics.graphicsLayer
3022import androidx.compose.ui.res.painterResource
3123import androidx.compose.ui.unit.dp
32- import androidx.compose.ui.zIndex
24+ import androidx.navigation.NavController
25+ import androidx.navigation.NavGraph.Companion.findStartDestination
26+ import androidx.navigation.compose.NavHost
27+ import androidx.navigation.compose.composable
28+ import androidx.navigation.compose.currentBackStackEntryAsState
29+ import androidx.navigation.compose.rememberNavController
3330import androidx.tv.material3.ClickableSurfaceDefaults
3431import androidx.tv.material3.ExperimentalTvMaterial3Api
3532import androidx.tv.material3.Icon
@@ -39,14 +36,27 @@ import app.pwhs.tv.presentation.manage.ManageScreen
3936import app.pwhs.tv.presentation.receive.ReceiveScreen
4037import app.pwhs.tv.presentation.settings.SettingsScreen
4138
39+ /* * Top-level destinations reachable from the side rail. */
40+ private object TvRoute {
41+ const val RECEIVE = " receive"
42+ const val MANAGE = " manage"
43+ const val SETTINGS = " settings"
44+ }
45+
4246/* *
43- * Top-level TV shell: A completely static side Navigation Rail.
44- * Extremely lightweight and performant for low-end TV hardware.
47+ * Top-level TV shell: a static side Navigation Rail driving a Navigation Compose [NavHost].
48+ *
49+ * Tabs are switched with the bottom-nav idiom (`saveState`/`restoreState` + `launchSingleTop`),
50+ * so a destination's back-stack entry — and its ViewModel — survives while you're on another tab.
51+ * The heavy data (installed apps, local-APK scan) is loaded once by the ViewModels rather than on
52+ * every visit, so returning to a tab restores instantly instead of re-querying and flashing a
53+ * loading state — that reload-on-every-switch is what made navigation feel janky.
4554 */
4655@Composable
4756fun TvApp (modifier : Modifier = Modifier ) {
48- // Survives the locale/config-change recreate so the user stays on their current tab.
49- var tab by rememberSaveable { mutableIntStateOf(0 ) }
57+ val navController = rememberNavController()
58+ val backStackEntry by navController.currentBackStackEntryAsState()
59+ val currentRoute = backStackEntry?.destination?.route ? : TvRoute .RECEIVE
5060
5161 Box (
5262 modifier = modifier.fillMaxSize().background(MaterialTheme .colorScheme.surface),
@@ -78,73 +88,50 @@ fun TvApp(modifier: Modifier = Modifier) {
7888 Spacer (Modifier .height(32 .dp))
7989
8090 NavigationItem (
81- selected = tab == 0 ,
82- onClick = { tab = 0 },
91+ selected = currentRoute == TvRoute . RECEIVE ,
92+ onClick = { navController.switchTab( TvRoute . RECEIVE ) },
8393 iconRes = R .drawable.ic_apk_install
8494 )
8595 Spacer (Modifier .height(12 .dp))
8696 NavigationItem (
87- selected = tab == 1 ,
88- onClick = { tab = 1 },
97+ selected = currentRoute == TvRoute . MANAGE ,
98+ onClick = { navController.switchTab( TvRoute . MANAGE ) },
8999 iconRes = R .drawable.ic_delete
90100 )
91101 Spacer (Modifier .height(12 .dp))
92102 NavigationItem (
93- selected = tab == 2 ,
94- onClick = { tab = 2 },
103+ selected = currentRoute == TvRoute . SETTINGS ,
104+ onClick = { navController.switchTab( TvRoute . SETTINGS ) },
95105 iconRes = R .drawable.ic_setting
96106 )
97107 }
98108
99- // Content Area — all three destinations stay composed once first visited (keep-alive),
100- // so switching tabs is an alpha/focus toggle instead of a full teardown + rebuild +
101- // data reload. That rebuild-and-reload on every switch is what made navigation feel
102- // janky on low-end TV hardware; here the LaunchedEffect keys never reset, so the app
103- // list / QR / storage stats are computed once and simply shown again.
109+ // Content Area
104110 Box (Modifier .weight(1f ).fillMaxHeight()) {
105- ScreenSlot (active = tab == 0 ) {
106- ReceiveScreen (active = tab == 0 , modifier = Modifier .fillMaxSize())
107- }
108- ScreenSlot (active = tab == 1 ) {
109- ManageScreen (active = tab == 1 , modifier = Modifier .fillMaxSize())
110- }
111- ScreenSlot (active = tab == 2 ) {
112- SettingsScreen (modifier = Modifier .fillMaxSize())
111+ NavHost (
112+ navController = navController,
113+ startDestination = TvRoute . RECEIVE ,
114+ modifier = Modifier .fillMaxSize(),
115+ ) {
116+ composable( TvRoute . RECEIVE ) { ReceiveScreen (modifier = Modifier .fillMaxSize()) }
117+ composable( TvRoute . MANAGE ) { ManageScreen (modifier = Modifier .fillMaxSize()) }
118+ composable( TvRoute . SETTINGS ) { SettingsScreen (modifier = Modifier .fillMaxSize()) }
113119 }
114120 }
115121 }
116122 }
117123}
118124
119125/* *
120- * Keeps a destination's composition — and therefore its loaded data, scroll position and focus
121- * state — alive across tab switches. The slot composes its [content] the first time it becomes
122- * [active] (lazy, so unvisited tabs cost nothing at startup) and never disposes it afterwards.
123- *
124- * When inactive it is drawn fully transparent and made focus-inert: [canFocus] is false and any
125- * D-pad attempt to enter the group is cancelled, so focus can't wander into an off-screen
126- * destination. (Explicit `requestFocus()` calls still pierce this, which is why the screens gate
127- * their own focus requests on an `active` flag.)
126+ * Bottom-nav style tab switch: single top-level entry per tab, with the leaving tab's state saved
127+ * and the entered tab's state restored so it comes back exactly as the user left it.
128128 */
129- @OptIn(androidx.compose.ui.ExperimentalComposeUiApi ::class )
130- @Composable
131- private fun ScreenSlot (active : Boolean , content : @Composable () -> Unit ) {
132- var everActive by remember { mutableStateOf(false ) }
133- if (active) everActive = true
134- if (! everActive) return
135-
136- Box (
137- modifier = Modifier
138- .fillMaxSize()
139- .zIndex(if (active) 1f else 0f )
140- .graphicsLayer { alpha = if (active) 1f else 0f }
141- .focusProperties {
142- canFocus = active
143- if (! active) onEnter = { cancelFocus() }
144- }
145- .focusGroup(),
146- ) {
147- content()
129+ private fun NavController.switchTab (route : String ) {
130+ if (currentDestination?.route == route) return
131+ navigate(route) {
132+ popUpTo(graph.findStartDestination().id) { saveState = true }
133+ launchSingleTop = true
134+ restoreState = true
148135 }
149136}
150137
0 commit comments