Marat
Marat

Reputation: 1358

How to check visibility of list item in Jetpack Compose

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.

Upvotes: 3

Views: 9636

Answers (3)

Hevin Mun
Hevin Mun

Reputation: 31

Visibility Tracker works like the viewabilityConfigCallbackPairs.

VisibilityTracker(
    threshold = 0.5f,
    onVisibilityChanged = { isVisible ->
        if (isVisible) yourLogic()
    },
    treatOnStopAsInvisible = true
) {
    // Your composable content here
}

Upvotes: 0

Om Kumar
Om Kumar

Reputation: 1674

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" }

Upvotes: 17

Gabriele Mariotti
Gabriele Mariotti

Reputation: 364948

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")
    }

Upvotes: 2

Related Questions