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 ) 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 } } } 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() { 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 } } 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) This is

May 3, 2025 - 06:35
 0
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
)

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

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() {

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

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)

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

Ensure you import:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

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

And a simple ViewModel:

class ShopListViewModel : ViewModel() {
    private val _loggedImpressions = mutableSetOf()

    fun trackImpression(shopId: String) {
        if (_loggedImpressions.add(shopId)) {
            Log.d("Impression", "Logged impression for $shopId")
        }
    }
}

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.