(Android TV) Advanced — Focus Requester Manipulation
Before we start, I would like to acknowledge Mark Murillo as a person who engaged a lot in making this example work properly.
Disclaimer: Since the focus restoration API is relatively new and still experimental, its usage is continually evolving. It’s highly recommended to update to the latest builds or versions to avoid encountering issues that may have already been resolved. Currently, there is a known bug in the focus restoration that prevents it from remembering focus across nested lazy containers. In addition, there is a bug in the
FocusRequester
implementation related tofocusProperties
on anexit
event, it is supposed to be called when a user navigates out from the screen, so to make it work properly it is necessary to invoke it manually by callingonNavigateOut
in the implementation below. Once this issue is resolved, you won’t need to perform the following tedious steps to transfer focus to the last focused item.
In Jetpack Compose, managing focus is crucial for providing a seamless user experience, especially when dealing with navigation between composables. In this article, I will discuss potential challenges developers may encounter when working with FocusRequester and provide solutions to address them. To illustrate these points, I created a minimal code example that concentrates on the topic at hand.
Let’s begin by examining the main screen (which I will reference throughout this post):
Screen Description
The screen is divided into two panels:
- Left Panel: This panel contains a set of buttons. When a user clicks on any of these buttons, the right panel updates accordingly.
- Right Panel: This panel displays several buttons corresponding to the option selected on the left panel. Clicking any of these buttons navigates the user to a new screen.
The screen flow is straightforward. Users are expected to click a button on the left panel, then shift their focus to the right panel where they can select an option to navigate to a new screen. It’s important to note that on Android TV, users navigate through the screen using a remote control, moving the focus left, right, up, and down.
Expected Results
The goals for this implementation are:
1. Initial Focus Movement: When a user first moves focus from the left panel to the right panel, the first item in the right panel should gain focus (take into account that by default, the focus would move to the closest item on the screen). For example, if a user is on Left Panel 2 and clicks right, the default behaviour would move focus to Right Panel 2. We will change this so that the focus moves to Right Panel 0, as expected.
2. Focus Memory Between Panels: When a user moves focus between the left and right panels, the focus should return to the last focused item on that panel. For instance, if a user is on Left Panel 3 and moves focus to Right Panel 0, then navigates to Right Panel 2, and finally clicks the left arrow, the focus should return to Left Panel 3 (by default it would return to the closest item in the Left Panel which is Left Panel 2).
3. Back Button Behavior: When a user on the Right Panel clicks the back button, the focus should return to the Left Panel, specifically to the position it last occupied.
4. Navigation to New Screens: When a user on the Right Panel clicks a button, it navigates them out to a new screen. When the back button is pressed on the new screen, it closes the screen, and the user navigates back to the panels’ screen, with a focus on the button that was clicked before leaving (in the example above it is a RightPanel: 0).
============================================================
I believe these cases are the most common and even though such focus behaviour might be expected to work seamlessly, in practice, you are likely to encounter a few pitfalls. Let’s address these issues one by one and develop a solution.
Solution
To achieve the desired functionality, we will utilize the FocusRequester
and its focusProperties
, specifically the enter
and exit
properties. All of this will be encapsulated to allow for easy reuse.
class FocusRequesterModifiers private constructor(
val parentModifier: Modifier,
val parentFocusRequester: FocusRequester,
val childModifier: Modifier,
val childFocusRequester: FocusRequester,
val needsRestore: MutableState<Boolean>
) {
// Whenever we have a navigation event, need to call this before actually navigating.
@OptIn(ExperimentalComposeUiApi::class)
fun onNavigateOut() {
needsRestore.value = true
parentFocusRequester.saveFocusedChild()
}
companion object {
/**
* Returns a set of modifiers [FocusRequesterModifiers] which can be used for restoring focus and
* specifying the initially focused item.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun create(
parentFocusRequester: FocusRequester = FocusRequester(),
onComposableFocusEntered: (() -> Unit)? = null,
onComposableFocusExited: (() -> Unit)? = null
): FocusRequesterModifiers {
val focusRequester = remember { parentFocusRequester }
val childFocusRequester = remember { FocusRequester() }
val needsRestore = rememberSaveable { mutableStateOf(false) }
val parentModifier = Modifier
.focusRequester(focusRequester)
.focusProperties {
exit = {
onComposableFocusExited?.invoke()
focusRequester.saveFocusedChild()
FocusRequester.Default
}
enter = {
onComposableFocusEntered?.invoke()
if (focusRequester.restoreFocusedChild()) { FocusRequester.Cancel }
else { childFocusRequester }
}
}
val childModifier = Modifier.focusRequester(childFocusRequester)
LifecycleEventObserver { event: Lifecycle.Event ->
if (event == Lifecycle.Event.ON_RESUME) {
if (needsRestore.value) {
childFocusRequester.requestFocus()
needsRestore.value = false
}
}
}
return FocusRequesterModifiers(
parentModifier,
focusRequester,
childModifier,
childFocusRequester,
needsRestore
)
}
}
}
The FocusRequesterModifiers Class
The FocusRequesterModifiers
class encapsulates the focus behavior and provides a reusable set of modifiers to manage focus transitions and state restoration.
Key Components and Their Roles
1) Constructor Parameters:
- parentModifier:
Modifier
that handles focus for the parent composable. - parentFocusRequester:
FocusRequester
for the parent composable. - childModifier:
Modifier
that handles focus for the child composable. - childFocusRequester:
FocusRequester
for the child composable. - needsRestore:
MutableState<Boolean>
to track whether focus needs to be restored.
2) onNavigateOut Method:
- This method should be called before navigating away from the current composable. It sets
needsRestore
totrue
and saves the state of the currently focused child composable.
3) Companion Object and create Method:
- The
create
method initializes the necessary focus requesters and modifiers, and sets up focus properties to handle entering and exiting focus states.
Focus Properties Explained
1) Exit:
- When focus exits the parent composable,
onComposableFocusExited
is invoked if provided. - The
focusRequester.saveFocusedChild()
method saves the currently focused child’s state. - The focus is then set to the default
FocusRequester
.
2) Enter:
- When focus enters the parent composable,
onComposableFocusEntered
is invoked if provided. - The method attempts to restore the previously focused child using
focusRequester.restoreFocusedChild()
. - If successful, it cancels the default focus behaviour. If not, it directs the focus to the
childFocusRequester
.
Usage:
Right panel:
@Composable
fun RowScope.RightPanel(onClick: () -> Unit) {
// 1 - Create FocusRequesterModifiers
val focusModifiers = FocusRequesterModifiers.create()
val buttons: List<String> by rememberSaveable { mutableStateOf(List(4) { "Button ${it + 1}" }) }
Column(
modifier = focusModifiers
.parentModifier // 2 - Assign parentModifier
.background(Color.Green.copy(alpha = 0.1f))
.fillMaxHeight()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
LazyVerticalGrid(
modifier = Modifier.padding(16.dp),
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(
items = buttons,
key = { idx, _ -> idx }
) { idx, _ ->
Button(
modifier = Modifier
.padding(8.dp)
.let { modifier ->
if (idx == 0) focusModifiers.childModifier // 3- Assign childModifier
else modifier
},
onClick = {
// 4- invoke onNavigateOut
focusModifiers.onNavigateOut()
onClick()
}
) {
Text(text = "Right Panel: $idx")
}
}
}
}
}
(as mentioned in the comments above) Key points of usage here are:
- Create FocusRequesterModifiers
- Assign parentModifier
- Assign childModifier
- once a user leaves the screen it is necessary to invoke
onNavigateOut
Left Panel:
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun RowScope.LeftPanel() {
val buttons: List<String> by rememberSaveable { mutableStateOf(List(5) { "Button ${it + 1}" }) }
val isPaneFocused = rememberSaveable { mutableStateOf(true) }
// 1 - Create FocusRequesterModifiers
val focusModifiers = FocusRequesterModifiers.create(
onComposableFocusEntered = { isPaneFocused.value = true },
onComposableFocusExited = { isPaneFocused.value = false }
)
BackHandler(enabled = !isPaneFocused.value) {
focusModifiers.childFocusRequester.requestFocus()
}
LifecycleEventObserver { event: Lifecycle.Event ->
if (event == Lifecycle.Event.ON_RESUME){
if (isPaneFocused.value
&& !focusModifiers.needsRestore.value
&& !focusModifiers.parentFocusRequester.restoreFocusedChild()) {
focusModifiers.childFocusRequester.requestFocus()
}
}
}
LazyColumn(
modifier = focusModifiers.parentModifier // 2 - Assign parentModifier
.background(Color.Blue.copy(alpha = 0.1f))
.fillMaxHeight()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
itemsIndexed(
items = buttons,
key = { idx, _ -> idx }
) { idx, _ ->
Button(
modifier = Modifier
.let { modifier ->
if (idx == 0) {
focusModifiers.childModifier // 3 - Assign childModifier
} else {
modifier
}
},
onClick = { /* nothing */ }
) {
Text(text = "Left Panel: $idx")
}
}
}
}
(as mentioned in the comments above) Key points of usage here are:
- Create FocusRequesterModifiers
- Assign parentModifier
- Assign childModifier
Also, pay attention that here we are using two callbacks in the create
method, it is necessary to deal with back click properly (as described above in case 3 Back Button Behavior). In addition, in the left panel, we are using LifecycleEventObserver
to bring the focus to the first item in the list once a user opens the screen for the first time.
P.S. It has to be mentioned that all of this is necessary as there is a bug in the FocusRequester
implementation related to focusProperties
on an exit
event, it is supposed to be called once a user navigating out from the screen, so to make it work properly it is necessary to invoke it manually like we did in the implementation above in onNavigateOut
.
Conclusion
By leveraging FocusRequester
and focusProperties
, and encapsulating this functionality in the FocusRequesterModifiers
class, we can create a robust focus management system in Jetpack Compose. This approach simplifies the handling of focus states, making our UI more intuitive and responsive to user interactions.
Full Code could be found on my GitHub: https://github.com/alekseytimoshchenko/FocusRequesterExample
✋ Thanks for taking the time to read through my article. If you found something to be not quite right or have other information to add please reach out in the comments section below. If you enjoyed this article, please click on the clap icon a few times or share it on social media (or both).