Home
cd ../playbooks
Developer ToolsAdvanced

Leanback to Compose for TV Migration

Migrate an Android TV app from the legacy Leanback UI Toolkit to Jetpack Compose for TV (androidx.tv) — the 10-foot UI design constraints, D-pad focus handling (initial focus, bidirectional routing, focusRestorer, IME focus chaining), Media3 playback with PlayerSurface, a five-phase migration order, and a direct Leanback-class-to-Compose-screen mapping table.

15 minutes
By Google (android/skills)Source
#android#android-tv#jetpack-compose#leanback#kotlin#media3#d-pad-navigation

Porting a phone Compose screen straight to Android TV breaks on day one — nothing receives initial D-pad focus, carousels forget which card you were on when you navigate back to them, and a text field silently refuses to open the keyboard, because TV Compose needs explicit focus management that mobile Compose never required.

Who it's for: Android engineers migrating a Leanback-based TV app to Jetpack Compose for TV, teams retiring BrowseSupportFragment/ArrayObjectAdapter/CardPresenter in favor of androidx.tv.material3, developers debugging D-pad focus traps or IllegalStateException from an off-screen FocusRequester, anyone building Media3-based video playback for Android TV with Compose

Example

"Migrate our BrowseSupportFragment screen to Compose for TV" → A BrowseScreen built from LazyColumn-of-LazyRows with androidx.tv.material3 components, Modifier.focusRestorer() on each row so vertical navigation between carousels doesn't lose the last-focused card, initial focus wired via FocusRequester in a LaunchedEffect, and the five-phase migration order that replaces legacy Fragments without breaking compilation mid-way

CLAUDE.md Template

New here? 3-minute setup guide → | Already set up? Copy the template below.

# Leanback to Compose for TV Migration

Migrate an Android TV application from the legacy Leanback UI Toolkit, Android Views, or Support Fragments to Jetpack Compose for TV (`androidx.tv`) — covering browse screens, settings screens, authentication screens, and video playback screens, and replacing `BrowseSupportFragment`, `LeanbackSettingsFragment`, `PreferenceFragment`, `VideoSupportFragment`, `GuidedStepSupportFragment`, `SearchSupportFragment`, `VerticalGridSupportFragment`, `Presenter`, `ArrayObjectAdapter`, and `CursorMapper` with modern Compose equivalents.

## The 10-Foot UI

A "10-foot UI" is a design paradigm for televisions, tailored for viewing from roughly 3 meters away:

- **Viewing distance** — text and UI elements must be comfortably readable from a distance; layouts stay uncluttered, without dense blocks of text.
- **Color contrast** — high-contrast palettes and distinct visual indicators keep focused states visible across different TV panels, which often have lower display contrast.
- **D-pad navigation** — interaction relies on a directional remote with limited 4-way navigation (up/down/left/right); components are organized into clear spatial grids and carousels without focus traps.

## Core Architecture and Library Selection

- **UI modernization**: favor custom, cinematic layouts over legacy 1:1 templates — dynamic gradient hero backdrops, custom focus animations, custom navigation drawers, custom layouts.
- **Primary design system**: always use `androidx.tv.material3.*` (`androidx.tv:tv-material`) over mobile `androidx.compose.material3.*`. TV Material 3 has built-in D-pad focus handling, focus zoom scaling, and TV-optimized typography and shapes.
- **Focus zoom animation**: for interactive cards, use `CompactCard`, `ClassicCard`, or `WideCardContainer` with `scale = CardDefaults.scale(focusedScale = 1.1f)` for the standard TV focus animation.
- **Coil image loading**: include `io.coil-kt:coil-compose` to use declarative `AsyncImage(model, contentDescription, ...)` without a manual `ImageLoader`.
- **Explicit imports**: always import TV Material 3 classes explicitly (`import androidx.tv.material3.Surface`) instead of wildcard imports.
- **File naming**: name Composable screen files after the screen (`BrowseScreen.kt`, `PlaybackScreen.kt`, `AuthenticationScreen.kt`) — no generic `Main` prefixes.
- **Overscan and bezels**: apply horizontal padding (`horizontal = 48.dp` or `32.dp`, `vertical = 24.dp`) to root containers, carousels, and top bars to prevent clipping.
- **Reading width**: constrain long-form text columns with `Modifier.widthIn(max = 600.dp)`.
- **Media3 Compose**: include `androidx.media3:media3-ui-compose` when modernizing media playback screens.
- **No legacy `AndroidView` wrappers**: don't embed View-based components via `AndroidView { ... }`. Migrated screens use Compose components or Media3 Compose surfaces (`PlayerSurface`) exclusively.

## D-Pad Focus Handling and Navigation

Compose for TV requires explicit focus management — components don't receive initial focus automatically, and navigation uses 2D spatial coordinates.

### Initial Focus

Assign initial focus to the primary interactive element on every screen using `FocusRequester`:

```kotlin
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current

LaunchedEffect(Unit) {
    focusRequester.requestFocus()
}
```

For screens with dynamic state or pagers (e.g. `OnboardingScreen` using `HorizontalPager`), pass the state key to `LaunchedEffect` (e.g. `LaunchedEffect(pagerState.currentPage)`) so focus re-applies when the page changes.

### Bidirectional Focus Routing and Avoiding Focus Traps

When interactive elements sit on opposite sides of the display, standard 2D spatial navigation can fail to find targets across them, trapping the user. Rely on Compose's 2D spatial focus engine whenever possible; when connecting adjacent UI elements across scrollable containers (`LazyColumn`/`LazyRow`), don't set directional overrides (`up =`, `down =`) targeting individual items inside lazy lists — when an item scrolls off-screen, its `FocusRequester` becomes uninitialized and throws `IllegalStateException` during focus searches.

### Row Focus Recollection (Modifier.focusRestorer)

When navigating vertically between horizontal carousels (`LazyRow`), Compose's default spatial focus engine searches along the X coordinate of the focused item — scroll right in Row 1, press DOWN, and it focuses whatever sits at that X coordinate in Row 2, not necessarily the last item visited there. Attach `Modifier.focusRestorer()` (no arguments) directly to each `LazyRow` so every row remembers and restores its own last-focused card. Don't pass custom fallback `FocusRequester` lambdas in lazy containers — calling `requestFocus` on an unattached or off-screen item during rapid D-pad scrolling throws `IllegalStateException`.

```kotlin
LazyRow(
    modifier = Modifier.focusRestorer(),
    contentPadding = PaddingValues(horizontal = 48.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    itemsIndexed(videos) { vidIndex, video ->
        CompactCard(
            onClick = { onVideoClick(video) },
            image = {
                AsyncImage(
                    model = video.cardImageUrl,
                    contentDescription = video.title,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier.fillMaxSize()
                )
            },
            title = { Text(video.title) },
            modifier = Modifier
                .then(
                    if (catIndex == 0 && vidIndex == 0) Modifier.focusRequester(firstCardFocusRequester)
                    else Modifier
                )
                .onFocusChanged { if (it.isFocused) { focusedVideo = video; focusedCategoryIndex = catIndex } }
        )
    }
}
```

### Text Input, Back-Key Interception, and IME Focus Chaining

Migrating search bars or login forms (from `SearchSupportFragment`, `GuidedStepSupportFragment`): don't use bare `BasicTextField` containers or empty `Surface(onClick = {})` wrappers — they prevent D-pad CENTER from attaching the virtual keyboard.

1. **Clickable TV surface wrapper with back-key interception**: wrap a standard M3 `TextField` inside a focusable TV `Surface(onClick = { focusRequester.requestFocus() }, ...)`, and attach `Modifier.onPreviewKeyEvent` to intercept `Key.Back`/`Key.Escape` so the user can leave the input field without exiting the screen entirely.
2. **Why interception is mandatory**: pressing Back while editing a text field normally navigates back and exits the screen. Intercepting `Key.Back`/`Key.Escape` on `KeyUp` to clear focus instead lets the user return to D-pad navigation without accidentally leaving the screen.
3. **IME focus chaining**: for multi-field forms, attach `KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })` with `ImeAction.Next` on upper fields, and `ImeAction.Done` on the bottom field to route to the submit button.

## Lazy Containers

Use standard `LazyColumn`, `LazyRow`, and `LazyVerticalGrid`. For a custom pivot scroll line via `BringIntoViewSpec`, ensure Compose Foundation 1.7.0+ (`implementation 'androidx.compose.foundation:foundation:1.7.0'` or newer BOM). Annotate composables using `Modifier.focusRestorer` with `@OptIn(ExperimentalFocusRestorerApi::class, ExperimentalComposeUiApi::class)`.

## Media3 Video Playback and Transport Controls

Migrating `VideoSupportFragment`/`PlaybackGlue`: use Compose Media3 `PlayerSurface` (`androidx.media3.ui.compose.PlayerSurface`) combined with a translucent transport controls overlay.

1. **Mandatory transport buttons**: layer a translucent bottom controls bar over `PlayerSurface` with at minimum `PlayPauseButton`, `SeekBackButton`, `SeekForwardButton` from Media3 UI Compose — never leave the overlay empty. Requires `androidx.media3:media3-ui-compose-material3:1.6.0` alongside `androidx.media3:media3-ui-compose`.
2. **D-pad directional seeking**: attach `Modifier.onPreviewKeyEvent` and intercept `Key.DirectionLeft`/`Key.DirectionRight` to seek ±10 seconds. Never use legacy Android View keycodes.
3. **No legacy wrappers**: don't wrap `PlayerView`/`StyledPlayerView` in `AndroidView { ... }` — use `PlayerSurface` with `ExoPlayer` directly.

## Phased Migration Strategy

To migrate cleanly without breaking compilation or introducing circular dependencies, execute in five phases:

**Phase 1 — Foundation and design system**: create `TvTheme.kt` wrapping `TvMaterialTheme`; build atomic reusable components (`MovieCard`, `SectionHeader`, `LoadingIndicator`, `ErrorState`).

**Phase 2 — Leaf and standalone screens**: migrate screens with no outbound navigation first (error, onboarding, settings). Replace `BaseLeanbackPreferenceFragmentCompat`/`LeanbackSettingsFragment` with Compose `ListItem` + `Switch` bound directly to `SharedPreferences`. Assign initial D-pad focus on every screen. Replace legacy `Fragment` classes with `ComponentActivity` using Compose declaratively. Clean up legacy Leanback theme/style references (e.g. `@style/PreferenceThemeOverlay.v14.Leanback`) that break once leanback dependencies are removed.

**Phase 3 — Core browsing and discovery screens**: migrate `VerticalGridScreen` (`LazyVerticalGrid`), `SearchScreen` (`BasicTextField` with live filtering), and `BrowseScreen` (`LazyColumn` of `LazyRow`s). Remove `ArrayObjectAdapter`, `ListRowPresenter`, `CardPresenter`, `HeaderItem`.

**Phase 4 — Details and media playback**: migrate `VideoDetailsScreen` and `GuidedStepScreen`; migrate `PlaybackScreen` using `PlayerSurface` plus the transport-controls overlay.

**Phase 5 — Final unification and cleanup**: remove all remaining legacy `.java` activities/fragments/presenters/XML layouts; ensure every activity extends `ComponentActivity`/`FragmentActivity` calling `setContent { ... }`; remove legacy Leanback themes and `lb_`-prefixed style references entirely.

## Component and Class Mapping Guide

| Legacy Leanback / View Class | Modern Compose Equivalent |
|---|---|
| `BrowseSupportFragment` / `MainFragment` | `BrowseScreen` (`LazyColumn` of categorized `LazyRow`s + Hero Banner) |
| `DetailsSupportFragment` | `VideoDetailsScreen` (poster, text column, action buttons, related `LazyRow`) |
| `VideoSupportFragment` / `PlaybackGlue` | `PlaybackScreen` (Media3 `ExoPlayer` + Compose `PlayerSurface`) |
| `GuidedStepSupportFragment` | `GuidedStepScreen` (split-screen: 40% guidance pane, 60% actions pane) |
| `SearchSupportFragment` | `SearchScreen` (`BasicTextField` + live filtering + `LazyVerticalGrid`) |
| `VerticalGridSupportFragment` | `VerticalGridScreen` (`LazyVerticalGrid(columns = GridCells.Fixed(5))`) |
| `ArrayObjectAdapter` / `Presenter` | Declarative `@Composable` functions observing immutable `State<List<T>>` |
| `CursorMapper` / `LoaderManager` / `CursorLoader` | Kotlin Coroutines / `withContext(Dispatchers.IO)` in a Repository object |
| `OnboardingSupportFragment` | `OnboardingScreen` (`HorizontalPager` + D-pad navigation buttons) |
| `LeanbackSettingsFragment` / `PreferenceFragment` | `SettingsScreen` (`FocusRequester` on first `ListItem` + trailing `Switch` bound to `SharedPreferences`) |

Get new playbooks like this one

One email a week with new Claude Code workflows. Free, like everything here.

No spam. Unsubscribe anytime.

README.md

What This Does

A migration playbook, from Google's own android/skills repository, for moving an Android TV application off the legacy Leanback UI Toolkit and onto Jetpack Compose for TV (androidx.tv). It starts from the "10-foot UI" design paradigm that TV interfaces have to satisfy — comfortable readability from across a room, high-contrast focus states, and D-pad-only 4-way navigation with no focus traps — and works through the specific engineering rules that make Compose actually work for that paradigm: androidx.tv.material3 over mobile Material3 for built-in D-pad focus handling, explicit initial-focus assignment via FocusRequester (TV Compose never focuses anything automatically), and Modifier.focusRestorer() on every horizontal carousel so vertical navigation between rows doesn't lose the user's place — the exact bug that produces the most confusing TV-Compose focus complaints.

It covers Media3 video playback migration (PlayerSurface plus a mandatory transport-controls overlay, D-pad-driven seeking via onPreviewKeyEvent, never a legacy AndroidView-wrapped PlayerView), text input and IME focus chaining for search bars and login forms (including why intercepting the Back key during text editing is mandatory, not optional), a five-phase migration order designed to avoid breaking compilation mid-migration (foundation → leaf screens → browsing screens → details/playback → final cleanup), and a direct mapping table from every major Leanback class (BrowseSupportFragment, ArrayObjectAdapter, CardPresenter, GuidedStepSupportFragment, and more) to its modern Compose equivalent.


Quick Start

Step 1: Create a Project Folder

mkdir tv-migration && cd tv-migration

Step 2: Download the Template

Click Download above, then:

mv ~/Downloads/CLAUDE.md ./

Step 3: Migrate a Screen

claude

Point Claude at a Leanback-based screen (a BrowseSupportFragment, a settings screen, a video-playback screen) and ask it to migrate that screen to Compose for TV. It will apply the correct focus-handling patterns, the right androidx.tv.material3 components, and place the work at the correct phase of the overall migration order.


Tips & Best Practices

  • Attach Modifier.focusRestorer() to every LazyRow carousel from the start — retrofitting it after users report "focus jumps around" is far more work than including it in the first pass at each screen.
  • Assign initial focus with a LaunchedEffect(Unit) (or keyed to relevant state for pagers) on literally every screen — a screen with no initial focus assignment leaves the D-pad with nothing to move from, which reads as a completely broken screen to a TV user.
  • Follow the five-phase order even under deadline pressure: migrating browsing/discovery screens (Phase 3) before the foundational design system and leaf screens (Phases 1–2) tends to produce rework once the shared TvTheme and atomic components stabilize.

Limitations

  • Targets Android TV specifically via androidx.tv.* — the focus-handling patterns don't transfer to mobile Compose, where initial and directional focus work differently.
  • Assumes the app can adopt Media3 for playback; apps on older playback stacks need that migration handled as a prerequisite, not covered here.
  • The component mapping table covers the common Leanback surface (BrowseSupportFragment, DetailsSupportFragment, GuidedStepSupportFragment, and similar) — a heavily customized Presenter subclass hierarchy may need additional analysis beyond the direct 1:1 mappings shown.

$Related Playbooks

Developer Tools

Long-Horizon Agent Prompting

Design the launch prompt for an agent working autonomously for hours or days — a pseudo-formal task brief with an exact success predicate, an enumerated non-counting-outcomes list, adversarial verification, and effort floors, because persistence pressure against a loose spec produces confident non-solutions.

10 minutes
Advanced
Developer Tools

Building Animations in Expo and React Native

A construction discipline for React Native motion: a frequency gate that produces zero lines of code when nothing should animate, a cheapest-tool-that-fits selection table, spring/easing values instead of guesses, and the JS-thread rules that separate 60fps from 20fps on a real device.

10 minutes
Advanced
Developer Tools

Idea-to-Design Brainstorming Gate

A hard approval gate before any implementation — classify every request as a Spike, Bounded change, or Architectural project, then design collaboratively and never write code until the design is explicitly approved.

5 minutes
Intermediate
Developer Tools

Hookify Guardrails

Create custom Claude Code guardrails with lightweight markdown rule files instead of hand-editing hooks.json — pattern matching, no restart required, rules live-load on the next tool call.

5 minutes
Beginner
Developer Tools

Learning Mode Coding Coach

Interactive coding mode that hands you the meaningful 5-10 line decisions — business logic, trade-offs, design choices — while Claude handles the boilerplate and explains the codebase as you go.

2 minutes
Beginner
Developer Tools

Loopy: Build and Reuse Agent Loops

Mine your codebase and coding history for repeated work, turn it into bounded agent loops, and audit the loops you already have

10 minutes
Intermediate
Developer Tools

GPT Taste Skill: Strict Anti-Slop UI Rules

A stricter, more deterministic anti-slop frontend ruleset with GSAP motion patterns, despite the GPT-oriented name it works as a standard CLAUDE.md in Claude Code

5 minutes
Intermediate
Developer Tools

GitHub Contributor

A phase-based playbook for shipping pull requests maintainers actually merge — discovery, CONTRIBUTING compliance, PR-size checks, minimal diffs, and post-submission interaction.

20 minutes
Intermediate
Developer Tools

GitHub Operations

Comprehensive GitHub operations using the gh CLI and REST/GraphQL APIs — PRs, issues, repositories, workflows, and bulk operations across public and Enterprise GitHub.

10 minutes
Intermediate
Developer Tools

i18n Expert

Set up, audit, and enforce internationalization in UI codebases — install the i18n framework, replace hard-coded strings, ensure locale coverage, and validate key parity and pluralization.

20 minutes
Intermediate
Developer Tools

LLM Icon Finder

Find and download AI/LLM model brand icons from the lobe-icons library — Claude, GPT, Gemini, and more — as ready-to-use SVG, PNG, or WEBP URLs.

5 minutes
Beginner
Developer Tools

LLM Coding Guardrails

Four behavioral guidelines that reduce the most common Claude coding mistakes: silent assumptions, overcomplication, scope creep, and vague success criteria.

2 minutes
Beginner

Browse all Developer Tools playbooks →