Android Jetpack Compose TV Focus restoring

2.1k Views Asked by At

I have TvLazyRows inside TvLazyColumn. When I navigate to the end of all lists(position [20,20]) navigate to next screen and return back, focus is restored to the first visible position [15,1], not the position where I was before [20,20]. How can I restore focus to some specific position?

enter image description here

class MainActivity : ComponentActivity() {

    private val rowItems = (0..20).toList()
    private val rows = (0..20).toList()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            MyAppNavHost(navController = navController)
        }
    }

    @Composable
    fun List(navController: NavController) {
        val fr = remember {
            FocusRequester()
        }
        TvLazyColumn( modifier = Modifier
            .focusRequester(fr)
            .fillMaxSize()
            ,
            verticalArrangement = Arrangement.spacedBy(16.dp),
            pivotOffsets = PivotOffsets(parentFraction = 0.05f),
        ) {
            items(rows.size) { rowPos ->
                Column() {
                    Text(text = "Row $rowPos")
                    TvLazyRow(
                        modifier = Modifier
                            .height(70.dp),
                        horizontalArrangement = Arrangement.spacedBy(16.dp),
                        pivotOffsets = PivotOffsets(parentFraction = 0.0f),
                    ) {
                        items(rowItems.size) { itemPos ->
                            var color by remember {
                                mutableStateOf(Color.Green)
                            }
                            Box(
                                Modifier
                                    .width(100.dp)
                                    .height(50.dp)
                                    .onFocusChanged {
                                        color = if (it.hasFocus) {
                                            Color.Red
                                        } else {
                                            Color.Green
                                        }
                                    }
                                    .background(color)
                                    .clickable {
                                        navController.navigate("details")
                                    }


                            ) {
                                Text(text = "Item ${itemPos.toString()}", Modifier.align(Alignment.Center))
                            }
                        }
                    }
                }
            }
        }
        LaunchedEffect(true) {
            fr.requestFocus()
        }
    }

    @Composable
    fun MyAppNavHost(
        navController: NavHostController = rememberNavController(),
        startDestination: String = "list"
    ) {
        NavHost(
            navController = navController,
            startDestination = startDestination
        ) {
            composable("details") {
                Details()
            }
            composable("list") { List(navController) }
        }
    }

    @Composable
    fun Details() {
        Box(
            Modifier
                .background(Color.Blue)
                .fillMaxSize()) {
            Text("Second Screen", Modifier.align(Alignment.Center), fontSize = 48.sp)
        }
    }

}

versions

dependencies {

    implementation 'androidx.core:core-ktx:1.10.1'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
    implementation 'androidx.activity:activity-compose:1.7.1'
    implementation platform('androidx.compose:compose-bom:2022.10.00')
    implementation 'androidx.compose.ui:ui'
    implementation 'androidx.compose.ui:ui-graphics'
    implementation 'androidx.compose.ui:ui-tooling-preview'
    implementation 'androidx.compose.material3:material3'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
    debugImplementation 'androidx.compose.ui:ui-tooling'
    debugImplementation 'androidx.compose.ui:ui-test-manifest'

    // Compose for TV dependencies
    def tvCompose = '1.0.0-alpha06'
    implementation "androidx.tv:tv-foundation:$tvCompose"
    implementation "androidx.tv:tv-material:$tvCompose"

    def nav_version = "2.5.3"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

I tried passing FocusRequestor to each focusable element inside list. In that case I was able to restore focus. But For big amount of elements inside list it starts crashing with OutOfMemmoryError. So I need some another solution.

3

There are 3 best solutions below

5
On BEST ANSWER

In Jetpack Compose, navigation is stateless by design, which means the focus state is not preserved by default. To achieve this, we have to maintain the state (the item's position) ourselves.

Below is a proposed solution that you can integrate into your code. Note that this solution works with the assumption that your list's items aren't dynamically changed. If they do, you may have to tweak the logic a bit:

  1. You need to maintain the last focused item in a state.
private var lastFocusedItem by rememberSaveable{ mutableStateOf(Pair(0, 0)) }
  1. When an item gets focus, you need to update lastFocusedItem.
.onFocusChanged { focusState ->
    if (focusState.hasFocus) {
        lastFocusedItem = Pair(rowPos, itemPos)
    }
    ...
}
  1. When you navigate back to the list screen, you need to request focus for the last focused item.
LaunchedEffect(true) {
    // Request focus to the last focused item
    focusRequesters[lastFocusedItem]?.requestFocus()
}

To achieve the last point, we need to have a map of FocusRequesters. We should use a map with keys as item positions (rowPos, itemPos) and values as FocusRequesters.

Here's the updated portion of your code that maintains and restores the focus of the last navigated item.

This is a two-step process:

  1. Create a mutable state map that holds pairs of (rowPos, itemPos) as keys and their corresponding FocusRequester as values. Use rememberSaveable to keep value during screen navigation.
  2. Remember a FocusRequester for each item and add it to the focusRequesters map.

Your updated List composable might look something like this:

@Composable
fun List(navController: NavController) {
    val focusRequesters = remember { mutableMapOf<Pair<Int, Int>, FocusRequester>() }
    var lastFocusedItem by rememberSaveable{ mutableStateOf(Pair(0, 0)) }
    TvLazyColumn(
        modifier = Modifier
            .fillMaxSize(),
        verticalArrangement = Arrangement.spacedBy(16.dp),
        pivotOffsets = PivotOffsets(parentFraction = 0.05f),
    ) {
        items(rows.size) { rowPos ->
            Column() {
                Text(text = "Row $rowPos")
                TvLazyRow(
                    modifier = Modifier
                        .height(70.dp),
                    horizontalArrangement = Arrangement.spacedBy(16.dp),
                    pivotOffsets = PivotOffsets(parentFraction = 0.0f),
                ) {
                    items(rowItems.size) { itemPos ->
                        var color by remember { mutableStateOf(Color.Green) }
                        val fr = remember { FocusRequester() }
                        focusRequesters[Pair(rowPos, itemPos)] = fr
                        Box(
                            Modifier
                                .width(100.dp)
                                .height(50.dp)
                                .focusRequester(fr)
                                .onFocusChanged {
                                    color = if (it.hasFocus) {
                                        lastFocusedItem = Pair(rowPos, itemPos)
                                        Color.Red
                                    } else {
                                        Color.Green
                                    }
                                }
                                .background(color)
                                .clickable {
                                    navController.navigate("details")
                                }
                        ) {
                            Text(text = "Item ${itemPos.toString()}", Modifier.align(Alignment.Center))
                        }
                    }
                }
            }
        }
    }
    LaunchedEffect(true) {
        focusRequesters[lastFocusedItem]?.requestFocus()
    }
}

PS. to have composable methods is a bad idea. Composables should be pure functions without side effects.

2
On

@corvinav has posted a great approach. However, that approach has a possibility of crashing the app if the focusRequester is not attached to the element before requesting focus on it. Check out more details here: gBug - 276738340

Summarizing the problems in @corvinav's approach:

  • If the user has clicked an item from a row or column which requires scrolling, this approach won't work because that item won't be in view for requesting focus
  • Even if the item's index is in view, LaunchedEffect doesn't guarantee that the item will be ready to request focus.
  • All the focus requesters are stored in a map. If the rows/columns are modified, then index-based approach for getting the focus requester may not work.

A better way would be to make use of navController.popBackStack() call on the details page which will restore the previous page and have many of the things preserved for you like scroll state across TvLazyRows and TvLazyColumns. One thing that it currently doesn't do is restore the focus the last focused item. You can do that by making use of focus requester, but with a slight modification to @corvinav's approach. We can make use of onGloballyPositioned modifier to guarantee that the item is in view and ready to accept focus.

Two key points to restore the previous state:

  • Use one of navController.pop* methods to restore previous pages. Checkout jetpack/compose/navigation for more details
  • Remember to add the above call to the BackHandler as well

Demo:

Find the relevant code snippet for the above demo:

@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun App() {
    val navController = rememberNavController()
    val lastFocusedItemId = remember { mutableStateOf<String?>(null) }

    // To avoid requesting focus on the item more than once
    val itemAlreadyFocused = remember { mutableStateOf(false) }

    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            LaunchedEffect(Unit) {
                // reset the value when home is launched
                itemAlreadyFocused.value = false
            }
            HomePage(
                navController = navController,
                lastFocusedItemId = lastFocusedItemId,
                itemAlreadyFocused = itemAlreadyFocused,
            )
        }
        composable("movie") {
            BackHandler {
                navController.popBackStack()
            }
            Button(onClick = { navController.popBackStack() }) {
                Text("Home")
            }
        }
    }
}

@Composable
fun HomePage(
    navController: NavController,
    lastFocusedItemId: MutableState<String?>,
    itemAlreadyFocused: MutableState<Boolean>,
) {
    TvLazyColumn(Modifier.fillMaxSize()) {
        // `rows` could be coming from your view model
        itemsIndexed(rows) { index, rowData ->
            MyRow(
                rowData = rowData,
                navController = navController,
                lastFocusedItemId = lastFocusedItemId,
                itemAlreadyFocused = itemAlreadyFocused,
            )
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MyRow(
    rowData: List<MyItem>,
    navController: NavController,
    lastFocusedItemId: MutableState<String?>,
    itemAlreadyFocused: MutableState<Boolean>,
) {
    TvLazyRow(
        horizontalArrangement = Arrangement.spacedBy(20.dp),
        modifier = Modifier.focusRestorer()
    ) {
        items(myItems) { item ->
            key(item.id) {
                val focusRequester = remember { FocusRequester() }
                Card(
                    modifier = Modifier
                        .focusRequester(focusRequester)
                        .onGloballyPositioned {
                            if (
                                lastFocusedItemId.value == item.id &&
                                !itemAlreadyFocused.value
                            ) {
                                focusRequester.requestFocus()
                            }
                        }
                        .onFocusChanged {
                            if (it.isFocused) {
                                lastFocusedItemId.value = item.id
                                itemAlreadyFocused.value = true
                            }
                        },
                    onClick = { navController.navigate("movie") }
                )
            }
        }
    }
}
0
On

I have a different approach here , which does not prevent other elements outside of the list to get focus.

var lastFocusedChannel by rememberSaveable { mutableStateOf<Int?>(null) }

 GridItem(channel,
 onClick = {
                //on click logic

                //Store the focused element id when clicked
                lastFocusedChannel = channel.hashCode()
            }, hadFocusBeforeNavigation = lastFocusedChannel == channel.hashCode()) {

                 // onFocusChanged Listener to Reset stored value after accuiring focus
                if (it.hasFocus) lastFocusedChannel = null
            }
        }
//...
// In each Item
// Apply the modifier

  val focusRequester = remember { FocusRequester() }

    Card(
        onClick = { onClick.invoke(channel) },
        modifier = Modifier
            .size(channelItemSize)
            .onFocusChanged(onFocusChanged)
            .focusRequester(focusRequester)
            .onGloballyPositioned {
               if(hadFocusBeforeNavigation) focusRequester.requestFocus()
                }
            })

Complete Code Snippet:

package dev.khaled.leanstream.channels

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BrokenImage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.Border
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import coil.compose.AsyncImage
import dev.khaled.leanstream.conditional

val channelItemSize = 128.dp
val isTouchScreen = false //TODO


@Composable
fun ChannelsGrid(items: List<Channel>, onClick: (channel: Channel) -> Unit) {
    var lastFocusedChannel by rememberSaveable { mutableStateOf<Int?>(null) }

    TvLazyVerticalGrid(
        columns = TvGridCells.Adaptive(channelItemSize),
        verticalArrangement = Arrangement.spacedBy(16.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        contentPadding = PaddingValues(16.dp),
    ) {
        items(items) { channel ->
            GridItem(channel, onClick = {
                onClick(it)
                lastFocusedChannel = channel.hashCode()
            }, hadFocusBeforeNavigation = lastFocusedChannel == channel.hashCode()) {
                if (it.hasFocus) lastFocusedChannel = null
            }
        }
    }
}


@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun GridItem(
    channel: Channel,
    onClick: (channel: Channel) -> Unit,
    hadFocusBeforeNavigation: Boolean,
    onFocusChanged: (FocusState) -> Unit,
) {
    val focusRequester = remember { FocusRequester() }

    Card(
        onClick = { onClick.invoke(channel) },
        modifier = Modifier
            .size(channelItemSize)
            .onFocusChanged(onFocusChanged)
            .focusRequester(focusRequester)
            .conditional(hadFocusBeforeNavigation) {
                onGloballyPositioned {
                    focusRequester.requestFocus()
                }
            }
            .conditional(isTouchScreen) {
                clickable { onClick.invoke(channel) }
                clip(RoundedCornerShape(16))
            },
        border = CardDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.Black))),
    ) {
        AsyncImage(
            model = channel.icon,
            modifier = Modifier.fillMaxSize(),
            contentDescription = null,
            error = rememberVectorPainter(Icons.Rounded.BrokenImage),
        )
    }
}

Result: Image