(Android TV) Advanced — Focus Requester Manipulation

Oleksii Tymoshchenko
8 min readJul 23, 2024

--

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 to focusProperties on an exit 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 calling onNavigateOut 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:

  1. Left Panel: This panel contains a set of buttons. When a user clicks on any of these buttons, the right panel updates accordingly.
  2. 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 to true 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:

  1. Create FocusRequesterModifiers
  2. Assign parentModifier
  3. Assign childModifier
  4. 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:

  1. Create FocusRequesterModifiers
  2. Assign parentModifier
  3. 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.

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

--

--

Oleksii Tymoshchenko

I have been developing for Android with Java and Kotlin from 2015.