Problem:
I'm developing an Android TV app using Jetpack Compose with ModalNavigationDrawer. When the drawer opens, I want the focus to automatically move to the currently selected NavigationDrawerItem (the one with selected = true).
Current Implementation:
@Composable
fun TestDrawerContent() {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val focusRequester = remember { FocusRequester() }
val coroutineScope = rememberCoroutineScope()
var selectedIndex by remember { mutableIntStateOf(0) }
val menuItems = listOf("Home", "Movies", "Series", "Settings")
val tabFocusRequesters = remember(menuItems) {
List(menuItems.size) { FocusRequester() }
}
val closeDrawerWidth = 80.dp
val backgroundContentPadding = 10.dp
SideEffect {
coroutineScope.launch {
focusRequester.requestFocus()
}
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
Column(
Modifier
.fillMaxHeight()
.padding(horizontal = 12.dp)
.selectableGroup()
.onFocusChanged { focusState ->
if (focusState.hasFocus) {
tabFocusRequesters[selectedIndex].requestFocus()
}
},
horizontalAlignment = Alignment.Start,
) {
menuItems.forEachIndexed { index, title ->
NavigationDrawerItem(
modifier = Modifier
.height(40.dp)
.focusRequester(tabFocusRequesters[index]),
selected = selectedIndex == index,
leadingContent = {
Icon(
imageVector = Icons.Default.Accessibility,
contentDescription = null
)
},
onClick = {
selectedIndex = index
coroutineScope.launch {
drawerState.setValue(DrawerValue.Closed)
focusRequester.requestFocus()
}
}
) {
Text(text = title)
}
}
}
}
) {
Box(
Modifier
.padding(closeDrawerWidth + backgroundContentPadding)
.focusRequester(focusRequester)
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
Text("Selected: ${menuItems[selectedIndex]}")
Row {
Card(onClick = {}) {
Icon(
modifier = Modifier.size(94.dp),
imageVector = Icons.Default.Accessibility,
contentDescription = null,
)
Text("Favorites TBD")
}
Card(onClick = {}) {
Icon(
modifier = Modifier.size(94.dp),
imageVector = Icons.Default.Accessibility,
contentDescription = null,
)
Text("Favorites TBD")
}
Card(onClick = {}) {
Icon(
modifier = Modifier.size(94.dp),
imageVector = Icons.Default.Accessibility,
contentDescription = null,
)
Text("Favorites TBD")
}
}
}
}
}
}
Issues I've encountered:
Using LaunchedEffect(drawerState.currentState) - This causes the drawer to close immediately after opening
Adding .focusable() to NavigationDrawerItem - This breaks the visual selection highlighting
Using onFocusChanged on Column - The focus request doesn't work consistently
What I want to achieve:
When the drawer opens, focus should automatically move to the item with selected = true
Visual highlighting of the selected item should remain intact
The drawer should stay open and allow normal D-pad navigation
When an item is selected, focus should return to the main content
Question:
What is the correct way to implement auto-focus on the selected NavigationDrawerItem when ModalNavigationDrawer opens in Android TV Compose, without breaking the visual selection or causing the drawer to close unexpectedly?
Environment:
Any working examples or alternative approaches would be greatly appreciated!
I will try to answer each of your questions separately.
The common UX pattern for tabs or navigation on a tv device is to switch on-focus instead of asking the user to click on the Tab/Navigation item. Focus restoration in this is simple by just using the focusRestorer
modifier. Note: a non-lazy list would also require the focusGroup
modifier after the focusRestorer
.
// When no item was previously focused.
// Assign this to whichever item you want to gain focus the first time.
val fallbackFocusItem = remember { FocusRequester() }
Column(
modifier = Modifier
.selectableGroup()
.focusRestorer { fallbackFocusItem }
.focusGroup()
) {
NavigationDrawerItem(Modifier.focusRequester(fallbackFocusItem))
NavigationDrawerItem()
NavigationDrawerItem()
// ...
}
Now, when you move focus between the drawer and the main content, the drawer will correctly restore the focus to the last focused item.
However, your use case is a bit different. You want the navigation to change on click of item. So, it is possible that the user moves focus to some other item, but without selecting the item, they close the drawer. Now, if you open the drawer again, the focus will move to the last focused item instead of the last selected item. This complicates the focus restoration.
The focusRestorer
API is built around user-driven focus navigation instead of programmatically choosing the focused item. For your use case, you will need to manually manage the restoration of focus requester using the focusProperties
and focusGroup
modifier.
data class Item(val title: String, val focusRequester: FocusRequester)
@Composable
fun List() {
// This should be assigned to the main content
val mainContentFocusRequester = remember { FocusRequester() }
val items = remember {
listOf(
Item("Home", FocusRequester()),
Item("Settings", FocusRequester()),
Item("Favourites", FocusRequester()),
)
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
Column(Modifier
.selectableGroup()
.focusProperties {
exit = { mainContentFocusRequester }
enter = { items[selectedIndex].focusRequester }
}
.focusGroup(),
) {
// ...
}
}
) {
// ...
}
}
This happened because you assigned a focusable
modifier to the the NavigationDrawerItem
. NavigationDrawerItem
internally assigns a combined focus + click modifier and the your focusable
modifier causes conflict with that. Whenever you see a library composable that has a onClick
parameter, you should not assign any of focusable
or clickable
modifiers.
There is a bug in the focus engine where you can't move focus (using D-pad) between 2 overlapping surfaces (different z indexes) when all items of 1 surface are hidden behind the other. A temporary workaround is to use the onKeyEvent
to close the navigation drawer and use the focusManager
to move focus between drawer and main content.
NavigationDrawerItem(
selected = selectedIndex == index,
modifier = Modifier
.focusRequester(item.focusRequester)
.onKeyEvent {
if (it.key.keyCode == Key.DirectionRight.keyCode) {
drawerState.setValue(DrawerValue.Closed)
mainContentFocusRequester.requestFocus()
true
} else {
false
}
},
) {
Text(item.title)
}
You can use the same solution as done in onKeyEvent
modifier to move focus to main content after closing the drawer.
NavigationDrawerItem(
selected = selectedIndex == index,
onClick = {
selectedIndex = index
drawerState.setValue(DrawerValue.Closed)
mainContentFocusRequester.requestFocus()
},
) {
Text(item.title)
}
data class Item(
val title: String,
val icon: ImageVector,
val focusRequester: FocusRequester
)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NavDrawer() {
val mainContentFocusRequester = remember { FocusRequester() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
var selectedIndex by remember { mutableIntStateOf(0) }
val items = remember {
listOf(
Item(title = "Home", icon = Icons.Default.Home, focusRequester = FocusRequester()),
Item(
title = "Settings",
icon = Icons.Default.Settings,
focusRequester = FocusRequester()
),
Item(
title = "Favourites",
icon = Icons.Default.Favorite,
focusRequester = FocusRequester()
),
)
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.fillMaxHeight()
.padding(12.dp)
.selectableGroup()
.focusProperties {
exit = { mainContentFocusRequester }
enter = { items[selectedIndex].focusRequester }
}
.focusGroup(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
) {
items.forEachIndexed { index, item ->
key(item.title) {
NavigationDrawerItem(
selected = selectedIndex == index,
onClick = {
selectedIndex = index
drawerState.setValue(DrawerValue.Closed)
mainContentFocusRequester.requestFocus()
},
modifier = Modifier
.focusRequester(item.focusRequester)
.onKeyEvent {
if (it.key == Key.DirectionRight) {
drawerState.setValue(DrawerValue.Closed)
mainContentFocusRequester.requestFocus()
true
} else {
false
}
},
leadingContent = {
Icon(
imageVector = item.icon,
contentDescription = null
)
},
) {
Text(item.title)
}
}
}
}
}
) {
Row(
Modifier
.fillMaxSize()
.padding(start = 80.dp + 10.dp, top = 20.dp)
.focusRequester(mainContentFocusRequester)
) {
Button(onClick = {}) { Text("BUTTON") }
}
}
}