Focus Management in Compose

AndroidCompose
Share on LinkedIn

Building a Compose app for Android TV without explicit focus management ships a UI that looks correct on your monitor and feels broken on a couch. D-pad users tab through focusable elements in traversal order—not visual order—and when that order jumps from the sidebar to row seventeen of a grid, users leave. Focus on TV is not an accessibility add-on; it is the primary navigation model.

Focusable modifiers

Make composables focusable with Modifier.focusable() or clickable variants that include focus:

Box(
    modifier = Modifier
        .focusable()
        .onFocusChanged { state ->
            isFocused = state.isFocused
        }
        .border(
            width = if (isFocused) 2.dp else 0.dp,
            color = MaterialTheme.colorScheme.primary,
        )
        .clickable { onSelect() },
) {
    Text(title)
}

TV Material components (androidx.tv.material3) provide Card and Button with built-in focus scaling and glow—prefer them over phone Material on leanback.

FocusRequester for initial and restored focus

@Composable
fun DetailScreen(initialFocusId: String?) {
    val focusRequesters = remember { mutableMapOf<String, FocusRequester>() }

    LaunchedEffect(initialFocusId) {
        initialFocusId?.let { id ->
            focusRequesters[id]?.requestFocus()
        }
    }

    LazyRow {
        items(categories, key = { it.id }) { category ->
            val requester = remember { FocusRequester() }
            focusRequesters[category.id] = requester

            CategoryChip(
                category = category,
                modifier = Modifier.focusRequester(requester),
            )
        }
    }
}

Request focus after layout—LaunchedEffect or onGloballyPositioned first frame, not in composable body.

Custom focus traversal

Override default search with focus properties:

Modifier.focusProperties {
    // When pressing RIGHT from this item, focus this specific target
    right = nextItemRequester
    up = sidebarRequester
    // Prevent focus from leaving grid at edges
    canFocus = isEnabled
}

For grids, broken row-major order is the top complaint. Align traversal with visual rows:

Modifier.focusProperties {
    next = { direction ->
        when (direction) {
            FocusDirection.Right -> itemAt(row, col + 1)?.focusTarget
            FocusDirection.Down -> itemAt(row + 1, col)?.focusTarget
            else -> FocusDirection.Next
        }
    }
}

focusRestorer on back stack

Navigation Compose back stack loses focus by default. Save and restore:

@Composable
fun CatalogScreen() {
    var lastFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
    val rowFocusRequester = remember { FocusRequester() }

    LazyColumn(
        modifier = Modifier.focusRestorer {
            rowFocusRequester.saveFocusedChild()
        },
    ) {
        itemsIndexed(items) { index, item ->
            ItemRow(
                item = item,
                modifier = Modifier
                    .focusRequester(if (index == lastFocusedIndex) rowFocusRequester else FocusRequester())
                    .onFocusChanged {
                        if (it.isFocused) lastFocusedIndex = index
                    },
            )
        }
    }
}

focusRestorer (Compose 1.7+) replays saved focus when returning to a composable subtree.

Lazy lists and focus

LazyRow/LazyColumn recycle items—FocusRequester instances must be keyed to stable item IDs, not index:

items(items, key = { it.id }) { item ->
    val requester = remember(item.id) { FocusRequester() }
    // ...
}

Bringing focused item into view when focus moves programmatically:

LaunchedEffect(focusedIndex) {
    listState.animateScrollToItem(focusedIndex)
    focusRequesters[focusedIndex]?.requestFocus()
}

Dialogs and overlays

When a modal opens, trap focus inside and return on dismiss:

Dialog(onDismissRequest = onDismiss) {
    Box(
        modifier = Modifier
            .focusGroup()
            .onPreviewKeyEvent { event ->
                if (event.key == Key.Back) {
                    onDismiss()
                    true
                } else false
            },
    ) {
        val firstButton = remember { FocusRequester() }
        LaunchedEffect(Unit) { firstButton.requestFocus() }
        // dialog content
    }
}

focusGroup() keeps D-pad traversal within the dialog boundary.

Testing focus

Compose UI tests with performKeyPress(Key.DirectionRight) and assert onNodeWithTag("item-2").assertIsFocused(). For TV, run on Android TV emulator with hardware keyboard mapped to D-pad.

Phone and tablet crossover

Large-screen and foldable apps benefit from the same focus patterns when users attach keyboard or use stylus hover. Modifier.hoverable() pairs with focus for desktop-mode Chromebooks running Android apps.

Focus order customization

Override default focus traversal order for non-linear layouts:

Row(modifier = Modifier.focusGroup()) {
    Box(
        modifier = Modifier
            .focusRequester(item3Focus)
            .focusProperties { next = item1Focus }
    ) { Item3() }
    Box(modifier = Modifier.focusRequester(item1Focus)) { Item1() }
    Box(modifier = Modifier.focusRequester(item2Focus)) { Item2() }
}

Use focusProperties { next = ...; previous = ... } to define custom traversal. Essential for TV grids where visual layout doesn't match logical reading order.

FocusRestorer for temporary focus loss

When a dialog closes or a snackbar dismisses, restore focus to the previously focused element:

var focusedElement by remember { mutableStateOf<FocusRequester?>(null) }

Box(modifier = Modifier.onFocusChanged { state ->
    if (state.isFocused) focusedElement = currentFocusRequester
}) { /* content */ }

// On dialog dismiss:
LaunchedEffect(dialogVisible) {
    if (!dialogVisible) focusedElement?.requestFocus()
}

Without focus restoration, D-pad users land on an unexpected element after dialog close — disorienting on TV.

Leanback vs Compose TV

Android TV Leanback library (XML-based) is legacy — Compose TV is the forward path:

Leanback Compose TV
UI framework XML Views Compose
Focus handling Built-in browse/ details Manual FocusRequester
Maintenance Maintenance mode Active development
Custom layouts Difficult Full Compose flexibility

New TV apps should use Compose TV. Existing Leanback apps migrate screen-by-screen to Compose.

Failure modes

Production checklist

Common production mistakes

Teams get focus management tv wrong in predictable ways:

Compose UI work on focus management tv janks when recomposition scope is too wide, remember keys omit stable inputs, and semantics for TalkBack are added only after QA finds focus traps.

Resources

Frequently asked questions

How does focus work differently on Android TV vs phone?

TV apps have no touch input—every interactive element must be reachable via D-pad directional navigation. Compose assigns focus to one focused composable at a time, drawing a focus highlight. You must define logical focus traversal order; the system cannot infer grid layout intent from visual placement alone without focus properties or custom focus search.

What is FocusRequester used for?

FocusRequester programmatically moves focus to a composable—on screen entry, after closing a dialog, or when a row expands. Call requestFocus() from LaunchedEffect or a callback, not during composition. Each focusable composable needs exactly one FocusRequester attached via Modifier.focusRequester.

How do I restore focus after navigating back?

Use Modifier.focusRestorer() or save the focused item key in rememberSaveable and request focus on that item when the screen recomposes. Without restoration, back navigation often lands focus on the first focusable element, disorienting TV users who expect focus where they left off.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →