Search code examples
androidandroid-jetpack-composeandroid-tvandroid-jetpack-compose-tv

How to scroll a LazyRow faster using the dpad?


I'm trying to implement a carousel component on Android TV with Compose, and I have a problem with fast scrolling using the dpad. NB: I want to keep the focused item as the first displayed item on the screen.

Here is a screen capture:

Carousel screen capture

The first 5 items are scrolled by pressing and releasing the right key after each item. The next 15 items are scrolled by keeping the right key pressed to the end of the list.

The scrolling and focus management work well, but I would like to make it faster. On the screen capture you see that when pressing the right key, the list is scrolled then the next item gets the focus. It is really slow.

Here is the Composable function:

@Composable
private fun CustomLazyRow() {
    val scrollState = rememberLazyListState()

    LazyRow(
        state = scrollState,
        horizontalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        itemsIndexed(
            items = (1..20).toList()
        ) { index, item ->
            var isFocused by remember { mutableStateOf(false) }
            Text(
                text = "Item $item",
                modifier = Modifier
                    .dpadNavigation(scrollState, index)
                    .width(156.dp)
                    .aspectRatio(4 / 3F)
                    .onFocusChanged { isFocused = it.isFocused }
                    .focusable()
                    .border(if (isFocused) 4.dp else Dp.Hairline, Color.Black)
            )
        }
    }
}

And the dpadNavigation Modifier function:

fun Modifier.dpadNavigation(
    scrollState: LazyListState,
    index: Int
) = composed {
    val focusManager = LocalFocusManager.current
    var focusDirectionToMove by remember { mutableStateOf<FocusDirection?>(null) }
    val scope = rememberCoroutineScope()

    onKeyEvent {
        if (it.type == KeyEventType.KeyDown) {
            when (it.nativeKeyEvent.keyCode) {
                KeyEvent.KEYCODE_DPAD_LEFT -> focusDirectionToMove = FocusDirection.Left
                KeyEvent.KEYCODE_DPAD_RIGHT -> focusDirectionToMove = FocusDirection.Right
            }
            if (focusDirectionToMove != null) {
                scope.launch {
                    if (focusDirectionToMove == FocusDirection.Left && index > 0) {
                        // This does not work:
                        // scope.launch { scrollState.animateScrollToItem(index - 1) }
                        scrollState.animateScrollToItem(index - 1)
                        focusManager.moveFocus(FocusDirection.Left)
                    }
                    if (focusDirectionToMove == FocusDirection.Right) {
                        // scope.launch { scrollState.animateScrollToItem(index + 1) }
                        scrollState.animateScrollToItem(index + 1)
                        focusManager.moveFocus(FocusDirection.Right)
                    }
                }
            }
        }
        true
    }
}

I thought it was caused by the animateScrollToItem function that had to complete before executing moveFocus.

So I tried to execute animateScrollToItem in its own launch block but it didn't work; in this case there is no scrolling at all.

You can see the complete source code in a repo at https://github.com/geekarist/perf-carousel.


Solution

  • Update 12th July 2024:

    Tv lazy layouts have been deprecated in favour of the core compose foundation lazy layouts. Look at this ticket to understand the reason behind this deprecation and migration steps: b/348896032


    Initial solution

    Google is working on making Jetpack compose compatible with TV. Checkout Android TV Compose releases.

    To answer your question, you can make use of the TvLazyRow composable to build a lazy row compatible with TV. To keep the focus at the same position, you can make use of the pivotOffset (PivotOffset) param of TvLazyRow to position the items inside of the row at a fixed position.