Jetpack Compose LazyRow - Center selected item
Asked Answered
A

5

15

How can I calculate the position of the selected item in a LazyRow and use it for centering the item on the screen? The items will have various sizes depending on their content.

If I go with lazyListState.animateScrollToItem(index) the selected item will be positioned to the left in the LazyRow. Result

What I want to achieve is to have the selected item centered like this Expected

The code I have currently:

@Composable
fun Test() {
    Column {
        TestRow(listOf("Angola", "Bahrain", "Afghanistan", "Denmark", "Egypt", "El Salvador", "Fiji", "Japan", "Kazakhstan", "Kuwait", "Laos", "Mongolia"))
        TestRow(listOf("Angola", "Bahrain", "Afghanistan"))
    }
}

@OptIn(ExperimentalSnapperApi::class)
@Composable
fun TestRow(items: List<String>) {
    val selectedIndex = remember { mutableStateOf(0) }
    val lazyListState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    LazyRow(
        modifier = Modifier.fillMaxWidth(),
        state = lazyListState,
        horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
        contentPadding = PaddingValues(horizontal = 12.dp),
        flingBehavior = rememberSnapperFlingBehavior(
            lazyListState = lazyListState,
            snapOffsetForItem = SnapOffsets.Start
        )
    ) {
        itemsIndexed(items) { index, item ->
            TestItem(
                content = item,
                isSelected = index == selectedIndex.value,
                onClick = {
                    selectedIndex.value = index
                    coroutineScope.launch {
                        lazyListState.animateScrollToItem(index)
                    }
                }
            )
        }
    }
}

@Composable
fun TestItem(
    content: String,
    isSelected: Boolean,
    onClick: () -> Unit
) {
    Button(
        modifier = Modifier.height(40.dp),
        colors = ButtonDefaults.buttonColors(
            backgroundColor = if (isSelected) Color.Green else Color.Yellow,
            contentColor = Color.Black
        ),
        elevation = null,
        shape = RoundedCornerShape(5.dp),
        contentPadding = PaddingValues(0.dp),
        onClick = onClick
    ) {
        Text(
            modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
            text = (content).uppercase(),
        )
    }
}

The code horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) is to make sure the LazyRow centers all the items, when there is not enough items for scrolling. Example with 3 items: All centered

I have tried various suggestions posted at similar questions, but with no luck in finding a solution. jetpack-compose-lazylist-possible-to-zoom-the-center-item how-to-focus-a-invisible-item-in-lazycolumn-on-jetpackcompose /jetpack-compose-make-the-first-element-in-a-lazyrow-be-aligned-to-the-center-o

Aesthetics answered 26/10, 2022 at 8:53 Comment(0)
A
9

I found a solution that fits my needs, since I don't expect to navigate to a selected item, without being able to see it.

This solution can't center items that is not visible on the screen.

coroutineScope.launch {
  val itemInfo = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
  if (itemInfo != null) {
     val center = lazyListState.layoutInfo.viewportEndOffset / 2
     val childCenter = itemInfo.offset + itemInfo.size / 2
     lazyListState.animateScrollBy((childCenter - center).toFloat())
  } else {
     lazyListState.animateScrollToItem(index)
  }
}

Hope t

Aesthetics answered 26/10, 2022 at 13:9 Comment(1)
Thank you for sharing this. Works as expected. It's genius, but I can't help but feel that there must be an easier way...Rollicking
W
6

Improving the @Markram answer

You can create an extension of "LazyListState":

fun LazyListState.animateScrollAndCentralizeItem(index: Int, scope: CoroutineScope) {
    val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
    scope.launch {
        if (itemInfo != null) {
            val center = [email protected] / 2
            val childCenter = itemInfo.offset + itemInfo.size / 2
            [email protected]((childCenter - center).toFloat())
        } else {
            [email protected](index)
        }
    }
}

And then just use it directly in your Composable:

val coroutineScope = rememberCoroutineScope()
coroutineScope.launch {
    listState.animateScrollAndCentralizeItem(index , this)
}
Whiting answered 7/11, 2022 at 20:5 Comment(0)
A
3

Improving both Soares and Markman answers you can reference the methods without this for readability and make the function suspend instead of passing a coroutine scope.

suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) {
    val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
    if (itemInfo != null) {
        val center = layoutInfo.viewportEndOffset / 2
        val childCenter = itemInfo.offset + itemInfo.size / 2
        animateScrollBy((childCenter - center).toFloat())
    } else {
        animateScrollToItem(index)
    }
}

Then you can just call it as you'd normally call animateScrollToItem

scope.launch {
    lazyListState.animateScrollAndCentralizeItem(index)
}
Apochromatic answered 15/8, 2023 at 17:51 Comment(0)
C
2

Here is the version that works on off-screen items. Because the animation is fast, the flash is hard to notice.

A more perfect but complex solution is to use scroll { ... } to control the whole animation as animateScrollToItem does.

suspend fun LazyListState.centerItem(index: Int) {
    suspend fun locateTarget(): Boolean {
        val layoutInfo = layoutInfo
        val containerSize =
            layoutInfo.viewportSize.width - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding

        val target = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
            ?: return false
        val targetOffset = containerSize / 2f - target.size / 2f
        animateScrollBy(target.offset - targetOffset)
        return true
    }

    if (!locateTarget()) {
        val visibleItemsInfo = layoutInfo.visibleItemsInfo
        val currentIndex = visibleItemsInfo.getOrNull(visibleItemsInfo.size / 2)?.index ?: -1
        scrollToItem(
            if (index > currentIndex) {
                (index - visibleItemsInfo.size + 1)
            } else {
                index
            }.coerceIn(0, layoutInfo.totalItemsCount)
        )
        locateTarget()
    }
}
Castroprauxel answered 27/8, 2023 at 3:36 Comment(0)
L
1

Here is a clean solution:

suspend fun LazyListState.animateScrollToItemCenter(index: Int) {
    layoutInfo.resolveItemOffsetToCenter(index)?.let {
        animateScrollToItem(index, it)
        return
    }

    scrollToItem(index)

    layoutInfo.resolveItemOffsetToCenter(index)?.let {
        animateScrollToItem(index, it)
    }
}

private fun LazyListLayoutInfo.resolveItemOffsetToCenter(index: Int): Int? {
    val itemInfo = visibleItemsInfo.firstOrNull { it.index == index } ?: return null
    val containerSize = viewportSize.width - beforeContentPadding - afterContentPadding
    return -(containerSize - itemInfo.size) / 2
}

And use it like this:

val lazyListState = rememberLazyListState()

LaunchedEffect(selectedIndex) {
    lazyListState.animateScrollToItemCenter(selectedIndex)
}
// Or use rememberCoroutineScope() instead
Lopeared answered 19/9, 2023 at 20:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.