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 {...}
...
}
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?
Since the build id in your repository is severely out of date, I would first recommend that you
1.0.0-alpha08
as of today)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"
)
}
}
}
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 akey
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
)
}