Focus as a state - new effective Android TV focus management system with Jetpack Compose
Focus is the main thing that differs TV from mobile as we don't have touch support on TV. In this article I'm presenting a comprehensive solution to focus management with Jetpack Compose on TV.
If you have ever written any TV app you should be familiar with all the pitfalls of Android focus management. API is inconsistent and developing for TV is not an easy or pleasant walk. First of all, let’s define the problem and take a brief look at some solutions you may find on the Internet.
Problem definition
When mobile Android developer starts writing TV apps they are very disappointed with the fact the focus doesn’t work out of the box in the way we want it to work.
The most common issues are:
focus management in the scope of one screen (which UI blocks are focusable, how the focus should traverse across the blocks, etc)
focus management across the screens (for example, focus restoration after returning to an already visited screen)
Archiving correct behaviour in all cases can be a tedious task. Later be ready to have a lot of focus issue reports once the app goes into production.
Existing solutions
Now when we understand the problem, let’s briefly look into existing solutions.
Leanback
Leanback is an umbrella term for a set of AndroidX libraries that aim to help develop TV apps. We can use TV versions of widgets to fix the focus behaviour, for example, BrowseFrameLayout. It does not help to fix all focus issues though, you have to go through a lot of customization, optimization, and hacks to make it work as you want.Jetpack Compose for TV
Compose for TV aims to adapt regular Compose libraries to make it work on TV frictionless. There are a few issues though. First of all, these libraries are behind the regular ones. It means something that is already fixed in the regular version will come to TV only in half a year or so. Secondly, they are still in the early development stage, making the Compose TV experience like developing on regular Compose a year before public release.
Ideation behind a new solution
Ideally, one would like to work with regular Jetpack Compose libraries as they are up-to-date. Fortunately, this is possible. All that is needed is to include Compose libraries in the build.gradle or libs.versions.toml file. However, there is a lack of comprehensive focus management system that helps us to solve problems described in the Problem definition block of this article.
The main issue with Compose is that focus is not a part of the state. It means you have to use modifiers such as focusRequester, onFocusChanged, focusable, focusProperties, and others to understand what is focused, add proper UI reaction to focus change and customize focus traversal order. Focus in Compose documentation describes these techniques.
However, in practice, it still requires a lot of hacks and workarounds to make it work. Some focus modifiers may be experimental and/or broken. For example, focusRestorer is marked as ExperimentalComposeUiApi and doesn’t work in some conditions as intended.
Considering all the issues with the ‘recommended’ approach, we come again to the main issue with it:
The main issue with Compose is that focus is not a part of the state.
This is what focus as a state fixes.
Focus as a state concept
So what we want is to have a state like this:
data class RangesState(
val mountainRanges: List<MountainRange>,
val focusedBlock: FocusableBlock,
val focusedRangeIndex: Int,
val focusedMountainIndex: Int,
) {
enum class FocusableBlock {
RANGES, MOUNTAINS
}
}
This state corresponds to the screen represented in picture 1.
To be able to operate like this we need a few things:
Disable the default focus management system entirely.
Listen to all key events on the screen.
Handle the keys we’re interested in and change the state accordingly.
Simplified events/data flow can look like in picture 2.
Formally, it’s a case of Unidirectional Data Flow described in Android documentation.
In this paradigm, we don’t have any composable focus modifiers.
I named it Focus as a state. In my opinion, the name reflects exactly what is going on under the hood.
Step-by-step example of focus as a state
To help you understand the concept, here is an example of how the user can select a mountain on the right side of the screen from picture 1.
First of all, here are the data models we will use in this example:
data class MountainRange(
val id: Int,
val name: String,
val mountains: List<Mountain>
)
data class Mountain(
val id: Int,
val name: String,
@DrawableRes val image: Int
)
Now let’s go through the states that ViewModel emits.
The screen starts with the initial state #1:
RangesState(
mountainRanges = listOf(...),
focusedBlock = FocusableBlock.RANGES,
focusedRangeIndex = 0,
focusedMountainIndex = 0,
)
Notice that the focused block is a ranges list, the focused item index is 0, and the focused mountain index within the range is 0.
The user can change the mountain range by pressing Down on the D-pad. ViewModel receives a key event and produces a new state #2:
RangesState(
mountainRanges = listOf(...),
focusedBlock = FocusableBlock.RANGES,
focusedRangeIndex = 1,
focusedMountainIndex = 0,
)
This is how we can change the focused mountain range.
To move focus to the mountains grid, the user presses the Right button. The new state #3:
RangesState(
mountainRanges = listOf(...),
focusedBlock = FocusableBlock.MOUNTAINS,
focusedRangeIndex = 1,
focusedMountainIndex = 0,
)
Using direction keys user can change the focus as described above and select another episode. For example, the user presses Right and Down (they want to choose Mountain 5). The state after the Right press is #4:
RangesState(
mountainRanges = listOf(...),
focusedBlock = FocusableBlock.MOUNTAINS,
focusedRangeIndex = 1,
focusedMountainIndex = 1,
)
The state after the Down press is #5:
RangesState(
mountainRanges = listOf(...),
focusedBlock = FocusableBlock.MOUNTAINS,
focusedRangeIndex = 1,
focusedMountainIndex = 4,
)
Now the user wants to open the selected mountain details. They press the central button, ViewModel receives the key event and can easily calculate what mountain should be opened:
val focusedMountain = state.mountainRanges[state.focusedRangeIndex].mountains[state.focusedMountainIndex]
ViewModel triggers the necessary code to move to the next screen.
Here is a video demonstration of this example:
Here is the repository with the source code.
Now, let’s discuss the pros and cons of this solution.
PROS and CONS of focus as a state
This solution is beneficial for the next reasons:
It makes default Compose libraries work for TV (you can always have up-to-date dependencies and use all the benefits of Compose).
As ViewModel can be effectively covered with the unit tests, the focus state is unit testable (it is impossible with the default approach!).
Focus restoration works out of the box, no additional code is required. As the focus is a part of the state and the state is stored in ViewModel, it can be restored as long as ViewModel is alive. This can be extended even further! For example, the state can be stored in the database.
Troubleshooting is much easier. The state can be easily logged and debugged.
Much cleaner code compared to the default approach.
However, there are things to consider:
Some boilerplate code is required. We will dive into implementation details in the next section.
Probably there will be issues with the widgets that must be focusable to work. For example, TextField that handles user input. To make it work the system requires additional workaround. This case is out of the scope of the current work.
Implementation details
First of all, make sure you have familiarized yourself with the example project codebase. Some things to pay attention to are described below.
Default focus lock
As was mentioned earlier, to achieve the desired behaviour the default focus must be locked somehow.
In pure Compose app we can choose 2 ways depending on the app development stage and requirements:
Lock the default focus once on the activity level and forget about it forever.
Lock focus individually for each of the screens. It requires more boilerplate code depending on the UI but brings more flexibility. In case the screen has any TextFields or you’re working on the hybrid Compose/XML TV app project, this is the only way to workaround.
To make things easier for you I implemented the harder case #2 in the example project. Pay attention to FocusManagement.kt. withManagedFocus() is a top-wrapper for the screen. It creates a dummy view that captures the focus all the time while we are on that screen. This view also listens to the key events and sends them to the screen’s view model.
Note, that in Compose only the focusable view can listen to the key events so this is the only place where we can put key listener.
FocusManagement.kt and withManagedFocus() are reusable and can be used as-is on the hybrid Compose/XML project. XML screen will use the default focus system and for the Composable screen just call ScreenFocus.capture() to suppress the default focus.
Lazy lists scroll
Note that we don’t use rememberLazyListState() and rememberLazyGridState() directly as the list/grid won’t be properly scrolled when the focused position comes closer to the lazy list edge. The reason is that both focused and unfocused items are the same for the system. The only difference is on the app level. Use provided rememberSyncedLazyListState(…) and rememberSyncedLazyVerticalGridState(…) wrappers for proper behavior. Respective source files are LazyListState.kt and LazyGridState.kt.
Index calculation
To make index calculation reusable use appropriate functions from CalculateListIndex.kt and CalculateGridIndex.kt.
Paradigm shift
As you may have noticed, some code is required to make focus as a state work. However, the difference is that it is unit-testable. We can cover our utilities and wrappers with the unit tests. We can unit-test view models and be sure new changes do not break the focus. This is something unachievable with the default approach.
My code is just an example of how focus as a state can be implemented. You can choose to make some (or all) things differently depending on the project you are working on.
Final words
Having focus as a state helps to extend the Compose paradigm even further. It allows to write highly maintainable, extendable, and testable code. I hope the people who develop Compose will consider taking some of the ideas described here and bringing them to Compose.
Just in case here is an example repo link.
Contact
If you want to reach me, feel free to connect/follow me on LinkedIn or Github. I would appreciate it if you could add a quick note while sending a connection request.
If you like this article consider subscribing to my Substack.
This is a nice experiment, however I can see lots of issues with this approach as it is not tied to the system focus in compose.
This means when you move the focused index to a button for example, the click handler of the button does not work as it does not have "real" system focus.
Also I am not a fan of the fact that the viewmodel needs to know what the UI looks like, this makes it impossible to use the same viewmodel across different screens that are closey related to each other. What happens if you had a filter for example, that should open the same screen with a filter bar, then now that filter bar cannot receive focus as the viewmodel does not know it, thus having to make the viewmodel know all the different cases it could be used in.
It also makes it impossible to use this in a project with multiple app modules sharing common viewmodels for example, like a project with a mobile app and a tv app, or even a WearOS app.
So it is a nice try, but I do not think the downsides of this approach is any better than using the system focus in the somewhat quirky way it currently needs to be used in.
Some good ideas here! However I guess accessibility and TalkBack support would need to be added in somehow as well. And would this work if the user used a mouse as input and clicked around on the elements...?