Search code examples
androidkotlinandroid-jetpack-composeandroid-tvandroid-jetpack-compose-tv

Android Jetpack Compose TV Focus restoring


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.


Solution

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