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

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