For TV apps, the browsing experience relies on efficient focus-based navigation. Using standard Compose Foundation lazy layouts, you can create performant vertical and horizontal lists that automatically handle focus-driven scrolling to keep active items in view.
Default scroll behavior optimized for TV
Starting with Compose Foundation 1.7.0, standard lazy layouts (like LazyRow
and LazyColumn) include built-in support for focus-positioning features. This
is the recommended way to build catalogs for TV apps as it helps keep focused
items remain visible and positioned intuitively for the user.
To implement a basic scrollable list, use the standard lazy components. These components automatically handle D-pad navigation and bring the focused item into view.
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@Composable
fun MovieCatalog(movies: List<Movie>) {
LazyRow {
items(movies) { movie ->
MovieCard(
movie = movie,
onClick = { /* Handle click */ }
)
}
}
}
Customize scroll behavior with BringIntoViewSpec
If your design requires a specific "pivot" point (for example, keeping the
focused item exactly 30% from the left edge), you can customize the scrolling
behavior using a BringIntoViewSpec. This replaces the older pivotOffsets
functionality by allowing you to define exactly how the viewport should scroll
to accommodate a focused item.
1. Define a custom BringIntoViewSpec
The following helper composable lets you define a "pivot" based on parent and
child fractions. The parentFraction determines where in the container the item
should land, and the childFraction determines which part of the item aligns
with that point.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PositionFocusedItemInLazyLayout(
parentFraction: Float = 0.3f,
childFraction: Float = 0f,
content: @Composable () -> Unit,
) {
val bringIntoViewSpec = remember(parentFraction, childFraction) {
object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float, // Item's initial position
size: Float, // Item's size
containerSize: Float // Container's size
): Float {
// Calculate the offset position of the item's leading edge.
val initialTargetForLeadingEdge =
parentFraction * containerSize - (childFraction * size)
// If the item fits in the container, and scrolling would cause
// its trailing edge to be clipped, adjust targetForLeadingEdge
// to prevent over-scrolling near the end of list.
val targetForLeadingEdge = if (size <= containerSize &&
(containerSize - initialTargetForLeadingEdge) < size) {
// If clipped, align the item's trailing edge with the
// container's trailing edge.
containerSize - size
} else {
initialTargetForLeadingEdge
}
// Return scroll distance relative to initial item position.
return offset - targetForLeadingEdge
}
}
}
// Apply the spec to all scrollables in the hierarchy
CompositionLocalProvider(
LocalBringIntoViewSpec provides bringIntoViewSpec,
content = content,
)
}
2. Apply the custom spec
Wrap your layouts with the helper to apply the positioning. This is useful for creating a "consistent focus line" across different rows of your catalog.
PositionFocusedItemInLazyLayout(
parentFraction = 0.3f, // Pivot 30% from the edge
childFraction = 0.5f // Center of the item aligns with the pivot
) {
LazyColumn {
items(sectionList) { section ->
// This row and its items will respect the 30% pivot
LazyRow { ... }
}
}
}
3. Opt-out for specific nested layouts
If you have a specific nested layout that should use standard scrolling behavior
instead of your custom pivot, provide the DefaultBringIntoViewSpec:
private val DefaultBringIntoViewSpec = object : BringIntoViewSpec {}
PositionFocusedItemInLazyLayout {
LazyColumn {
item {
// This row will ignore the custom pivot and use default behavior
CompositionLocalProvider(LocalBringIntoViewSpec provides DefaultBringIntoViewSpec) {
LazyRow { ... }
}
}
}
}
In effect, by passing an empty BringIntoViewSpec enables the framework's
default behavior to take over.
Migration from TV Foundation to Compose Foundation
The TV-specific lazy layouts in androidx.tv.foundation are deprecated in favor
of the standard Compose Foundation layouts.
Dependency updates
Verify that your build.gradle uses version 1.7.0 or higher for:
androidx.compose.foundationandroidx.compose.runtime
Component mapping
To migrate, update your imports and remove the Tv prefix from your components:
| Deprecated TV component | Compose Foundation replacement |
|---|---|
| TvLazyRow | LazyRow |
| TvLazyColumn | LazyColumn |
| TvLazyHorizontalGrid | LazyHorizontalGrid |
| TvLazyVerticalGrid | LazyVerticalGrid |
| pivotOffsets | BringIntoViewSpec (via LocalBringIntoViewSpec) |