3

FlatList of React Nativehas a property viewabilityConfigCallbackPairs where you can set:

viewabilityConfig: {
    itemVisiblePercentThreshold: 50,
    waitForInteraction: true,
  }

to detect visible items of the list with threshold of 50% and after interaction or scroll.

Does Jetpack Compose also have something similar to this?

There is LazyListState with some layout info. But I wonder if there is anything built-in component/property for this use case.

Edit

I have a list of cardviews and I want to detect which card items (at least 50% of card is visible) are visible on display. But it needs to be detected only when the card is clicked or list is scrolled by user.

Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841
Marat
  • 1,288
  • 2
  • 11
  • 22
  • You have layout info, and it's enough for any viisiblity information. You need a callback when the middle item is shown? – Phil Dukhov Sep 20 '21 at 10:15
  • How do you do different percentage thresholds? In addition, layout info can't give us interaction info – Marat Sep 20 '21 at 11:17
  • What is the interaction information? Do you need to know if the view scrolled programmatically or by user touch? – Phil Dukhov Sep 20 '21 at 11:22
  • yes, by user. For example, when it is scrolled or clicked – Marat Sep 20 '21 at 11:25
  • clicked on what? some button that will scroll your view? I'm not familiar with react native, so I don't know what's `waitForInteraction` responsible for. Please add detailed explanation to your question so everyone without react native knowledge can understand your it. – Phil Dukhov Sep 20 '21 at 11:34

2 Answers2

12

To get an updating list of currently visible items with a certain threshold LazyListState can be used.

LazyListState exposes the list of currently visible items List<LazyListItemInfo>. It's easy to calculate visibility percent using offset and size properties, and thus apply a filter to the visible list for visibility >= threshold.

LazyListItemInfo has index property, which can be used for mapping LazyListItemInfo to the actual data item in the list passed to LazyColumn.

fun LazyListState.visibleItems(itemVisiblePercentThreshold: Float) =
    layoutInfo
        .visibleItemsInfo
        .filter {
            visibilityPercent(it) >= itemVisiblePercentThreshold
        }

fun LazyListState.visibilityPercent(info: LazyListItemInfo): Float {
    val cutTop = max(0, layoutInfo.viewportStartOffset - info.offset)
    val cutBottom = max(0, info.offset + info.size - layoutInfo.viewportEndOffset)

    return max(0f, 100f - (cutTop + cutBottom) * 100f / info.size)
}

Usage

val list = state.visibleItems(50f) // list of LazyListItemInfo

This list has to be mapped first to corresponding items in LazyColumn.

val visibleItems = state.visibleItems(50f)
            .map { listItems[it.index] }

@Composable
fun App() {
    val listItems = remember { generateFakeListItems().toMutableStateList() }

    val state = rememberLazyListState()

    LazyColumn(Modifier.fillMaxSize(), state = state) {
        items(listItems.size) {
            Item(listItems[it])
        }
    }

    val visibleItems by remember(state) {
      derivedStateOf {
        state.visibleItems(50f)
          .map { listItems[it.index] }
      }
    }
    LaunchedEffect(visibleItems) {
      Log.d(TAG, "App: $visibleItems")
    }
}

fun generateFakeListItems() = (0..100).map { "Item $it" }
Community
  • 1
  • 1
Om Kumar
  • 1,404
  • 13
  • 19
  • Thank you. Upvoted. I also ended up using the list state and implemented myself – Marat Sep 22 '21 at 11:47
  • Do let me know if there is anything missing in this answer from getting accepted. Thanks! – Om Kumar Sep 22 '21 at 18:41
  • 1
    small Kotlin tip: you can use `maxOf()` instead of `max()` for all usages inside `visibilityPercent` :-) – voghDev Jan 18 '22 at 11:11
  • 3
    `layoutInfo.visibleItemsInfo` forces recomposition, which goes into infinite recomposition: https://issuetracker.google.com/issues/216499432 – Jemshit Feb 24 '22 at 09:17
  • 1
    The idea is good, but when you have more than one item in the LazyColumn becomes hard, also there should be a check to avoid calling this every second – Skizo-ozᴉʞS ツ Mar 08 '22 at 07:17
  • `layoutInfo` is an observable property which is updated after every layout. Then by recomposition you trigger one more relayout and so on. In this case you have to use `derivedStateOf` – Gabriele Mariotti Dec 03 '22 at 09:20
  • 1
    Tested, and as mentioned this solution will end up on infinite recomposition. – desgraci May 25 '23 at 10:07
1

The LazyListState#layoutInfo contains information about the visible items.
Since you want to apply a threshold you need to check the first and last item positions and size according to viewport size. All other items are for sure visible.

It is important to note that since you are reading the state you should use derivedStateOf to avoid redundant recompositions.

Something like:

@Composable
private fun LazyListState.visibleItemsWithThreshold(percentThreshold: Float): List<Int> {

    return remember(this) {
        derivedStateOf {
            val visibleItemsInfo = layoutInfo.visibleItemsInfo
            if (layoutInfo.totalItemsCount == 0) {
                emptyList()
            } else {
                val fullyVisibleItemsInfo = visibleItemsInfo.toMutableList()
                val lastItem = fullyVisibleItemsInfo.last()

                val viewportHeight = layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset

                if (lastItem.offset + (lastItem.size*percentThreshold) > viewportHeight) {
                    fullyVisibleItemsInfo.removeLast()
                }

                val firstItemIfLeft = fullyVisibleItemsInfo.firstOrNull()
                if (firstItemIfLeft != null &&
                    firstItemIfLeft.offset + (lastItem.size*percentThreshold) < layoutInfo.viewportStartOffset) {
                    fullyVisibleItemsInfo.removeFirst()
                }

                fullyVisibleItemsInfo.map { it.index }
            }
        }
    }.value
}

and then just use:

    val state = rememberLazyListState()

    LazyColumn( state = state ){
       //items
    }
    val visibleItems = state.visibleItemsWithThreshold(percentThreshold = 0.5f)

In this way you have the list of all the visible items with a threshold of 50%. You can observe the list using something:

    LaunchedEffect(visibleItems){
        Log.d(TAG, "App: $visibleItems")
    }
Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841