{"id":76164,"date":"2025-10-07T11:37:58","date_gmt":"2025-10-07T06:07:58","guid":{"rendered":"https:\/\/www.tothenew.com\/blog\/?p=76164"},"modified":"2025-10-13T15:08:15","modified_gmt":"2025-10-13T09:38:15","slug":"migrating-from-leanback-to-jetpack-compose-in-android-tv","status":"publish","type":"post","link":"https:\/\/www.tothenew.com\/blog\/migrating-from-leanback-to-jetpack-compose-in-android-tv\/","title":{"rendered":"Migrating from Leanback to Jetpack Compose in Android TV"},"content":{"rendered":"<p>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 \u2014 patterns that don\u2019t scale well in 2025.<\/p>\n<p>With Jetpack Compose for TV, we finally get:<\/p>\n<ul>\n<li>A declarative focus model.<\/li>\n<li>Composable theming instead of XML overrides.<\/li>\n<li>Reusable UI that works across Mobile, Tablet, and TV.<\/li>\n<\/ul>\n<p>But migrating a production TV app isn\u2019t as simple as replacing a BrowseSupportFragment. You need a strategy.<\/p>\n<p>This post is a real-world migration guide \u2014 not just code snippets.<\/p>\n<h1>\ud83d\ude80 <strong>Migration Philosophy<\/strong><\/h1>\n<p>Instead of rewriting everything at once, think bottom-up + modular:<\/p>\n<ul style=\"list-style-type: disc;\">\n<li>Extract a design system \u2192 one source of truth for colors, typography, dimensions.<\/li>\n<li>Replace leaf components first \u2192 cards, buttons, headers.<\/li>\n<li>Introduce Compose inside Leanback (hybrid approach).<\/li>\n<li>Migrate screens one by one \u2192 Browse \u2192 Details \u2192 Search \u2192 Player.<\/li>\n<li>Switch navigation layer last \u2192 Fragments \u2192 Navigation-Compose.<\/li>\n<\/ul>\n<h1>\ud83c\udfa8 Theming \u2013 From XML to Composable<\/h1>\n<p>Leanback theming is XML-driven:<\/p>\n<p>@style\/BrowseTitleView<br \/>\n@style\/RowHeader<br \/>\n&#8220;&gt; &#8221; width=&#8221;300&#8243; height=&#8221;78&#8243; data-mce-src=&#8221;https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-34-58-300&#215;78.png&#8221;&gt;xml themeThis worked, but it was rigid.<\/p>\n<p>In Compose-TV, theming is Kotlin-driven:<\/p>\n<div id=\"attachment_76159\" style=\"width: 310px\" class=\"wp-caption alignnone\"><img aria-describedby=\"caption-attachment-76159\" decoding=\"async\" loading=\"lazy\" class=\"size-medium wp-image-76159\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-41-41-300x289.png\" alt=\"@Composablefun TvAppTheme(content: @Composable () -&gt; 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 ) } \" width=\"300\" height=\"289\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-41-41-300x289.png 300w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-41-41-768x739.png 768w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-41-41-624x600.png 624w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-41-41-24x24.png 24w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-41-41.png 809w\" sizes=\"(max-width: 300px) 100vw, 300px\" \/><p id=\"caption-attachment-76159\" class=\"wp-caption-text\">compose theme<\/p><\/div>\n<p>\u2705 You now control theme per screen or per component, not just app-wide.<br \/>\n\u2705 Easy to experiment with focus states and high-contrast accessibility themes.<\/p>\n<h1>\ud83e\udde9 <strong>Modular Migration Strategy<\/strong><\/h1>\n<p>Let\u2019s say your current structure looks like this:<\/p>\n<p><strong>:app<\/strong><br \/>\n<strong>\u251c\u2500\u2500 ui (Leanback fragments, presenters)<\/strong><br \/>\n<strong>\u251c\u2500\u2500 data<\/strong><br \/>\n<strong>\u251c\u2500\u2500 domain<\/strong><\/p>\n<p>You want to evolve into:<\/p>\n<p><strong>:app<\/strong><br \/>\n<strong>\u251c\u2500\u2500 ui-leanback (legacy fragments, presenters)<\/strong><br \/>\n<strong>\u251c\u2500\u2500 ui-compose (new Compose screens)<\/strong><br \/>\n<strong>\u251c\u2500\u2500 design-system (colors, typography, dimensions)<\/strong><br \/>\n<strong>\u251c\u2500\u2500 domain<\/strong><br \/>\n<strong>\u2514\u2500\u2500 data<\/strong><\/p>\n<ul>\n<li>design-system \u2192 A pure Kotlin module containing ColorPalette, Typography, spacing constants. Both XML and Compose can consume this.<\/li>\n<li>ui-compose \u2192 All new TV screens in Compose.<\/li>\n<li>ui-leanback \u2192 Old Leanback screens until migration is done.<\/li>\n<\/ul>\n<p>This way, you can:<\/p>\n<ol>\n<li>Ship hybrid builds (Compose + Leanback) in production.<\/li>\n<li>Roll out Compose screens gradually (safe migration).<\/li>\n<\/ol>\n<h1>\ud83c\udfac <strong>Screen Migration Example<\/strong><\/h1>\n<p>Leanback BrowseSupportFragment<\/p>\n<div id=\"attachment_76160\" style=\"width: 310px\" class=\"wp-caption alignnone\"><img aria-describedby=\"caption-attachment-76160\" decoding=\"async\" loading=\"lazy\" class=\"size-medium wp-image-76160\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-46-22-300x150.png\" alt=\"class MainFragment : BrowseSupportFragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) title = &quot;Movies&quot; headersState = HEADERS_ENABLED val rowsAdapter = ArrayObjectAdapter(ListRowPresenter()) val listRowAdapter = ArrayObjectAdapter(CardPresenter()) listRowAdapter.add(Movie(&quot;Interstellar&quot;)) rowsAdapter.add(ListRow(HeaderItem(0, &quot;Sci-Fi&quot;), listRowAdapter)) adapter = rowsAdapter } } \" width=\"300\" height=\"150\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-46-22-300x150.png 300w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-46-22-768x385.png 768w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-46-22-624x313.png 624w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-46-22.png 840w\" sizes=\"(max-width: 300px) 100vw, 300px\" \/><p id=\"caption-attachment-76160\" class=\"wp-caption-text\">Leanback BrowseSupportFragment<\/p><\/div>\n<p>Compose Equivalent<\/p>\n<div id=\"attachment_76161\" style=\"width: 313px\" class=\"wp-caption alignnone\"><img aria-describedby=\"caption-attachment-76161\" decoding=\"async\" loading=\"lazy\" class=\" wp-image-76161\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-47-56-212x300.png\" alt=\"@Composablefun MoviesScreen(movies: List&lt;Movie&gt;) { TvLazyColumn { item { Text( text = &quot;Sci-Fi&quot;, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp) ) } item { TvLazyRow { items(movies) { movie -&gt; 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)) } } \" width=\"303\" height=\"429\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-47-56-212x300.png 212w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-47-56-624x882.png 624w, \/blog\/wp-ttn-blog\/uploads\/2025\/09\/Screenshot-from-2025-09-09-16-47-56.png 670w\" sizes=\"(max-width: 303px) 100vw, 303px\" \/><p id=\"caption-attachment-76161\" class=\"wp-caption-text\">compose example<\/p><\/div>\n<h3>\ud83c\udfaf Difference:<\/h3>\n<ol>\n<li>No presenters.<\/li>\n<li>No fragment boilerplate.<\/li>\n<li>Full control over focus UI.<\/li>\n<\/ol>\n<h1>\ud83c\udfae <strong>Focus and Remote Navigation<\/strong><\/h1>\n<p>Leanback managed focus magically, but often in a rigid way.<\/p>\n<p>In Compose-TV:<\/p>\n<ul>\n<li>Use Modifier.focusable() for all interactive elements.<\/li>\n<li>Group elements with FocusGroup for better navigation.<\/li>\n<li>Use onPreviewKeyEvent for custom DPAD overrides (e.g., skip sections).<\/li>\n<\/ul>\n<p><em><strong>Modifier.onPreviewKeyEvent { keyEvent -&gt;<\/strong><\/em><br \/>\n<em><strong>\u00a0 \u00a0 if (keyEvent.key == Key.DirectionDown) {<\/strong><\/em><br \/>\n<em><strong>\u00a0 \u00a0 \u00a0 \u00a0 \/\/ Handle custom navigation<\/strong><\/em><br \/>\n<em><strong>\u00a0 \u00a0 \u00a0 \u00a0 true<\/strong><\/em><br \/>\n<em><strong>\u00a0 \u00a0 } else false<\/strong><\/em><br \/>\n<em><strong>}<\/strong><\/em><\/p>\n<h1>\ud83d\udcca Performance Considerations<\/h1>\n<p>Leanback was optimized for large rows. In Compose:<\/p>\n<p>Use LazyRow \/ LazyColumn for virtualization.<br \/>\nCombine with Paging 3 for infinite scrolling:<\/p>\n<p><em><strong>val movies = pager.collectAsLazyPagingItems()<\/strong><\/em><\/p>\n<p><em><strong>LazyRow {<\/strong><\/em><br \/>\n<em><strong>\u00a0 \u00a0 items(movies.itemCount) { index -&gt;<\/strong><\/em><br \/>\n<em><strong>\u00a0 \u00a0 \u00a0 \u00a0 movies[index]?.let { MovieCard(it) }<\/strong><\/em><br \/>\n<em><strong>\u00a0 \u00a0 }<\/strong><\/em><br \/>\n<em><strong>}<\/strong><\/em><\/p>\n<p>Test on real devices: Emulator doesn\u2019t always reflect true DPAD latency.<\/p>\n<h1>\ud83e\uddea <strong>Pitfalls &amp; Lessons Learned<\/strong><\/h1>\n<ul>\n<li>Theme clashes: If Leanback fragments still use XML themes, ensure Compose surfaces are wrapped in TvAppTheme.<\/li>\n<li>Focus loops: Compose focus isn\u2019t 1:1 with Leanback; test \u201cedge cases\u201d like end-of-row navigation.<\/li>\n<li>Hybrid apps: Don\u2019t try to rewrite everything at once. A ComposeView inside Leanback is your friend during transition.<\/li>\n<\/ul>\n<h1>\u2705 <strong>Migration Checklist<\/strong><\/h1>\n<ul>\n<li>Extract theme + design system into shared module.<\/li>\n<li>Migrate leaf components (cards, buttons).<\/li>\n<li>Start hybrid: Use ComposeView inside Leanback.<\/li>\n<li>Replace rows\/fragments screen by screen.<\/li>\n<li>Switch navigation to Compose last.<\/li>\n<li>Remove Leanback dependency.<\/li>\n<\/ul>\n<h1><strong>Conclusion<\/strong><\/h1>\n<p>Migrating an Android TV app from Leanback to Jetpack Compose is not just a UI migration. It\u2019s a re-architecture:<\/p>\n<ol>\n<li>From XML themes \u2192 to Composable theming.<\/li>\n<li>From fragment-heavy code \u2192 to state-driven navigation.<\/li>\n<li>From rigid templates \u2192 to flexible layouts.<\/li>\n<li>Do it gradually, starting from lower modules and shared design system. By the end, you\u2019ll have a cleaner, modern, and scalable<\/li>\n<li>TV app that\u2019s easier to maintain and future-proof.<\/li>\n<\/ol>\n","protected":false},"excerpt":{"rendered":"<p>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 \u2014 patterns that don\u2019t 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 [&hellip;]<\/p>\n","protected":false},"author":2127,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"iawp_total_views":49},"categories":[518],"tags":[4845,5538,8139,8138,3115,1703],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/76164"}],"collection":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/users\/2127"}],"replies":[{"embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/comments?post=76164"}],"version-history":[{"count":3,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/76164\/revisions"}],"predecessor-version":[{"id":76708,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/76164\/revisions\/76708"}],"wp:attachment":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/media?parent=76164"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/categories?post=76164"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/tags?post=76164"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}