Get last visible item index in jetpack compose LazyColumn
A

7

37

I want to check if the list is scrolled to end of the list. How ever the lazyListState does not provide this property

Why do I need this? I want to show a FAB for "scrolling to end" of the list, and hide it if last item is already visible

(Note: It does, but it's internal

  /**
   * Non-observable way of getting the last visible item index.
   */
  internal var lastVisibleItemIndexNonObservable: DataIndex = DataIndex(0)

no idea why)

val state = rememberLazyListState()
LazyColumn(
    state = state,
    modifier = modifier.fillMaxSize()
) {
    // if(state.lastVisibleItem == logs.length - 1) ...
    items(logs) { log ->
        if (log.level in viewModel.getShownLogs()) {
            LogItemScreen(log = log)
        }
    }
}

So, how can I check if my LazyColumn is scrolled to end of the dataset?

Arriola answered 19/3, 2021 at 16:46 Comment(2)
If your objective is to load more data at this point, consider using Paging for Compose.Colwin
Nope. Just want to show a "Scroll to end" fabArriola
I
22

Here is a way for you to implement it:

Extension function to check if it is scrolled to the end:

fun LazyListState.isScrolledToTheEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1

Example usage:

val listState = rememberLazyListState()
val listItems = (0..25).map { "Item$it" }

LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
    items(listItems) { item ->
        Text(text = item, modifier = Modifier.padding(16.dp))
    }
}

Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) {
    if (!listState.isScrolledToTheEnd()) {
        ExtendedFloatingActionButton(
            modifier = Modifier.padding(16.dp),
            text = { Text(text = "Go to Bottom") },
            onClick = { /* Scroll to the end */}
        )
    }
}
Imprimis answered 26/3, 2021 at 0:38 Comment(4)
The link is no longer validWarnock
Accessing layoutInfo of LazyListState causes recomposing then ends up with infinity composition. A trick for avoiding that is to remember the firstVisibleItemIndex and check if the value is changed before getting layoutInfoAriellearies
This happened to us in our app too. I created an Issue Tracker here: issuetracker.google.com/issues/216499432Chaussure
Important to point out that layoutInfo.visibleItemsInfo.lastIndex will return lastIndex of currently visible items, if you can see 3 items, 2 will be returned for example which is different from layoutInfo.visibleItemsInfo.lastOrNull()?.index that would return index of the item in the list in general, not the list of visible itemsPuck
A
19

Starting from 1.4.0-alpha03 you can use LazyListState#canScrollForward to check if you can scroll forward or if you are at the end of the list.

   val state = rememberLazyListState()
   if  (!state.canScrollForward){ /* ... */  }

Before you can use the LazyListState#layoutInfo that contains information about the visible items. You can use it to retrieve information if the list is scrolled at the bottom.
Since you are reading the state you should use derivedStateOf to avoid redundant recompositions.

Something like:

val state = rememberLazyListState()

val isAtBottom by remember {
    derivedStateOf {
        val layoutInfo = state.layoutInfo
        val visibleItemsInfo = layoutInfo.visibleItemsInfo
        if (layoutInfo.totalItemsCount == 0) {
            false
        } else {
            val lastVisibleItem = visibleItemsInfo.last()
            val viewportHeight = layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset

            (lastVisibleItem.index + 1 == layoutInfo.totalItemsCount &&
                    lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight)
        }
    }
}

enter image description here

Aetna answered 2/12, 2022 at 11:25 Comment(1)
Is this an IF statement? (lastVisibleItem.index + 1 == layoutInfo.totalItemsCount && lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight) ???Nucleolated
E
13

I am sharing my solution in case it helps anyone.

It provides the info needed to implement the use case of the question and also avoids infinite recompositions by following the recommendation of https://developer.android.com/jetpack/compose/lists#control-scroll-position.

  1. Create these extension functions to calculate the info needed from the list state:
val LazyListState.isLastItemVisible: Boolean
        get() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
    
val LazyListState.isFirstItemVisible: Boolean
        get() = firstVisibleItemIndex == 0
  1. Create a simple data class to hold the information to collect:
data class ScrollContext(
    val isTop: Boolean,
    val isBottom: Boolean,
)
  1. Create this remember composable to return the previous data class.
@Composable
fun rememberScrollContext(listState: LazyListState): ScrollContext {
    val scrollContext by remember {
        derivedStateOf {
            ScrollContext(
                isTop = listState.isFirstItemVisible,
                isBottom = listState.isLastItemVisible
            )
        }
    }
    return scrollContext
}

Note that a derived state is used to avoid recompositions and improve performance. The function needs the list state to make the calculations inside the derived state. Read the link I shared above.

  1. Glue everything in your composable:
@Composable
fun CharactersList(
    state: CharactersState,
    loadNewPage: (offset: Int) -> Unit
) {
    // Important to remember the state, we need it
    val listState = rememberLazyListState()
    Box {
        LazyColumn(
            state = listState,
        ) {
            items(state.characters) { item ->
                CharacterItem(item)
            }
        }

        // We use our remember composable to get the scroll context
        val scrollContext = rememberScrollContext(listState)

        // We can do what we need, such as loading more items...
        if (scrollContext.isBottom) {
            loadNewPage(state.characters.size)
        }

        // ...or showing other elements like a text
        AnimatedVisibility(scrollContext.isBottom) {
            Text("You are in the bottom of the list")
        }

        // ...or a button to scroll up
        AnimatedVisibility(!scrollContext.isTop) {
            val coroutineScope = rememberCoroutineScope()
            Button(
                onClick = {
                    coroutineScope.launch {
                        // Animate scroll to the first item
                        listState.animateScrollToItem(index = 0)
                    }
                },
            ) {
                Icon(Icons.Rounded.ArrowUpward, contentDescription = "Go to top")
            }
        }
    }
}

Cheers!

Ecclesiology answered 2/4, 2022 at 20:23 Comment(1)
Remember to import import androidx.compose.runtime.*Oneirocritic
C
4

It's too late, but maybe it would be helpful to others. seeing the above answers, The layoutInfo.visibleItemsInfo.lastIndex will cause recomposition many times, because it is composed of state. So I recommend to use this statement like below with derivedState and itemKey in item(key = "lastIndexKey").

    val isFirstItemFullyVisible = remember {
        derivedStateOf {
            listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
        }
    }

    val isLastItemFullyVisible by remember {
        derivedStateOf {
            listState.layoutInfo
                .visibleItemsInfo
                .any { it.key == lastIndexKey }.let { _isLastIndexVisible ->
                    if(_isLastIndexVisible){
                        val layoutInfo = listState.layoutInfo
                        val lastItemInfo = layoutInfo.visibleItemsInfo.lastOrNull() ?: return@let false

                        return@let lastItemInfo.size+lastItemInfo.offset == layoutInfo.viewportEndOffset
                    }else{
                        return@let false
                    }
                }
        }
    }

    if (isFirstItemFullyVisible.value || isLastItemFullyVisible) {
         // TODO
    }

Cecilla answered 30/8, 2022 at 14:5 Comment(0)
A
3

Current solution that I have found is:

LazyColumn(
    state = state,
    modifier = modifier.fillMaxSize()
) {
    if ((logs.size - 1) - state.firstVisibleItemIndex == state.layoutInfo.visibleItemsInfo.size - 1) {
        println("Last visible item is actually the last item")
        // do something
    }
    items(logs) { log ->
        if (log.level in viewModel.getShownLogs()) {
            LogItemScreen(log = log)
        }
    }
}

The statement
lastDataIndex - state.firstVisibleItemIndex == state.layoutInfo.visibleItemsInfo.size - 1
guesses the last item by subtracting last index of dataset from first visible item and checking if it's equal to visible item count

Arriola answered 19/3, 2021 at 17:2 Comment(0)
S
3

Just wanted to build upon some of the other answers posted here.

@Tuan Chau mentioned in a comment that this will cause infinite compositions, here is something I tried using his idea to avoid this, and it seems to work ok. Open to ideas on how to make it better!

@Composable
fun InfiniteLoadingList(
    modifier: Modifier,
    items: List<Any>,
    loadMore: () -> Unit,
    rowContent:  @Composable (Int, Any) -> Unit
) {
    val listState = rememberLazyListState()
    val firstVisibleIndex = remember { mutableStateOf(listState.firstVisibleItemIndex) }
    LazyColumn(state = listState, modifier = modifier) {
        itemsIndexed(items) { index, item ->
            rowContent(index, item)
        }
    }
    if (listState.shouldLoadMore(firstVisibleIndex)) {
        loadMore()
    }
}

Extension function:

fun LazyListState.shouldLoadMore(rememberedIndex: MutableState<Int>): Boolean {
  val firstVisibleIndex = this.firstVisibleItemIndex
  if (rememberedIndex.value != firstVisibleIndex) {
      rememberedIndex.value = firstVisibleIndex
      return layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
  }
  return false
}

Usage:

    InfiniteLoadingList(
        modifier = modifier,
        items = listOfYourModel,
        loadMore = { viewModel.populateMoreItems() },
    ) { index, item ->
    val item = item as YourModel
    // decorate your row
}
Spue answered 23/1, 2022 at 0:29 Comment(0)
P
2

Try this:

val lazyColumnState = rememberLazyListState()
val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.lastIndex + state.firstVisibleItemIndex
Pushing answered 31/1, 2022 at 21:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.