Skip to content

Repository files navigation

tv-focus-navigation-kit

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:

  1. 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
  2. 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
  3. 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
  4. 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.

What's NOT here

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).

Install

Not published to Maven Central. Pull the tvfocus/ module into your own multi-module project:

  1. Copy the tvfocus/ directory into your project root.
  2. Add it to settings.gradle.kts:
    include(":tvfocus")
  3. Depend on it from your app module's build.gradle.kts:
    dependencies {
        implementation(project(":tvfocus"))
    }
  4. 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.

Quickstart

// 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.

API reference

dev.tvfocus.focus

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.

dev.tvfocus.modifiers

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.

dev.tvfocus.hero

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.

dev.tvfocus.rail

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.

dev.tvfocus.theme

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.

Building

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.

License

Apache 2.0. See LICENSE.

About

D-pad focus and navigation for Jetpack Compose for TV: deterministic bring-into-view, a hero scroll-race guard, cross-zone focus restoration, and a focus debug HUD, plus a standalone demo app.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages