5

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.

Plorial
  • 87
  • 1
  • 5

2 Answers2

3

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.

corvinav
  • 46
  • 2
  • 1
    Although it's hard to give a proper answer to the question, the approach proposed here has a couple of potential issues. First off the requesting focus upon adding this composable to the composition will likely cause unpredictable focus changes in quite a few scenarios. Another thing to watch out for here is that there's a good chance the last focused item is not yet composed when returning, which will cause a crash if you attempt to call requestFocus on the matching FocusRequester – Remco May 24 '23 at 10:46
  • @Remco Shure. with requestFocus need to be careful. But in this case, TvLazyRow is restoring the scroll position. And this code ( LaunchedEffect(true)) will be run only after the composition is finished. So all visible items are created. Also, null check will help to avoid crashes. We adding FocusRequesters to focusRequesters map only after item is added. – corvinav May 25 '23 at 14:20
  • `LaunchedEffect(true)` doesn't guarantee that the item will be in view before running. A better approach would be to use `onGloballyPositionChanged` or `onPlaced` modifiers to verify if the item is in view and then request the focus on it. Reference ticket: [gBug - 276738340](https://issuetracker.google.com/276738340) – vighnesh153 Jul 04 '23 at 11:25
1

@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") }
                )
            }
        }
    }
}
vighnesh153
  • 4,354
  • 2
  • 13
  • 27
  • Hi! what if I have another list inside the `composable("movie")` screen and will also navigate to another screen? should I implement `lastFocusedItemId` similar to a stack ADT? – Firate Aug 05 '23 at 12:44
  • All good now! I realized it's so much better to put the variable inside a viewmodel instead. – Firate Aug 05 '23 at 14:54