D-pad focus is the hardest part of building a TV app in Jetpack Compose.
Compose for TV gives you focusable(), FocusRequester, and a geometric
focus search, and then leaves you to discover the hard way that geometric
search is the wrong tool at almost every screen boundary, that
scrollToItem() doesn't mean "and now it's laid out," and that "press Back"
has an implicit contract about where focus lands that nobody wrote down.
This kit is the fix for four specific problems, extracted from a real, shipped Android TV app and genericized so none of it depends on that app's backend, navigation stack, or branding:
- Deterministic bring-into-view -- focusing an item that isn't guaranteed to be on screen yet, without a fixed delay and without a flaky race. docs/bring-into-view.md
- The hero scroll-race -- a hero's auto-focused CTA fighting
LazyColumn's bring-into-view behavior during a screen's enter transition, so the title ends up clipped under the nav. docs/hero-scroll-race.md - Cross-zone focus -- D-pad moves between a top nav, a hero, and content rows, resolved explicitly instead of by geometric guess, plus the focused-vs-selected distinction that guess-based nav code usually gets wrong. docs/cross-zone-focus.md
- Restore-on-back -- landing focus on the exact card a detail screen was opened from, not the top of the row. docs/restore-on-back.md
See DOCTRINE.md for the five rules the design choices here follow (content-first, remote-navigation-first, focus unmistakable from the couch, GPU-light, verify on real hardware).
This is a library module (tvfocus) plus a three-screen demo app (demo)
that exercises every pattern with hardcoded fake data -- no network calls,
no backend, nothing to configure to see it running.
This kit is focus and navigation ONLY. It does not include video playback,
a networking layer, image-loading configuration beyond a single optional
AsyncImage call, or any opinion about your app's color palette (see
LocalTvAccent below -- you bring your own).
Not published to Maven Central. Pull the tvfocus/ module into your own
multi-module project:
- Copy the
tvfocus/directory into your project root. - Add it to
settings.gradle.kts:include(":tvfocus") - Depend on it from your app module's
build.gradle.kts:dependencies { implementation(project(":tvfocus")) } - Match the dependency versions in
tvfocus/build.gradle.kts(Compose BOM,androidx.tv:tv-material, Coil) against what your app already uses, or let your app's versions win via your own version catalog.
Alternatively, just run the demo app from this repo directly (see
"Building" below) and copy individual files out of tvfocus/src/main/java/dev/tvfocus/
as needed -- every file is self-contained enough to lift on its own except
where its doc comment says otherwise.
// 1. One FocusController for the whole app, provided at the nav-graph root.
val fc = remember { FocusController() }
CompositionLocalProvider(LocalFocusController provides fc) {
// 2. A screen with a hero: pin it against the scroll-race, register how
// to focus it from elsewhere, seed initial focus on it.
val listState = rememberLazyListState()
var heroFocused by remember { mutableStateOf(false) }
HeroScrollGuard(listState) { heroFocused }
DisposableEffect(fc) {
val action: suspend () -> Boolean = { focusHeroOnList(listState, fc.heroAnchor) }
fc.focusHeroAction = action
onDispose { if (fc.focusHeroAction === action) fc.focusHeroAction = null }
}
LazyColumn(state = listState) {
item {
Column(Modifier.onFocusChanged { heroFocused = it.hasFocus }) {
Text("Some Show")
HeroActionRow(
primaryLabel = "Play",
primaryFocusRequester = fc.heroAnchor,
onPrimaryClick = { /* ... */ },
secondaryLabel = "More info",
onSecondaryClick = { /* ... */ },
modifier = Modifier.initialFocus(fc.heroAnchor),
)
}
}
item {
// 3. A row of cards, with a focus ring and restore-on-back for free.
CardRail(
title = "Trending",
items = myItems,
onLeadingUp = { scope.launch { focusHeroOnList(listState, fc.heroAnchor) } },
onSelect = { item, idx ->
fc.requestRestore(fc.focusedKey)
openDetail(item)
},
)
}
}
// 4. A top nav that hands focus into the hero deterministically.
NavHud(
pillars = listOf(NavPillar("home", "Home"), NavPillar("browse", "Browse")),
activeKey = "home",
onPillarSelected = { /* navigate */ },
)
}Run the demo module to see this fully wired across three real screens
instead of a snippet -- it's the more useful read.
| Symbol | What it does |
|---|---|
FocusZone |
Enum naming the regions of a TV screen (TopNav, Hero, Rows, Search, Modal, Player). Copy and extend freely. |
FocusController |
The single source of truth: current zone/key, per-zone last-focused memory, the navAnchor/heroAnchor cross-zone anchors, focusHeroAction, restore-on-back state, and debug telemetry. One instance per app, provided via LocalFocusController. |
LocalFocusController |
CompositionLocal<FocusController>. |
focusHeroOnList(listState, anchor) |
Deterministically bring list item 0 into view and focus it. See bring-into-view.md. |
focusRowItem(rowState, index, anchor) |
Same, for an arbitrary index. Powers CardRail's restore-on-back. |
parseRowKey(key) |
Parses a CardRail-emitted "row:<title>:<index>" key back into (title, index). |
NavPillar |
data class(key, label) -- one item in NavHud. |
NavHud(...) |
Top nav bar. The cross-zone DOWN/UP boundary handler. See cross-zone-focus.md. |
FocusDebugOverlay(...) |
Toggleable on-screen focus diagnostics HUD. Wire to KEYCODE_MENU. |
| Symbol | What it does |
|---|---|
Modifier.tvFocusable(accent, shape, glowWidth) |
Scale + border + glow focus ring for any focusable composable (not just tv-material Surfaces). |
Modifier.tvFocusableFlat(accent, shape) |
Same, no scale -- for elements that shouldn't grow (e.g. a full-width row). |
TvFocusDefaultAccent |
The default focus-ring color if you don't pass one. |
Modifier.initialFocus(requester, enabled, label) |
Seed focus onto an element exactly once, after layout. |
LocalTvFocusPreviewActive |
CompositionLocal<Boolean> -- set true from a transient overlay to suppress initialFocus while it's showing. |
| Symbol | What it does |
|---|---|
HeroScrollGuard(listState, heroFocused) |
Pins a hero-topped list at offset 0 while the hero holds focus. See hero-scroll-race.md. |
HeroActionRow(...) |
Primary + secondary CTA row wired with explicit UP routing, focus-zone reporting, and carousel-aware key stealing. The pattern to copy for any hero CTA. |
| Symbol | What it does |
|---|---|
CardItem |
Data shape for one card (title, art URLs, badge, status, progress). Empty art URLs render a generated placeholder -- no network call. |
CardStatus |
LIVE / UPCOMING / NEW / TRENDING / TOP10 / UHD pill enum. |
isRecentRelease(date, days) |
Real "is this new" check from an ISO date -- for wiring CardStatus.NEW to real data instead of a fabricated flag. |
CardRail(...) |
Horizontal card row: focus ring, focusRestorer(), restore-on-back (restoreIndex/onRestored), onLeadingUp. |
LandscapeCardRail(...) |
Same, 16:9 tiles -- Continue Watching and similar. |
Modifier.leadingUp(onLeadingUp) |
Routes D-pad UP on a LazyRow to an explicit callback. Apply only to a screen's first row. |
| Symbol | What it does |
|---|---|
LocalTvAccent |
CompositionLocal<Color> carrying the current screen's accent. This library ships the MECHANISM only -- your section-to-color mapping is yours to define and provide. |
This repo ships without a Gradle wrapper JAR -- committing a binary through an extraction/review pipeline isn't safe to do blind, so it's left out rather than risk a corrupt one. Before your first build:
gradle wrapper --gradle-version 8.11.1
(or open the project in a recent Android Studio, which bootstraps the
wrapper automatically). gradle/wrapper/gradle-wrapper.properties is
already in the repo and points at the right distribution.
From there:
./gradlew :demo:installDebug
installs the demo app on a connected Android TV / Fire TV device or a running Android TV emulator (Extended Controls -> D-pad, or a physical remote, to actually exercise the focus patterns -- see DOCTRINE.md rule 5 on why an emulator's arrow keys aren't the whole story).
A Gradle build on your own machine is the last step here, not the first.
Everything in this repo was extracted and reviewed as source, not compiled
in this pass -- run the build, and if a dependency version in
gradle/libs.versions.toml has drifted since (Compose BOM ships roughly
monthly), bump it before filing an issue against the code itself.
Apache 2.0. See LICENSE.