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 visiblevisiblePercentage
: How much of the view is visiblebounds
: The view's window boundsisAboveThreshold
: 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<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 } }
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<String>() 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.
Top comments (1)
This is good. Thanks