Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 430
Plorial Avatar asked Jun 18 '26 08:06

Plorial


2 Answers

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.

like image 126
corvinav Avatar answered Jun 19 '26 21:06

corvinav


@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") }
                )
            }
        }
    }
}
like image 27
vighnesh153 Avatar answered Jun 19 '26 21:06

vighnesh153



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!