Migrating from Leanback to Jetpack Compose in Android TV

07 / Oct / 2025 by Shubham Modi 0 comments

When Google introduced Leanback, it solved a hard problem: building focusable, remote-friendly UIs for Android TV. But Leanback was built on Fragments, Presenters, and XML themes — patterns that don’t scale well in 2025.

With Jetpack Compose for TV, we finally get:

  • A declarative focus model.
  • Composable theming instead of XML overrides.
  • Reusable UI that works across Mobile, Tablet, and TV.

But migrating a production TV app isn’t as simple as replacing a BrowseSupportFragment. You need a strategy.

This post is a real-world migration guide — not just code snippets.

🚀 Migration Philosophy

Instead of rewriting everything at once, think bottom-up + modular:

  • Extract a design system → one source of truth for colors, typography, dimensions.
  • Replace leaf components first → cards, buttons, headers.
  • Introduce Compose inside Leanback (hybrid approach).
  • Migrate screens one by one → Browse → Details → Search → Player.
  • Switch navigation layer last → Fragments → Navigation-Compose.

🎨 Theming – From XML to Composable

Leanback theming is XML-driven:

@style/BrowseTitleView
@style/RowHeader
“> ” width=”300″ height=”78″ data-mce-src=”https://www.tothenew.com/blog/wp-ttn-blog/uploads/2025/09/Screenshot-from-2025-09-09-16-34-58-300×78.png”>xml themeThis worked, but it was rigid.

In Compose-TV, theming is Kotlin-driven:

@Composablefun TvAppTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = darkColorScheme( primary = Color(0xFFE50914), secondary = Color(0xFFB81D24), background = Color.Black, surface = Color(0xFF121212) ), typography = Typography( titleLarge = TextStyle( fontSize = 22.sp, fontWeight = FontWeight.Bold, color = Color.White ), bodyLarge = TextStyle( fontSize = 18.sp, lineHeight = 24.sp ) ), content = content ) }

compose theme

✅ You now control theme per screen or per component, not just app-wide.
✅ Easy to experiment with focus states and high-contrast accessibility themes.

🧩 Modular Migration Strategy

Let’s say your current structure looks like this:

:app
├── ui (Leanback fragments, presenters)
├── data
├── domain

You want to evolve into:

:app
├── ui-leanback (legacy fragments, presenters)
├── ui-compose (new Compose screens)
├── design-system (colors, typography, dimensions)
├── domain
└── data

  • design-system → A pure Kotlin module containing ColorPalette, Typography, spacing constants. Both XML and Compose can consume this.
  • ui-compose → All new TV screens in Compose.
  • ui-leanback → Old Leanback screens until migration is done.

This way, you can:

  1. Ship hybrid builds (Compose + Leanback) in production.
  2. Roll out Compose screens gradually (safe migration).

🎬 Screen Migration Example

Leanback BrowseSupportFragment

class MainFragment : BrowseSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) title = "Movies" headersState = HEADERS_ENABLED val rowsAdapter = ArrayObjectAdapter(ListRowPresenter()) val listRowAdapter = ArrayObjectAdapter(CardPresenter()) listRowAdapter.add(Movie("Interstellar")) rowsAdapter.add(ListRow(HeaderItem(0, "Sci-Fi"), listRowAdapter)) adapter = rowsAdapter } }

Leanback BrowseSupportFragment

Compose Equivalent

@Composablefun MoviesScreen(movies: List<Movie>) { TvLazyColumn { item { Text( text = "Sci-Fi", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp) ) } item { TvLazyRow { items(movies) { movie -> FocusableMovieCard(movie) } } } } } @Composable fun FocusableMovieCard(movie: Movie) { var focused by remember { mutableStateOf(false) } Card( modifier = Modifier .size(180.dp, 240.dp) .focusable() .onFocusChanged { focused = it.isFocused } .border( width = if (focused) 3.dp else 0.dp, color = if (focused) Color.Yellow else Color.Transparent ), onClick = { /* navigate */ } ) { Text(movie.title, Modifier.padding(8.dp)) } }

compose example

🎯 Difference:

  1. No presenters.
  2. No fragment boilerplate.
  3. Full control over focus UI.

🎮 Focus and Remote Navigation

Leanback managed focus magically, but often in a rigid way.

In Compose-TV:

  • Use Modifier.focusable() for all interactive elements.
  • Group elements with FocusGroup for better navigation.
  • Use onPreviewKeyEvent for custom DPAD overrides (e.g., skip sections).

Modifier.onPreviewKeyEvent { keyEvent ->
    if (keyEvent.key == Key.DirectionDown) {
        // Handle custom navigation
        true
    } else false
}

📊 Performance Considerations

Leanback was optimized for large rows. In Compose:

Use LazyRow / LazyColumn for virtualization.
Combine with Paging 3 for infinite scrolling:

val movies = pager.collectAsLazyPagingItems()

LazyRow {
    items(movies.itemCount) { index ->
        movies[index]?.let { MovieCard(it) }
    }
}

Test on real devices: Emulator doesn’t always reflect true DPAD latency.

🧪 Pitfalls & Lessons Learned

  • Theme clashes: If Leanback fragments still use XML themes, ensure Compose surfaces are wrapped in TvAppTheme.
  • Focus loops: Compose focus isn’t 1:1 with Leanback; test “edge cases” like end-of-row navigation.
  • Hybrid apps: Don’t try to rewrite everything at once. A ComposeView inside Leanback is your friend during transition.

Migration Checklist

  • Extract theme + design system into shared module.
  • Migrate leaf components (cards, buttons).
  • Start hybrid: Use ComposeView inside Leanback.
  • Replace rows/fragments screen by screen.
  • Switch navigation to Compose last.
  • Remove Leanback dependency.

Conclusion

Migrating an Android TV app from Leanback to Jetpack Compose is not just a UI migration. It’s a re-architecture:

  1. From XML themes → to Composable theming.
  2. From fragment-heavy code → to state-driven navigation.
  3. From rigid templates → to flexible layouts.
  4. Do it gradually, starting from lower modules and shared design system. By the end, you’ll have a cleaner, modern, and scalable
  5. TV app that’s easier to maintain and future-proof.
FOUND THIS USEFUL? SHARE IT

Leave a Reply

Your email address will not be published. Required fields are marked *