android-jetpack-composeandroid-tvandroid-jetpack-compose-tv

Android TV Compose: How to auto-focus selected NavigationDrawerItem when ModalNavigationDrawer opens


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:

  1. Using LaunchedEffect(drawerState.currentState) - This causes the drawer to close immediately after opening

  2. Adding .focusable() to NavigationDrawerItem - This breaks the visual selection highlighting

  3. Using onFocusChanged on Column - The focus request doesn't work consistently

What I want to achieve:

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!


Solution

  • I will try to answer each of your questions separately.

    Q1. When the drawer opens, focus should automatically move to the item with selected = true

    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(),
                ) {
                    // ...
                }
            }
        ) {
            // ...
        }
    }
    

    Q2. Visual highlighting of the selected item should remain intact

    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.

    Q3. The drawer should stay open and allow normal D-pad navigation

    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)
    }
    

    Q4. When an item is selected, focus should return to the main content

    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)
    }
    

    Please find the complete working sample below:

    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") }
            }
        }
    }