Blog Infos
Author
Published
Topics
, , ,
Published
Jetpack Compose 1.9 — Visibility APIs

Have you ever wondered how TikTok knows when to autoplay the next video, or how an app decides when to mark an item as “seen”?

That’s visibility tracking — and until Jetpack Compose 1.9, it was clunky to implement.

When Compose 1.9 dropped in August ’25, one of the features that really caught my attention was the new visibility APIs.

You can read the official announcement here, but the gist is simple: you can now react to Composables becoming visible or hidden directly in your UI code.

The new APIs in action

 

LazyColumn {
    items(feedData) { video ->
        VideoRow(
            video,
            Modifier.onVisibilityChanged(
                minDurationMs = 500,
                minFractionVisible = 1f,
            ) { visible ->
                // Called when this Composable enters or exits the viewport
                if (visible) video.play() else video.pause()
            },
        )
    }
}

 

 

LazyColumn {
    items(100) {
        Box(
            Modifier
                .onFirstVisible(minDurationMs = 500) {
                  // Called only the first time this Composable is shown
                }
                .clip(RoundedCornerShape(16.dp))
                .drawBehind { drawRect(backgroundColor) }
                .fillMaxWidth()
                .height(100.dp)
        )
    }
}

 

Both methods allow you to specify:

  • The minimum time it must be visible (minDurationMs)
  • The fraction of the Composable that must be visible (minFractionVisible).

Both methods allow you to specify the minimum time it should be visible before the callback is triggered and as well how much of it needs to be visible.

Visibility Tracking before Compose 1.9

The first thing I thought was “Wait, what’s the current way of doing this then?”. Before Compose 1.9, the only real option was to watch LazyListState.layoutInfo.visibleItemsInfo and diff the set of visible indices between frames:

LaunchedEffect(listState) {
    snapshotFlow { listState.layoutInfo.visibleItemsInfo.map { it.key }.toSet() }
        .collect { visibleIds ->
            // Compare with previous set, detect entered/exited items
        }
}

Okay, that looks interesting. Let’s investigate both approaches!

Milliscope

Milliscope is a sample app with a simple idea:

  • Display a list of items in a list.
  • Each list item starts a timer when it becomes visible.
  • The timer stops when it goes off-screen.
  • At the end, each item shows the total time spent in view.

The aim is to compare both methods and see how much easier is with the new APIs.

The app has two branches: Snapshot and onVisibilityChanged. Each branch is identical to the other except for the way compose tracks visibility.

Here for the snapshot version:

@Composable
private fun NotifyVisibilityChanges(
    listState: LazyListState,
    onAction: (MainActivityAction) -> Unit,
    state: MainActivityState,
) {
    val idsByIndexState = rememberUpdatedState(newValue = state.items.map { it.id })

    LaunchedEffect(listState) {
        var previouslyVisibleIds: Set<ItemId> = emptySet()
        snapshotFlow { listState.layoutInfo.visibleItemsInfo.map { it.index }.sorted() }
            .distinctUntilChanged()
            .collect { visibleIndices ->
                val idsByIndex = idsByIndexState.value
                val visibleIds = visibleIndices.map { idsByIndex[it] }.toSet()
                val becameVisible = visibleIds - previouslyVisibleIds
                val becameHidden = previouslyVisibleIds - visibleIds
                becameVisible.forEach { onAction(BecameVisible(it)) }
                becameHidden.forEach { onAction(BecameNotVisible(it)) }
                previouslyVisibleIds = visibleIds
            }
    }
}

Here for the onVisibilityChanged version:

@Composable
private fun Item(
    item: MainActivityItemUi,
    onAction: (MainActivityAction) -> Unit,
) {
    Card(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
        ListItem(
            modifier = Modifier.onVisibilityChanged(
                minDurationMs = 0,
                minFractionVisible = 1f
            ) { isVisible ->
                when {
                    isVisible -> onAction(BecameVisible(item.id))
                    else -> onAction(BecameNotVisible(item.id))
                }
            },
            colors = ListItemDefaults.colors(containerColor = Color.Transparent),
            headlineContent = { Text(text = item.label) },
            trailingContent = { Text(text = item.formattedVisibleTimeInSeconds) },
        )
    }
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Results

After testing both implementations in Milliscope:

  • ✅ Simplicity:
  • onVisibilityChanged is much cleaner. The logic sits right inside each item, instead of managing sets of indices in a separate effect.
  • ❌ Reliability:
  • In my experiments, onVisibilityChanged sometimes missed events.
  • The snapshot approach, while verbose, was consistently correct. It always matched the real set of visible items, even under stress.
Takeaways
  • Compose 1.9 finally gives us first-class visibility APIs.
  • They are elegant, colocated with UI, and reduce boilerplate.
  • But they still feel immature: in real apps, you may hit edge cases.
  • For now, if you need production-ready reliability, stick with layoutInfo snapshot flows.
  • Keep an eye on these modifiers — they’re promising and likely to improve in future releases.
Update on 27/9/25

After updating Compose to version 1.9.2 (2025.09.01) the issues went away and now both versions work reliably. More details here.

This article was previously published on proandroiddev.com.

Menu