Eric
Eric

Reputation: 4413

How to maintain column during focus traversal LazyVerticalGrid on TV

Up Focus Traversal

I've noticed an issue that consistently happens when combining LazyVerticalGrid default focus handling with smooth scroll animations. If I hold down the up button on the remote the grid will start up focusing to the item directly above the previous item in the same column then as the scroll speeds up it will jump over to the last column.

I have a guess as to why this is. The system does not see the target item in the layout so it defaults to the "closest" 1-dimensional item index the target row relative to the previous item which is always in the last (far right) column.

Any idea how to workaround this limitation and maintain column during focus traversal? I've tried setting up custom focus traversal with .focusProperties {} modifier, defining the up and down directions, but once the target item is no longer in the layout it throws an exception because it's trying to traverse to a FocusRequester the isn't installed.

Update: Goggle knows about this on their issue tracker. Appears it has to do with LazyGrid using the same "BeyondBoundsLayout modifier" as LazyList which looks only at the first offscreen item to move focus to, not the entire row.

Minimal Reproducible Example (Trouble Building? Paste into New Project -> Television -> Empty Activity template)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val colors = arrayOf(
            Color.Red,
            Color(255, 165, 0),
            Color.Yellow,
            Color.Green
        )

        setContent {
            LazyVerticalGrid(
                state = rememberLazyGridState(),
                columns = GridCells.Fixed(4)
            ) {
                for (i in 0 until 100) {
                    item {
                        ClassicCard(
                            modifier = Modifier.padding(10.dp),
                            image = {
                                Box(modifier = Modifier
                                    .fillMaxWidth()
                                    .aspectRatio(16/9f)
                                    .background(colors[i % 4]))
                            },
                            title = {
                                Text("Item ${i + 1}")
                            },
                            onClick = {}
                        )
                    }
                }
            }
        }
    }
}

Custom Focus Traversal Example (Throws IllegalStateException if up or down button is held)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val numItems = 100
        val cols = 4

        val colors = arrayOf(
            Color.Red,
            Color(255, 165, 0),
            Color.Yellow,
            Color.Green
        )

        val focusRequesters = Array(numItems) { FocusRequester() }

        setContent {
            LazyVerticalGrid(
                state = rememberLazyGridState(),
                columns = GridCells.Fixed(cols)
            ) {
                for (i in 0 until numItems) {
                    val addUpFocus = i - cols in focusRequesters.indices
                    val addDownFocus = i + cols in focusRequesters.indices

                    val focusPropertiesMod = when {
                        addUpFocus && addDownFocus -> {
                            Modifier.focusProperties {
                                up = focusRequesters[i - cols]
                                down = focusRequesters[i + cols]
                            }
                        }
                        addUpFocus -> {
                            Modifier.focusProperties {
                                up = focusRequesters[i - cols]
                            }
                        }
                        addDownFocus -> {
                            Modifier.focusProperties {
                                down = focusRequesters[i + cols]
                            }
                        }
                        else -> {
                            Modifier
                        }
                    }

                    item {
                        ClassicCard(
                            modifier = Modifier
                                .padding(10.dp)
                                .focusRequester(focusRequesters[i])
                                .then(focusPropertiesMod),
                            image = {
                                Box(modifier = Modifier
                                    .fillMaxWidth()
                                    .aspectRatio(16/9f)
                                    .background(colors[i % 4]))
                            },
                            title = {
                                Text("Item ${i + 1}")
                            },
                            onClick = {}
                        )
                    }
                }
            }
        }
    }
}

Dependencies

[versions]
agp = "8.6.0-alpha06"
kotlin = "1.9.0"
coreKtx = "1.15.0"
appcompat = "1.7.0"
composeBom = "2025.01.01"
tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

Upvotes: 1

Views: 22

Answers (0)

Related Questions