Type‑agnostic RecyclerView Item Visibility Detection for Android
The article explains why reporting item exposure in onBindViewHolder is inaccurate, reviews existing scroll‑listener based solutions and their limitations, and then presents a layout‑manager‑independent Kotlin extension that reliably detects RecyclerView item visibility based on visible area percentage, also covering ViewPager2 page visibility handling.
In many Android business projects, developers report list item exposure inside onBindViewHolder() , which fires before the item is actually visible and couples the tracking logic with the adapter, breaking reuse and the open‑closed principle.
The author first shows a simple but flawed implementation:
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList
) {
ReportUtil.reportShow("material-item-show", materialId)
}Then a popular Stack Overflow solution is presented that listens to scroll events, obtains the first and last visible positions from a LinearLayoutManager , and calculates each item's visible height percentage. This approach assumes a linear vertical layout and fails for GridLayoutManager or custom managers.
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
val layoutManager = recycler.layoutManager as LinearLayoutManager
val firstPosition = layoutManager.findFirstVisibleItemPosition()
val lastPosition = layoutManager.findLastVisibleItemPosition()
for (pos in firstPosition..lastPosition) {
val view = layoutManager.findViewByPosition(pos)
if (view != null) {
val percentage = getVisibleHeightPercentage(view)
}
}
}
private fun getVisibleHeightPercentage(view: View): Double {
val itemRect = Rect()
val isParentViewEmpty = view.getLocalVisibleRect(itemRect)
val visibleHeight = itemRect.height().toDouble()
val height = view.measuredHeight
val viewVisibleHeightPercentage = visibleHeight / height * 100
return if (isParentViewEmpty) viewVisibleHeightPercentage else 0.0
}
})To avoid layout‑manager‑specific code, the author proposes a generic extension method for RecyclerView that iterates over all child views, compares each child’s visible rectangle with its full size, and triggers a callback when the visible area exceeds a configurable threshold (default 50%). The method also handles attaching/detaching listeners to prevent memory leaks.
fun RecyclerView.addOnItemVisibilityChangeListener(
percent: Float = 0.5f,
block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit
) {
val scrollListener = object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
checkVisibility()
}
}
// ... (visibility checking logic that uses getLocalVisibleRect and area comparison)
addOnScrollListener(scrollListener)
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewDetachedFromWindow(v: View?) {
if (v is RecyclerView) {
v.removeOnScrollListener(scrollListener)
v.removeOnAttachStateChangeListener(this)
}
}
override fun onViewAttachedToWindow(v: View?) {}
})
}This solution works for any LayoutManager because it directly queries the RecyclerView ’s child views. It also reports both visibility and invisibility events, enabling richer upstream handling.
For cases where the list is covered by a fragment (e.g., a bottom tab bar), the author adds a global visibility listener that forces all currently visible items to be reported as invisible when the whole RecyclerView becomes hidden.
onVisibilityChange(viewGroups, false) { view, isVisible ->
if (!isVisible) {
for (i in 0 until childCount) {
val child = getChildAt(i)
val adapterIndex = getChildAdapterPosition(child)
if (adapterIndex in visibleAdapterIndexs) {
block(child, adapterIndex, false)
visibleAdapterIndexs.remove(adapterIndex)
}
}
}
}The article also extends the idea to ViewPager2 , which internally uses a RecyclerView but does not expose it. A custom extension adds a page‑visibility listener that records the previously selected page and invokes callbacks for visibility changes.
fun ViewPager2.addOnPageVisibilityChangeListener(
block: (index: Int, isVisible: Boolean) -> Unit
) {
var lastPage = currentItem
val listener = object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
if (lastPage != position) block(lastPage, false)
block(position, true)
lastPage = position
}
}
registerOnPageChangeCallback(listener)
addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
override fun onViewDetachedFromWindow(v: View?) {
if (v is ViewPager2 && ViewCompat.isAttachedToWindow(v)) {
v.unregisterOnPageChangeCallback(listener)
v.removeOnAttachStateChangeListener(this)
}
}
override fun onViewAttachedToWindow(v: View?) {}
})
}By abstracting visibility detection into reusable, layout‑manager‑agnostic extensions, the approach simplifies tracking logic, improves code reuse across different list configurations, and adheres to clean architecture principles.
Sohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.