DEV Community

Paul Franco
Paul Franco

Posted on

How to Track Composable Visibility in Jetpack Compose — with a custom .trackVisibility Modifier

In Compose apps, it’s often necessary to know if a Composable is visible on screen and by how much. Whether you’re logging ad impressions, auto-playing videos, lazy-loading data, or triggering animations, visibility tracking is critical.

Jetpack Compose doesn’t expose a built-in way to detect visibility like legacy Views (ViewTreeObserver, RecyclerView.OnScrollListener). But with the power of Modifier.Node, we can create our own.

Today we’ll build and explain a trackVisibility modifier from scratch — with:

  • Full working code

  • Line-by-line explanations

  • Best practices and optimization tips

What the trackVisibility Modifier Does

This custom modifier:

  • Calculates how visible a Composable is (from 0.0 to 1.0)

  • Compares it against a threshold (default 50%)

  • Triggers a callback when the visibility changes significantly

Full Source Code with Explanations

Step 1: Define the visibility info structure

data class VisibilityInfo( val isVisible: Boolean, val visiblePercentage: Float, val bounds: Rect, val isAboveThreshold: Boolean ) 
Enter fullscreen mode Exit fullscreen mode

This class encapsulates visibility state:

  • isVisible: Whether any portion is visible

  • visiblePercentage: How much of the view is visible

  • bounds: The view's window bounds

  • isAboveThreshold: Whether it meets your visibility target

Step 2: The Modifier.Node Implementation

private class VisibilityTrackerNode( var thresholdPercentage: Float, var onVisibilityChanged: (VisibilityInfo) -> Unit, ) : Modifier.Node(), GlobalPositionAwareModifierNode { private var previousVisibilityPercentage: Float? = null private val minimumVisibilityDelta = 0.01f override fun onGloballyPositioned(coordinates: LayoutCoordinates) { val boundsInWindow = coordinates.boundsInWindow() val parentBounds = coordinates.parentLayoutCoordinates?.boundsInWindow() if (parentBounds == null || !coordinates.isAttached) { previousVisibilityPercentage = 0f return } val visibleLeft = max(boundsInWindow.left, parentBounds.left) val visibleRight = min(boundsInWindow.right, parentBounds.right) val visibleTop = max(boundsInWindow.top, parentBounds.top) val visibleBottom = min(boundsInWindow.bottom, parentBounds.bottom) val visibleWidth = max(0f, visibleRight - visibleLeft) val visibleHeight = max(0f, visibleBottom - visibleTop) val visibleArea = visibleWidth * visibleHeight val totalArea = (coordinates.size.width * coordinates.size.height).toFloat().takeIf { it > 0 } ?: return val visibilityPercentage = (visibleArea / totalArea).coerceIn(0f, 1f) val visibilityDifference = previousVisibilityPercentage?.let { previous -> abs(visibilityPercentage - previous) } ?: Float.MAX_VALUE if (visibilityDifference >= minimumVisibilityDelta) { onVisibilityChanged( VisibilityInfo( isVisible = visibilityPercentage > 0f, visiblePercentage = visibilityPercentage, bounds = boundsInWindow, isAboveThreshold = visibilityPercentage >= thresholdPercentage ) ) previousVisibilityPercentage = visibilityPercentage } } } 
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • GlobalPositionAwareModifierNode gives us access to layout bounds.

  • We calculate the overlap between the view and its parent to determine how much is visible.

  • We emit a callback if the visibility changed significantly.

Step 3: The ModifierNodeElement Glue

private class VisibilityTrackerElement( private val thresholdPercentage: Float, private val onVisibilityChanged: (VisibilityInfo) -> Unit, ) : ModifierNodeElement<VisibilityTrackerNode>() { override fun create() = VisibilityTrackerNode(thresholdPercentage, onVisibilityChanged) override fun update(node: VisibilityTrackerNode) { node.thresholdPercentage = thresholdPercentage node.onVisibilityChanged = onVisibilityChanged } override fun equals(other: Any?) = other is VisibilityTrackerElement && other.thresholdPercentage == thresholdPercentage && other.onVisibilityChanged == onVisibilityChanged override fun hashCode(): Int { var result = thresholdPercentage.hashCode() result = 31 * result + onVisibilityChanged.hashCode() return result } override fun InspectorInfo.inspectableProperties() { name = "trackVisibility" properties["thresholdPercentage"] = thresholdPercentage properties["onVisibilityChanged"] = onVisibilityChanged } } 
Enter fullscreen mode Exit fullscreen mode

This element binds the logic to the Compose modifier chain and handles recomposition safety.

Step 4: Public Modifier Extension

fun Modifier.trackVisibility( thresholdPercentage: Float = 0.5f, onVisibilityChanged: (VisibilityInfo) -> Unit, ): Modifier = this then VisibilityTrackerElement(thresholdPercentage, onVisibilityChanged) 
Enter fullscreen mode Exit fullscreen mode

This is the clean, declarative API you use in Composables.

Practical Usage Example with ViewModel

Here's how you would call a ViewModel function from inside trackVisibility safely:

import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.* @Composable fun ShopCardWithTracking(shopId: String, viewModel: ShopListViewModel) { var hasLogged by remember(shopId) { mutableStateOf(false) } Box( modifier = Modifier .fillMaxWidth() .height(250.dp) .trackVisibility(thresholdPercentage = 0.6f) { info -> if (info.isAboveThreshold && !hasLogged) { hasLogged = true viewModel.trackImpression(shopId) } } ) { Text("Shop ID: $shopId") } } 
Enter fullscreen mode Exit fullscreen mode

Ensure you import:

import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue 
Enter fullscreen mode Exit fullscreen mode

To avoid the "property delegate must have a getValue/setValue" compiler error.

Full LazyColumn Example

@Composable fun ShopListScreen(viewModel: ShopListViewModel = viewModel()) { val shops = listOf("A1", "B2", "C3", "D4") LazyColumn { items(properties) { propertyId -> ShopCardWithTracking(shopId, viewModel) } } } 
Enter fullscreen mode Exit fullscreen mode

And a simple ViewModel:

class ShopListViewModel : ViewModel() { private val _loggedImpressions = mutableSetOf<String>() fun trackImpression(shopId: String) { if (_loggedImpressions.add(shopId)) { Log.d("Impression", "Logged impression for $shopId") } } } 
Enter fullscreen mode Exit fullscreen mode

Best Practices & Optimizations

  • Use minimumVisibilityDelta to avoid over-triggering callbacks.

  • Avoid expensive logic inside onVisibilityChanged.

  • Guard with hasLogged to prevent duplicate triggers.

  • Keep ViewModel interactions lightweight.

Top comments (1)

Collapse
 
sahha profile image
Sahha

This is good. Thanks