Sitemap

Jetpack Compose on Android TV: designing for the 10-foot experience

8 min read2 days ago

--

Press enter or click to view image in full size

What actually changes when your Composables have to survive a D-pad instead of a thumb

A few months into a TV app I worked on, I had a screen that looked perfect in preview and was borderline unusable with a remote in hand. Everything rendered. Nothing told you where you were. Press right on the D-pad and something happened off-screen, with no sign of which card had focus. That gap, between “compiles and looks right” and “navigable with five buttons from eight feet away”, is what this piece is about.

Below is real code from a small recipe-catalog screen I built to work through these problems, the kind of demo row you’d find in a cooking app. It passes spotlessCheck test assembleDebug, so every snippet is lifted from working code.

Why phone UI patterns break on a TV screen

Phone UI design assumes a thumb that can land anywhere on the screen: tap, ripple, move on. None of that exists on a TV. There’s a cursor-less focus system that jumps between discrete targets based on which direction you pushed on a remote, scrolling is a side effect of focus advancing past the viewport, and ripple or elevation-on-press communicate nothing on a device with no concept of touch.

A phone keeps the user’s eyes and input at the same point; a TV splits them, with a visual focus indicator as the only thing connecting eyes on the screen to thumb on a remote. A weak indicator leaves the user navigating blind.

Focus is a first-class concept, not a side effect

On a phone, Compose’s focus system mostly stays out of your way. On a TV, focus is the input model: every interactive element needs an explicit position in the traversal order, and the screen has to decide, on launch, what’s focused first, since there’s no “nothing focused” state that still lets the user do anything. In the recipe screen, that means requesting focus on the first card as soon as composition is ready:

// File: tv/RecipeTvCatalogScreen.kt
@Composable
fun RecipeTvCatalogScreen() {
var focusedTitle by remember { mutableStateOf<String?>(null) }
val firstCardFocusRequester = remember { FocusRequester() }
    LaunchedEffect(Unit) {
firstCardFocusRequester.requestFocus()
}
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
// ...
}
}
}

The LaunchedEffect(Unit) matters. Calling requestFocus() directly in the composable body doesn't work reliably, because the focus target has to exist in the focus tree before you can request it, and that only happens once the first composition pass settles. Skip this and you'll sometimes get a screen where nothing is focused until the user presses a direction key once to "wake up" traversal, which looks like a bug because it is one.

Press enter or click to view image in full size

The first frame after launch: the header reads “Browse recipes,” and the focus ring is already on the first card, “Miso Noodle Bowl.”

androidx.tv.material3 vs material3: what you gain and what you lose

Google ships androidx.tv:tv-material, which exposes androidx.tv.material3, rather than retrofitting the phone library's Surface and Text for D-pad input. The phone versions are built around touch and mouse interaction, with no opinion about what a D-pad is; the TV versions treat focus as an interaction source, so a TV Surface with onClick responds to confirm input from a remote, not just a tap.

A clickable Surface in androidx.tv.material3 ships default D-pad focus styling through ClickableSurfaceDefaults: a scale bump when focused (1.1x) and a shift in container color, no custom modifier code required. What you have to watch for is whether those defaults are loud enough for the room the screen will be watched in. The color shift is tuned to work broadly across themes, which also makes it subtle, and subtle from eight feet away on a bright panel can be close to invisible. That's the reason to reach past the defaults: not because nothing exists, but because "something exists" isn't always loud enough from a couch.

Building a focusable card row with LazyRow and ClickableSurfaceDefaults

TvLazyRow and TvLazyColumn are gone from androidx.tv:tv-foundation: Compose Foundation 1.7.0 absorbed TV-style focus handling for lazy layouts into the regular LazyRow and LazyColumn. A guide that tells you to pull in a TV-specific lazy row for this is out of date.

What that migration leaves out: a plain LazyRow does not restore focus to whichever child was previously focused once you navigate away and back. Restoring the focused index across re-entry is something you opt into, with Modifier.focusRestorer() on the row. Skip it, and every re-entry lands on the first item instead of wherever the user actually was.

This screen uses a plain LazyRow, with focusRestorer() attached and paired with TV-flavored Surface and Text:

// File: tv/RecipeTvCatalogScreen.kt
LazyRow(
modifier = Modifier.focusRestorer(),
contentPadding =
PaddingValues(
horizontal = TvOverscanHorizontalMargin,
vertical = 16.dp,
),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
itemsIndexed(sampleRecipes) { index, recipe ->
RecipeCard(
recipe = recipe,
modifier =
if (index == 0) {
Modifier.focusRequester(firstCardFocusRequester)
} else {
Modifier
},
onConfirm = { focusedTitle = recipe.title },
)
}
}

Only the first item gets the focusRequester modifier. Every other card is focusable by default, and focusRestorer() returns focus to wherever the user left it, not just to index zero.

Press enter or click to view image in full size

The row after navigating right twice: the header still reads “Browse recipes,” and the focus ring has moved to the third card, “Smoky Lentil Stew.”

The card itself needs no custom modifier. Scale and border both come from ClickableSurfaceDefaults, with an explicit focused border added because the default border is Border.None:

// File: tv/RecipeTvCatalogScreen.kt
@Composable
private fun RecipeCard(
recipe: RecipeItem,
modifier: Modifier = Modifier,
onConfirm: () -> Unit,
) {
Surface(
onClick = onConfirm,
modifier = modifier.size(width = 180.dp, height = 220.dp),
scale = ClickableSurfaceDefaults.scale(focusedScale = RECIPE_CARD_FOCUSED_SCALE),
border =
ClickableSurfaceDefaults.border(
focusedBorder =
Border(
border = BorderStroke(RecipeCardFocusBorderWidth, RecipeCardFocusBorderColor),
shape = RoundedCornerShape(12.dp),
),
),
) {
// ...
}
}

RECIPE_CARD_FOCUSED_SCALE is 1.1f, the same value ClickableSurfaceDefaults.scale() already uses by default, kept explicit for readability. What's actually added is the border: a 3dp ring in a specific blue, because the default scale and color shift alone weren't enough to tell, at a glance, which card had focus. The scale animation is still the default; there's no hand-rolled graphicsLayer or animateFloatAsState anywhere in this file.

Handling D-pad navigation boundaries

In a single-row layout, pressing Up or Down leaves focus search with nowhere to go: no candidate is found, and the key event is dropped silently, zero visual feedback. The first time I tested this with an actual remote, I thought the device had frozen.

Get Rafael Meneghelo’s stories in your inbox

Join Medium for free to get updates from this writer.

Confirm needs no special handling. Surface(onClick = onConfirm) already wires DirectionCenter and Enter to the click handler through the TV interaction source.

Press enter or click to view image in full size

The same row right after pressing D-pad center on the third card: the header has changed from “Browse recipes” to “Smoky Lentil Stew,” while the focus ring stays put on the third card.

Back is tempting to wire up inside the card itself. Don’t. Intercept Key.Back in a leaf composable's onKeyEvent and return true unconditionally, and you swallow Back even when there's nothing for that card to dismiss, so the system can never fall through to the Activity or launcher behind it. Handle Back at the screen level instead, scoped to whether there's something to dismiss:

// File: tv/RecipeTvCatalogScreen.kt
BackHandler(enabled = focusedTitle != null) {
focusedTitle = null
}

When focusedTitle is null, enabled is false, and the system's default Back behavior takes over. When a card's detail state is active, Back clears it instead of leaving the app. Scope the handler to the condition where dismissing makes sense, and let the default own every other case.

Testing the dead-end case matters too: verify focus stays visibly anchored on the active card instead of disappearing when you press into open space. The focused border never detaches from whatever currently holds focus, so a dead-end press reads as “you’re already at the edge,” not as “nothing happened.”

Overscan and safe margins

TV panels, especially older ones, crop a margin around the edge of the rendered frame, a holdover from analog broadcast called overscan. The convention stuck because focus near the very edge of the screen looks cramped from a couch.

This screen keeps a horizontal and vertical inset on the container:

// File: tv/RecipeTvCatalogScreen.kt
private val TvOverscanHorizontalMargin = 48.dp
private val TvOverscanVerticalMargin = 27.dp

That’s roughly a 5% inset on each side against a 1080p frame, the commonly cited safe margin for TV UI, applied to both the outer Column padding and the LazyRow's contentPadding, so the first and last card get the same breathing room as the title above them.

Testing without a physical TV

I don’t have a TV wired up to a dev machine for every change. The focus and click logic lives entirely in composables and TV-aware defaults, so what needs verifying is key event routing and focus traversal. The fastest way to do that is an Android Virtual Device running a TV system image, driven from the command line: Android Studio’s Device Manager has a dedicated TV category, and “Television (1080p)” with a recent Google TV or Android TV image boots with the right window size and density already configured.

adb shell input keyevent sends the exact same D-pad events a remote would, so you can script the whole navigation path instead of clicking around with a mouse that doesn't exist on the real device:

adb shell input keyevent KEYCODE_DPAD_RIGHT
adb shell input keyevent KEYCODE_DPAD_LEFT
adb shell input keyevent KEYCODE_DPAD_UP
adb shell input keyevent KEYCODE_DPAD_DOWN
adb shell input keyevent KEYCODE_DPAD_CENTER
adb shell input keyevent KEYCODE_BACK

Numeric codes work too (22, 21, 19, 20, 23, and 4), handy in a script where typos in symbolic names are easy to miss. Right and Left confirm focus moves card to card; Up and Down confirm the dead-end case; Center confirms the click path fires through onClick; Back confirms BackHandler only intercepts when there's something to dismiss. It's not a substitute for watching the app on an actual television, but it closes most of the gap between "I think this works" and "I've driven it with the input method a viewer will use."

A short review checklist before shipping

Before I call a TV screen done, I go through this list:

  • Something is focused the instant the screen appears, no key press required.
  • Every focusable element has a focus state visible from across a room, not just the default color shift alone.
  • Direction keys with nowhere to go don’t silently swallow input; focus stays visibly put instead.
  • Back is handled at the screen level, scoped to whether there’s something to dismiss, never unconditionally inside a leaf component.
  • Content respects safe margins on all edges, not just the ones visible in the emulator window.
  • A row that can lose and regain focus has Modifier.focusRestorer() attached, so re-entering it doesn't reset to the first item.
  • Direction, confirm, and back navigation have all been driven with adb shell input keyevent on a TV emulator.
  • spotlessCheck, test, and assembleDebug all pass before the change goes near a real or virtual TV.

If you want to try this yourself, take one LazyRow, wire a focusRequester on its first item, and leave border at its default so only the scale and color shift are active. Navigate it with an AVD's D-pad emulation, scripted over adb. You'll lose track of the focused item within thirty seconds. Then add a visible border back and watch the difference.

--

--

Rafael Meneghelo
Rafael Meneghelo

Written by Rafael Meneghelo

Android Developer writting about Android and Kotlin.