원클릭으로
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) }