Focus restoration on navigation popBackStack and focus restoration using state Modifiers

851 Views Asked by At

In the JetStream tv app sample, it has a createInitialFocusRestorerModifiers() function which acts as the focus restorers of a TvLazyRow/Column for its children.

As said from the function's KDoc:

Returns a set of modifiers [FocusRequesterModifiers] which can be used for restoring focus and specifying the initially focused item.

Its usage:

  LazyRow(modifier.then(modifiers.parentModifier) {
   item1(modifier.then(modifiers.childModifier) {...}
   item2 {...}
   item3 {...}
   ...
  }

NestedLazyList composables The proper behavior should be the following image. Pressing DPad down should focus to the first item, and up should bring to the last saved state focus child.

Though, the problem here is it does not restore focus on navigation [popBackStack()] so I tried to integrate the said function with this answer.

Current code:

        // var anItemHasBeenFocused: Boolean = false
        // var lastFocusedItem: Pair<Int, Int> = ...

        TvLazyRow(
            modifier = focusRestorerModifiers.parentModifier,
            pivotOffsets = PivotOffsets(parentFraction = 0F)
        ) {
            itemsIndexed(items = items) { columnIndex, item ->
                val focusRequester = remember { FocusRequester() }
                
                RowItem(
                    modifier = Modifier
                        .focusRequester(focusRequester)
                        .ifElse(
                            condition = columnIndex == 0,
                            ifTrueModifier = focusRestorerModifiers.childModifier
                        )
                        .onPlaced {
                            val shouldFocusThisItem = lastFocusedItem.first == rowIndex
                                && lastFocusedItem.second == columnIndex
                                && !anItemHasBeenFocused

                            if (shouldFocusThisItem) {
                                // The stacktrace points here.
                                focusRequester.requestFocus()
                            }
                        }
                        .onFocusChange {
                            if(it.isFocused) {
                                onFocusedChange(item, columnIndex)
                            }
                        }
                        .focusProperties {
                            if (rowIndex == 0) {
                                up = FocusRequester.Cancel
                            } else if (isLastIndex) {
                                down = FocusRequester.Cancel
                            }
                        },
                    item = item,
                    isLastIndex = columnIndex == items.lastIndex,
                    onClick = onNavigateToSecondScreen
                )

        }

The code worked fine on the first item only, I was able to go back without any errors from the second screen after navigating thru the first item.

When I tried to scroll to the end item of the list - making the first item hidden from the current view - and tried to go to my second screen and immediately pop back from it, the app constantly throws:

   FocusRequester is not initialized. Here are some possible fixes:
   1. Remember the FocusRequester: val focusRequester = remember { FocusRequester() }
   2. Did you forget to add a Modifier.focusRequester() ?
   3. Are you attempting to request focus during composition? Focus requests should be made in
   response to some event. Eg Modifier.clickable { focusRequester.requestFocus() }

Question: How do I achieve this kind of focus behavior? Being able to combine both initial state focus restorers and navigation state focus restoring?

1

There are 1 best solutions below

3
On BEST ANSWER

1. Update to Latest builds/versions to avoid dealing with issues which might have already been fixed

Since the build id in your repository is severely out of date, I would first recommend that you

  • Either switch to either the latest alpha version (1.0.0-alpha08 as of today)
  • If you want to continue using snapshot versions, you can update the build id in the settings.gradle.kts. Checkout latest working androidx snapshot builds for finding the latest build ids.
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { 
            val buildId = "10639124"
            url = URI.create(
                "https://androidx.dev/snapshots/builds/$buildId/artifacts/repository"
            ) 
        }
    }
}

2. Coming to your question (WORKAROUND)

Note: Since the focus restoration API is slightly new and experimental, the usage is constantly evolving. I will try to keep this answer update-to-date when a better usage comes up. There is also a bug in focus restoration which prevents remembering focus across nested lazy containers. Once it gets resolved, we won't have to do the following tedious stuff to transfer focus to the last focused item.

Instead of passing down lastFocusedItem state to all the child components, we can make use of CompositionLocal to store the stuff. Following are some sample utility composition locals that we will make use of, in the demo below.

private val LocalLastFocusedItemPerDestination = compositionLocalOf<MutableMap<String, String>?> { null }
private val LocalFocusTransferredOnLaunch = compositionLocalOf<MutableState<Boolean>?> { null }
private val LocalNavHostController = compositionLocalOf<NavHostController?> { null }

@Composable
fun LocalLastFocusedItemPerDestinationProvider(content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalLastFocusedItemPerDestination provides remember { mutableMapOf() }, content = content)
}

@Composable
fun LocalFocusTransferredOnLaunchProvider(content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalFocusTransferredOnLaunch provides remember { mutableStateOf(false) }, content = content)
}

@Composable
fun LocalNavHostControllerProvider(navHostController: NavHostController, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalNavHostController provides navHostController, content = content)
}

@Composable
fun useLocalLastFocusedItemPerDestination(): MutableMap<String, String> {
    return LocalLastFocusedItemPerDestination.current ?: throw RuntimeException("Please wrap your app with LocalLastFocusedItemPerDestinationProvider")
}

@Composable
fun useLocalFocusTransferredOnLaunch(): MutableState<Boolean> {
    return LocalFocusTransferredOnLaunch.current ?: throw RuntimeException("Please wrap your app with LocalLastFocusedItemPerDestinationProvider")
}

@Composable
fun useLocalNavHostController(): NavHostController {
    return LocalNavHostController.current ?: throw RuntimeException("Please wrap your app with LocalNavHostControllerProvider")
}

With the above utilities in place, we can now create a modifier which would focus the item when the Nav Destination is launched. In this modifier, we will check if this was the last focused item before the Nav destination was changed, and if yes, we will request focus onto this item.

@Composable
fun Modifier.focusOnMount(itemKey: String): Modifier {
    val focusRequester = remember { FocusRequester() }
    val isInitialFocusTransferred = useLocalFocusTransferredOnLaunch()
    val lastFocusedItemPerDestination = useLocalLastFocusedItemPerDestination()
    val navHostController = useLocalNavHostController()
    val currentDestination = remember(navHostController) { navHostController.currentDestination?.route }

    return this
        .focusRequester(focusRequester)
        .onGloballyPositioned {
            val lastFocusedKey = lastFocusedItemPerDestination[currentDestination]
            if (!isInitialFocusTransferred.value && lastFocusedKey == itemKey) {
                focusRequester.requestFocus()
                isInitialFocusTransferred.value = true
            }
        }
        .onFocusChanged {
            if (it.isFocused) {
                lastFocusedItemPerDestination[currentDestination ?: ""] = itemKey
                isInitialFocusTransferred.value = true
            }
        }
}

Usage of this modifier is pretty straight forward. Wrap the screens with the utility providers we created above and assign the focusOnMount modifier to all the focusable items (like buttons, cards, tabs, etc.) on your page.

Notice that LocalFocusTransferredOnLaunchProvider is provided separately at each screen level.

@Composable
fun App() {
    val navController = rememberNavController()

    LocalNavHostControllerProvider(navController) {
        LocalLastFocusedItemPerDestinationProvider {
            NavHost(navController = navController, startDestination = "home") {
                composable("home") {
                    LocalFocusTransferredOnLaunchProvider {
                        HomePage()
                    }
                }
                composable("movie") {
                    LocalFocusTransferredOnLaunchProvider {
                        Button(
                            modifier = Modifier.focusOnMount("home button"),
                            onClick = { navController.popBackStack() }
                        ) {
                            Text("Home")
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun HomePage() {
    TvLazyColumn {
        items(15) { row ->
            val navController = useLocalNavHostController()
        
            Column {
                Text(text = "Row $row")
        
                val focusRestorerModifiers = createInitialFocusRestorerModifiers()
        
                TvLazyRow(modifier = focusRestorerModifiers.parentModifier) {
                    items(15) { column ->
                        val key = "row=$row, column=$column"
                        key(key) {
                            Card(
                                modifier = Modifier
                                    .ifElse(
                                        condition = column == 0,
                                        ifTrueModifier = focusRestorerModifiers.childModifier
                                    )
                                    .focusOnMount(key),
                                onClick = { navController?.navigate("movie") }
                            ) {}
                        }
                    }
                }
            }
        }
    }
}

Notice the usage of key and key composable. When using focus restoration, wrapping your focusable item in a key composable is important.

You might also observe that when the app first launches, nothing is focused. To fix this, you can update the LocalLastFocusedItemPerDestinationProvider to initialise with the keys of items that should get focus initially.

@Composable
fun LocalLastFocusedItemPerDestinationProvider(content: @Composable () -> Unit) {
    CompositionLocalProvider(
        LocalLastFocusedItemPerDestination provides remember {
            mutableMapOf(
                "home" to "row=0, column=0",
                "movie" to "home button"
            )
        },
        content = content
    )
}