1

Focus as a state - new effective Android TV focus management system with Jetpack...

 2 weeks ago
source link: https://alexzaitsev.substack.com/p/focus-as-a-state-new-effective-tv
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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.

Thanks for reading Alex’s Substack! Subscribe for free to receive new posts and support my 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.

https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd687f2f5-71b4-4736-8bf4-75367047c4eb_1920x1080.png
Picture 1 - Mountain ranges screen

To be able to operate like this we need a few things:

  1. Disable the default focus management system entirely.

  2. Listen to all key events on the screen.

  3. Handle the keys we’re interested in and change the state accordingly.

Simplified events/data flow can look like in picture 2.

https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe0276941-507d-4af7-8c31-47cc0aca1a06_1338x690.png
Picture 2 - events/data flow for suggested UI architecture

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.

  1. The screen starts with the initial state #1:

RangesState(
    mountainRanges = listOf(...),
    focusedBlock = FocusableBlock.RANGES,
    focusedRangeIndex = 0,
    focusedMountainIndex = 0,
) 
https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F867fa44b-e9d0-4dd2-a0c5-feecb1b910e4_1920x1080.png
Picture 3 - Mountains ranges screen, state #1

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.

  1. 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,
) 
https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5da299bc-aad2-444c-ada0-2c84d375d58d_1920x1080.png
Picture 4 - Mountain ranges screen, state #2

This is how we can change the focused mountain range.

  1. 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,
) 
https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fff2e725a-0586-4033-84c6-8ab0238c34db_1920x1080.png
Picture 5 - Mountain ranges screen, state #3
  1. 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,
) 
https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F09be7f4d-241f-4293-aa27-b4663a2ee632_1920x1080.png
Picture 6 - Mountain ranges screen, state #4

The state after the Down press is #5:

RangesState(
    mountainRanges = listOf(...),
    focusedBlock = FocusableBlock.MOUNTAINS,
    focusedRangeIndex = 1,
    focusedMountainIndex = 4,
) 
https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37e6e939-fc1d-4806-8bf2-7de284abdd83_1920x1080.png
Picture 7 - Mountain ranges screen, state #5
  1. 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]
  1. 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:

  1. It makes default Compose libraries work for TV (you can always have up-to-date dependencies and use all the benefits of Compose).

  2. As ViewModel can be effectively covered with the unit tests, the focus state is unit testable (it is impossible with the default approach!).

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

  4. Troubleshooting is much easier. The state can be easily logged and debugged.

  5. Much cleaner code compared to the default approach.

However, there are things to consider:

  1. Some boilerplate code is required. We will dive into implementation details in the next section.

  2. 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:

  1. Lock the default focus once on the activity level and forget about it forever.

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

Thanks for reading Alex’s Substack! Subscribe for free to receive new posts and support my work.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK