BringIntoViewSpec introduction

March 03, 2025


BringIntoViewSpec introduction

Introduction

In July 2024 we could see the first alpha version of Jetpack Compose tv foundation library, that deprecated TvLazyColumn and TvLazyRow. In January 2025 we got release of tv-foundation 1.0.0, where those composables are gone for good. But what has happened to them? How can we replace them?

Purpose of TvLazy* containers

First of all let me tell you what problems were solved by those TvLazy* Compsables. TVs are obviously very different devices than standard Android phones/tablets. The key difference is how user interacts with the UI. On TV there’s no touch interface, everything happens with the use of remote. This means we need to ensure that items remain visible when the user moves the focus. This is precisely why TvLazyColumn and TvLazyRow were introduced. We could provide pivotOffset parameter which was aligning the items to the position that developers/designers wanted. For instance TvLazyColumn aligned the focused item (the red rectangle in the GIF below) to a specific screen position (here 30% of the screen height). Each focused item was moved to the same position, as long as scrolling allowed (see “Item 0”, which is moved as close to 30%, as can be):

GIF

Pivot on LazyColumn/TvLazyRow was working in the same way, but in the other axis:

GIF

Replacement

Now that these composables are gone, how can we achieve the same behaviour? We should be using standard LazyColumn and LazyRow. But it doesn’t mean that they will behave in the same way on both mobile and Tv. Google introduced a thing called BringIntoViewSpec along with LocalBringIntoViewSpec, which is a composition local, that is internally read by scrollables to provide the behaviour that we desire. By providing different instances of BringIntoViewSpec via LocalBringIntoViewSpec, we can achieve different behaviours. Let’s see Google’s implementation for LocalBringIntoViewSpec:

actual val LocalBringIntoViewSpec: ProvidableCompositionLocal<BringIntoViewSpec> =
    compositionLocalWithComputedDefaultOf {
        val hasTvFeature =
        LocalContext.currentValue.packageManager.hasSystemFeature(FEATURE_LEANBACK)
        if (!hasTvFeature) {
            DefaultBringIntoViewSpec
        } else {
            PivotBringIntoViewSpec
        }
}

This piece of code makes sure that the behaviour of LazyColumn and LazyRow on TV and mobile in terms of item alignment is different. It provides different BringIntoViewSpecs depending on the type of the device the code is run on. We’ve already seen the expected behaviour on TV, so let’s see how does it behave on mobile device:

GIF

As you can see, now the focused item is moved only as much as it needs to be moved, to be fully visible. If it is completely visible - no scrolling takes place. If you’ve ever worked with accessibility engines, such as Talkback, you’ve probably noticed, that that’s exactly what happens when user walks over the items in such a layout. If you want to try it on your device, here’s the code for it:

@Composable
fun BringIntoViewSpecFun(modifier: Modifier = Modifier) {
    LazyColumn(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(8.dp),
        modifier = modifier,
    ) {
        items(10) { index ->
            val interactionSource = remember { MutableInteractionSource() }
            val isFocused by interactionSource.collectIsFocusedAsState()
            Box(
                modifier = Modifier
                    .focusable(interactionSource = interactionSource)
                    .size(200.dp)
                    .background(if (isFocused) Color.Red else Color.Gray),
                contentAlignment = Alignment.Center,
            ) {
                Text(text = "Item $index")
            }
        }
    }
}

How to use BringIntoViewSpec?

What is BringIntoViewSpec then? It’s a simple interface, which has field val scrollAnimationSpec: AnimationSpec<Float> and a method fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float). scrollAnimationSpec defines how should the scroll look like. Don’t get too used to it, as it’s already deprecated in the newest versions of lib. calculateScrollDistance’s job is to provide by how many pixels we need to scroll our scrollable container, to make the view placed where we want it to be. So far so good, but what if we want to customise the defaults? We just have to create our custom BringIntoViewSpec implementation and provide it with use of CompositionLocalProvider. Let’s use the Google’s implementation of PivotBringIntoViewSpec and modify it a bit, so that we can easily specify the alignment line:

class CustomBringIntoViewSpec(
    private val parentFraction: Float,
    private val childFraction: Float,
) : BringIntoViewSpec {

    override val scrollAnimationSpec: AnimationSpec<Float> = ...

    override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
        val leadingEdgeOfItemRequestingFocus = offset
        val trailingEdgeOfItemRequestingFocus = offset + size

        val sizeOfItemRequestingFocus =
            abs(trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus)
        val childSmallerThanParent = sizeOfItemRequestingFocus <= containerSize
        val initialTargetForLeadingEdge =
            (parentFraction * containerSize) -
                    (childFraction * sizeOfItemRequestingFocus)
        val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge

        val targetForLeadingEdge =
            if (childSmallerThanParent && spaceAvailableToShowItem < sizeOfItemRequestingFocus) {
                containerSize - sizeOfItemRequestingFocus
            } else {
                initialTargetForLeadingEdge
            }

        return leadingEdgeOfItemRequestingFocus - targetForLeadingEdge
    }
}

And now we can provide it to our LazyColumn/LazyRow. Here’s the code in case we want the start of the focused child to be at the 0% of screen width:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BringIntoViewSpecFun(modifier: Modifier = Modifier) {
    val bivs = remember { CustomBringIntoViewSpec(0f, 0f) }
    CompositionLocalProvider(LocalBringIntoViewSpec provides bivs) {
        LazyRow(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            modifier = modifier,
        ) {
            items(10) { index ->
                val interactionSource = remember { MutableInteractionSource() }
                val isFocused by interactionSource.collectIsFocusedAsState()
                Box(
                    modifier = Modifier
                        .focusable(interactionSource = interactionSource)
                        .size(200.dp)
                        .background(if (isFocused) Color.Red else Color.Gray),
                    contentAlignment = Alignment.Center,
                ) {
                    Text(text = "Item $index")
                }
            }
        }
    }
}

And it works like that:

GIF

You can see that items 6, 7, 8 and 9 are not aligned to the beginning of the screen, as LazyRow cannot scroll anymore. In case we want all items to be aligned to the position set by BringViewIntoSpec, we can achieve that by setting contentPadding of our container:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BringIntoViewSpecFun(modifier: Modifier = Modifier) {
    val bivs = remember { CustomBringIntoViewSpec(0f, 0f) }
    CompositionLocalProvider(LocalBringIntoViewSpec provides bivs) {
        val lazyListState = rememberLazyListState()
        val density = LocalDensity.current
        val viewPortWidth by remember {
            derivedStateOf {
                with(density) { lazyListState.layoutInfo.viewportSize.width.toDp() }
            }
        }
        LazyRow(
            state = lazyListState,
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            contentPadding = PaddingValues(end = viewPortWidth),
            modifier = modifier,
        ) {
            items(10) { index ->
                val interactionSource = remember { MutableInteractionSource() }
                val isFocused by interactionSource.collectIsFocusedAsState()
                Box(
                    modifier = Modifier
                        .focusable(interactionSource = interactionSource)
                        .size(200.dp)
                        .background(if (isFocused) Color.Red else Color.Gray),
                    contentAlignment = Alignment.Center,
                ) {
                    Text(text = "Item $index")
                }
            }
        }
    }
}

Instead of using any arbitrary value of padding end, I’ve used viewport width, as it is minimum amount that will make the view behave as we want:

GIF

In our CustomBringIntoViewSpec I’ve defined parentFraction, as well as childFraction. childFraction defines which part of the child we want to be aligned. 0f means start of the child, 0.5f stands for the middle and 1f is the end of the child. For example if we want each focused item to be placed exactly at the center of the screen, we can do it like that:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BringIntoViewSpecFun(modifier: Modifier = Modifier) {
    val bivs = remember { CustomBringIntoViewSpec(0.5f, 0.5f) }
    CompositionLocalProvider(LocalBringIntoViewSpec provides bivs) {
        val lazyListState = rememberLazyListState()
        val density = LocalDensity.current
        val horizontalPadding by remember {
            derivedStateOf {
                with(density) { lazyListState.layoutInfo.viewportSize.width.toDp() / 2 }
            }
        }
        LazyRow(
            state = lazyListState,
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            contentPadding = PaddingValues(horizontal = horizontalPadding),
            modifier = modifier,
        ) {
            items(10) { index ->
                val interactionSource = remember { MutableInteractionSource() }
                val isFocused by interactionSource.collectIsFocusedAsState()
                Box(
                    modifier = Modifier
                        .focusable(interactionSource = interactionSource)
                        .size(200.dp)
                        .background(if (isFocused) Color.Red else Color.Gray),
                    contentAlignment = Alignment.Center,
                ) {
                    Text(text = "Item $index")
                }
            }
        }
    }
}

By setting both parent and child fraction to 0.5f, we’re telling that we want to align center of each child to the center of the screen. That’s how it looks like on the device:

GIF

What is important is that the same BringViewIntoSpec can be reused - it will work with LazyColumn and LazyRow (as well as with all Lazy*Grids).

There’s also another typical use case, I can think of - aligning the focused item to the specific value, let’s say 80.dp. It might be tempting to calculate what percentage of the screen our 80.dp is, but we can do it in a simpler way. We need another parameter at our CustomBringIntoViewSpec:

class CustomBringIntoViewSpec(
    private val parentFraction: Float,
    private val childFraction: Float,
    private val parentStartOffsetPx: Int = 0,
) : BringIntoViewSpec {

    override val scrollAnimationSpec: AnimationSpec<Float> = ...

    override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
        val leadingEdgeOfItemRequestingFocus = offset
        val trailingEdgeOfItemRequestingFocus = offset + size

        val sizeOfItemRequestingFocus =
            abs(trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus)
        val childSmallerThanParent = sizeOfItemRequestingFocus <= containerSize
        val initialTargetForLeadingEdge =
            (parentFraction * containerSize) +
                    parentStartOffsetPx -
                    (childFraction * sizeOfItemRequestingFocus)
        val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge

        val targetForLeadingEdge =
            if (childSmallerThanParent && spaceAvailableToShowItem < sizeOfItemRequestingFocus) {
                containerSize - sizeOfItemRequestingFocus
            } else {
                initialTargetForLeadingEdge
            }

        return leadingEdgeOfItemRequestingFocus - targetForLeadingEdge
    }
}

Now we can provide offset from the parentFraction, which is exactly what we want.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BringIntoViewSpecFun(modifier: Modifier = Modifier) {
    val density = LocalDensity.current
    val parentStartOffset = 80.dp
    val parentStartOffsetPx = with(density) { parentStartOffset.roundToPx() }
    val bivs = remember { CustomBringIntoViewSpec(0f, 0f, parentStartOffsetPx) }
    CompositionLocalProvider(LocalBringIntoViewSpec provides bivs) {
        LazyRow(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            contentPadding = PaddingValues(start = parentStartOffset),
            modifier = modifier,
        ) {
            items(10) { index ->
                val interactionSource = remember { MutableInteractionSource() }
                val isFocused by interactionSource.collectIsFocusedAsState()
                Box(
                    modifier = Modifier
                        .focusable(interactionSource = interactionSource)
                        .size(200.dp)
                        .background(if (isFocused) Color.Red else Color.Gray),
                    contentAlignment = Alignment.Center,
                ) {
                    Text(text = "Item $index")
                }
            }
        }
    }
}

And just like that we got ourselves nice padding:

GIF

Conclusion

As you can see Google gave us quite scalable and well-thought API to work with. Thanks to removal of TvLazy* layouts, we now have a code that is much more reusable, as all we have to change right now is just the LocalBringIntoViewSpec and the same code will work well on mobile as well as on TVs.

I hope this article helps with your implementation. Stay tuned for the next article, where I’ll present more sophisticated usages of this API.