一键导入
kmp-navigation
Compose Navigation for TV: state-based screen switching, BackHandler, focus-aware transitions, and drawer/sidebar patterns
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Compose Navigation for TV: state-based screen switching, BackHandler, focus-aware transitions, and drawer/sidebar patterns
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Template repo file structure: where screens, navigation, theme, data, and platform entries live in the monorepo
Android TV build and D-pad QA using Android CLI first, with bounded Gradle and ADB fallbacks
Expo Application Services cloud build configuration for TV app APK and IPA generation
Expo TV configuration: app.json plugins, prebuild settings, platform-specific config for react-native-tvos
Fire TV leanback manifest configuration: banner icons, LEANBACK_LAUNCHER intent filter, TV-specific Android settings
Gradle build commands: assembleDebug, assembleRelease, APK output paths, and emulator installation
| name | kmp-navigation |
| description | Compose Navigation for TV: state-based screen switching, BackHandler, focus-aware transitions, and drawer/sidebar patterns |
| applies_to | ["screens","scaffold"] |
| load_when | adding new screens, changing navigation flow, or debugging back behavior |
The kmptv template uses state-based navigation (not Jetpack Navigation Compose). This is intentional — Navigation Compose has known focus-restoration issues on TV that make it unreliable for D-pad apps.
The Android TV app manages navigation via state variables in MainScreen():
@Composable
private fun MainScreen() {
var selectedItem by remember { mutableStateOf<ContentItem?>(null) }
var showingDetail by rememberSaveable { mutableStateOf(false) }
var showingVideoPlayer by rememberSaveable { mutableStateOf(false) }
BackHandler(enabled = showingVideoPlayer || showingDetail) {
if (showingVideoPlayer) {
showingVideoPlayer = false
} else if (showingDetail) {
showingDetail = false
selectedItem = null
}
}
when {
showingVideoPlayer && selectedItem?.videoUrl != null ->
VideoPlayerScreen(item = selectedItem!!, onBack = { showingVideoPlayer = false })
showingDetail && selectedItem != null ->
ContentDetailScreen(item = selectedItem!!, onBack = { showingDetail = false })
else ->
HomeScreen(onItemClick = { item ->
selectedItem = item
showingDetail = true
})
}
}
Crossfade and custom transitions work naturally with when blocks.import androidx.activity.compose.BackHandler
BackHandler(enabled = /* when this screen is active */) {
// Pop this screen
}
Rules:
enabled parameter scoped to the current screen. Without it, back handling fires when it shouldn't.Home (no BackHandler — system exits app)
└── Detail (BackHandler → goes back to Home)
└── VideoPlayer (BackHandler → goes back to Detail)
androidtv-app/src/main/java/.../compose/:@Composable
fun NewScreen(
item: ContentItem,
onBack: () -> Unit,
) {
// Screen content
}
var showingNewScreen by rememberSaveable { mutableStateOf(false) }
when block (order matters — more specific screens first):when {
showingVideoPlayer -> VideoPlayerScreen(...)
showingNewScreen -> NewScreen(item = selectedItem!!, onBack = { showingNewScreen = false })
showingDetail -> ContentDetailScreen(...)
else -> HomeScreen(...)
}
BackHandler(enabled = showingNewScreen) {
showingNewScreen = false
}
The template uses Crossfade for the hero banner. For screen transitions, the when block provides an instant swap. To add animated transitions:
AnimatedContent(
targetState = currentScreen,
transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
}
) { screen ->
when (screen) {
Screen.Home -> HomeScreen(...)
Screen.Detail -> ContentDetailScreen(...)
Screen.Player -> VideoPlayerScreen(...)
}
}
Caution: Complex transitions can interfere with focus. Test that focus lands correctly after every transition.
For apps with multiple top-level sections (e.g., Home, Movies, TV Shows, Settings), add a navigation drawer:
@Composable
fun AppWithDrawer() {
var currentSection by rememberSaveable { mutableStateOf(Section.Home) }
var drawerOpen by remember { mutableStateOf(false) }
val drawerFocusRequester = remember { FocusRequester() }
Row(Modifier.fillMaxSize()) {
// Drawer panel (collapsed by default, expands on focus/dpad-left)
NavigationDrawer(
isOpen = drawerOpen,
sections = Section.values().toList(),
selectedSection = currentSection,
onSectionSelected = { section ->
currentSection = section
drawerOpen = false
},
modifier = Modifier.focusRequester(drawerFocusRequester),
)
// Content area
Box(Modifier.weight(1f)) {
when (currentSection) {
Section.Home -> HomeScreen(...)
Section.Movies -> CategoryScreen(genre = "Movies")
Section.Settings -> SettingsScreen()
}
}
}
// Open drawer on left D-pad at screen edge
BackHandler(enabled = drawerOpen) {
drawerOpen = false
}
}
The Apple TV app uses SwiftUI's NavigationStack:
NavigationStack {
HomeView()
.navigationDestination(for: ContentItem.self) { item in
ContentDetailView(item: item)
}
}
SwiftUI handles focus restoration automatically on tvOS. The back button (Menu on Siri Remote) pops the stack natively.
popBackStack(), focus jumps to the first focusable instead of the previously focused item. Use state-based navigation.rememberSaveable for screen state. remember loses state on configuration change. Use rememberSaveable for navigation booleans.enabled guard. Intercepts back presses on every screen. Always scope with enabled = specificScreenIsActive.enum class Screen { Home, Detail, Player, Settings }
var currentScreen by rememberSaveable { mutableStateOf(Screen.Home) }