Focus Handling on Android TV: The 10-Foot UI Nobody Tests
On Android TV there is no touch — focus is the entire navigation model. The four failure modes that make a ported phone app unusable on a television, how to fix them in Compose for TV, and the test that costs nothing: unplug your mouse.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — On television there is no pointer. Every interaction is move focus, then confirm, so focus is the navigation model. The four failures that make ported apps unusable: focus landing nowhere after async content loads, focus escaping a row, focus that isn't visible from three metres, and nothing focused on screen entry. All are invisible in a phone-shaped test plan. The cheapest test in existence: unplug your mouse and navigate with the D-pad only.
Why this is the defining TV problem
A phone app assumes the user can touch any pixel. Porting it to TV replaces that with four arrow keys and a confirm button. Every screen must now answer questions it never had to:
- What is focused when this screen opens?
- Where does focus go from here, in each of four directions?
- What happens to focus when this content changes underneath it?
None of these produce a crash when answered badly. They produce an app where the user cannot reach the play button — which is worse, because your monitoring will never show it.
Failure 1: focus falls into the void
The most common and most damaging:
1. Screen opens, rows are still loading.
2. Focus is placed on a placeholder item.
3. Real data arrives, the list is rebuilt, that item no longer exists.
4. Focus is now on nothing. Every key press does nothing.
The app looks frozen. Users press buttons harder, then leave. On phones the equivalent bug is invisible, because touch doesn't depend on a persistent focus target.
The fix is to make focus survive content changes:
TvLazyRow(
modifier = Modifier.focusRestorer(), // remembers and restores the focused child
) {
items(items, key = { it.id }) { item -> // stable keys are essential
MovieCard(item)
}
}
Stable keys matter as much as focusRestorer() — without them the list can't recognise that an item
persisted across the update, so there's nothing to restore to.
Failure 2: focus escapes the row
Pressing RIGHT at the last item of a carousel should stop, not leap into the navigation drawer. The default search finds whatever is geometrically to the right, which on a 10-foot layout is frequently something unrelated.
Constrain it explicitly:
Row(
modifier = Modifier.focusProperties {
right = FocusRequester.Cancel // stop at the end of the row
}
) { /* items */ }
Or direct it deliberately when a jump is correct:
Modifier.focusProperties { down = playButtonRequester }
The principle: on TV, specify the focus graph rather than inheriting it from geometry. Geometry is a reasonable default for a form; it's a poor one for a media browse screen.
Failure 3: focus you can't see
This one is a design failure that engineers usually notice first, because we're the ones testing at 50cm on a monitor.
At three metres, a 2dp border is invisible. TV focus indication needs to be much louder than feels comfortable on a desk:
Card(
scale = CardDefaults.scale(focusedScale = 1.1f),
border = CardDefaults.border(
focusedBorder = Border(BorderStroke(3.dp, MaterialTheme.colorScheme.onSurface)),
),
colors = CardDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant),
)
Scale and border and a background change. Any one alone is unreliable across the range of TV panels, viewing distances and picture modes your users actually have — including the ones with "vivid" mode crushing your contrast.
Test at real distance. Stand up, walk back, look at it. It takes thirty seconds and it's the only honest check.
Failure 4: nothing focused on entry
Never assume the system will pick a sensible initial target. Claim it:
val firstItem = remember { FocusRequester() }
LaunchedEffect(Unit) { firstItem.requestFocus() }
MovieRow(modifier = Modifier.focusRequester(firstItem))
Choose the target deliberately: on a detail screen that's Play, not the back button. On a browse screen it's the first item of the first row, or the item the user came from.
The test that costs nothing
Unplug your mouse. Navigate your entire app with the D-pad only.
Emulator users: use the arrow keys and disable pointer input. Everything you cannot reach, your users cannot reach — and unlike on a phone, they have no fallback.
For regression coverage, drive keys in an instrumented test:
@Test fun `play button is reachable from the first row`() {
composeRule.onNodeWithTag("row_0_item_0").requestFocus()
repeat(3) { composeRule.onRoot().performKeyPress(NativeKeyEvent.KEYCODE_DPAD_DOWN) }
composeRule.onNodeWithTag("play").assertIsFocused()
}
Focus paths are exactly the kind of thing that breaks during an unrelated layout change, so a handful of these earn their keep.
The reframe
On phone, layout is the design and focus is an accessibility concern. On TV, focus is the design — it's the only way anyone moves through your app. Give the focus graph the same deliberate attention you'd give the visual hierarchy, and most of the "this app feels wrong on TV" complaints disappear at once.