Android Development: How to Effectively Trigger Impression Events With Multiple RecyclerViews
Introduction
I am Hand-Tomi, and I am developing the "my route" app for Android at KINTO Technologies.
Recently, our project needed an event trigger for when a RecyclerView item in the RecyclerView was fully visible. There are several ways to do this, but we found a solution that could work by adding a little code to the RecyclerView Adapter. I am writing this article to share and exchange ideas about this method and its implementation.
What is a RecyclerView?
A RecyclerView is an extensible and flexible UI component for viewing and managing dynamic datasets efficiently in Android applications.
The Goal of This Article
I will explain how to trigger an event when multiple RecyclerView items are fully visible.
For example, when the image on the left (←) is visible, Card1
and Card2
in Title1
and Card1
and Card2
in Title2
should be triggered. I will explain how to trigger Card3
in Title2
when you scroll Title2
and change the visibility as shown in the image on the right (→).
Left image (←) | Right image (→) |
---|---|
Terms
- Parent RecyclerView: A RecyclerView placed in an overall layout that allows vertical scrolling.
- Child RecyclerView: A RecyclerView that can be scrolled horizontally as an item in the Parent RecyclerView.
Parent RecyclerView | Child RecyclerView |
---|---|
Receiving Scroll Events
First of all, when an item's visibility changes, it is necessary to track the following two events in order to check the item's visibility status.
- When the parent RecyclerView is scrolled vertically
- When a child RecyclerView is scrolled horizontally
To track these events, setviewTreeObserver.addOnScrollChangedListener
for the child RecyclerView.
recyclerView.viewTreeObserver.addOnScrollChangedListener {
// TODO: Check the visibility status
}
viewTreeObserver
is used to register listeners that watch for global changes to the ViewTree, such as global layout changes, start of drawing, and changes to touch mode. By registering addOnScrollChangedListener
in viewTreeObserver
, you can get the scroll change events included in the screen.
To get an event when the child RecyclerView is placed in the layout, write code that sets the onAttachedToRecyclerView()
method in the Adapter of the child RecyclerView and releases it with the onDetachedFromRecyclerView()
method.
private val globalOnScrollChangedListener = ViewTreeObserver.OnScrollChangedListener {
checkImpression()
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
this.recyclerView = recyclerView
recyclerView.viewTreeObserver.addOnScrollChangedListener(globalOnScrollChangedListener)
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
recyclerView.viewTreeObserver.removeOnScrollChangedListener(globalOnScrollChangedListener)
}
private fun checkImpression() {
// TODO Check
}
By implementing the above code, when the parent or child RecyclerView is scrolled or visible for the first time, the event is passed to the checkImpression()
function.
Checking That the Child RecyclerView Is Fully Visible
If the child RecyclerView itself is not fully visible, the items in it are also considered not fully visible. Therefore, you first need to make sure that the child RecyclerView is fully visible. For that purpose, we created the following function.
private fun RecyclerView.isRecyclerViewFullyVisible(): Boolean {
if (!this.isAttachedToWindow) return false
val rect = Rect()
val isVisibleRecyclerView = getGlobalVisibleRect(rect)
if (!isVisibleRecyclerView) return false
return (rect.bottom - rect.top) >= height
}
View.getGlobalVisibleRect(rect: Rect): Boolean
- This method returns
true
if the view is at least partially visible on the screen, orfalse
if it is not. rect
stores the position and size of the view, with the origin at the top left of the screen.
- This method returns
if (!this.isAttachedToWindow) return false
- The
getGlobalVisibleRect
method may returntrue
ifRecyclerView
is not included in the layout layer. Therefore, it skips checking and returns `false<4>} if it is not in the layout layer.
- The
(rect.bottom - rect.top) >= height
- Check the height of the visible view and compare it with the height of the view to see if it is fully visible.
With this function included in thecheckImpression()
method, the processing is skipped if the child RecyclerView is not fully visible.
- Check the height of the visible view and compare it with the height of the view to see if it is fully visible.
private fun checkImpression() {
if (recyclerView?.isRecyclerViewFullyVisible() == false) {
return
}
// TODO Check that the items in the Child RecyclerView are fully visible
}
Get the positions of the fully visible items in Child RecyclerView
LinearLayoutManager
provides a function that allows you to determine if the items in the RecyclerView are visible on the screen.
findFirstCompletelyVisibleItemPosition()
- Returns the first position that is fully visible on the screen.
findLastCompletelyVisibleItemPosition()
- Returns the first position that is fully visible on the screen.
findFirstVisibleItemPosition()
- Returns the first Position that is at least partially visible on the screen.
findLastVisibleItemPosition()
- Returns the first Position that is at least partially visible on the screen.
In this article, we want to make sure that the items are fully visible. So, we usedfindFirstCompletelyVisibleItemPosition()
andfindLastCompletelyVisibleItemPosition()
.
- Returns the first Position that is at least partially visible on the screen.
private fun checkImpression() {
if (recyclerView?.isRecyclerViewFullyVisible() == false) {
return
}
val layoutManager = layoutManager as? LinearLayoutManager ?: return null
val first = layoutManager.findFirstCompletelyVisibleItemPosition()
val last = layoutManager.findLastCompletelyVisibleItemPosition()
// TODO Trigger the event if there is a newly visible item
}
Event trigger for when a new item is fully visible
When an event with the position obtained in the above session is triggered, it will trigger the event for each item that is currently fully visible.
Since we don’t want to get duplicate information, let’s implement an event that triggers when a new item is fully visible.
private var oldRange = IntRange.EMPTY
private fun checkImpression() {
if (recyclerView?.isRecyclerViewFullyVisible() != true) {
oldRange = IntRange.EMPTY
return
}
val layoutManager = recyclerView?.layoutManager as? LinearLayoutManager ?: return
val newFirst = layoutManager.findFirstCompletelyVisibleItemPosition()
val newLast = layoutManager.findLastCompletelyVisibleItemPosition()
val newRange = newFirst..newLast
for (position in newRange.minus(oldRange)) {
// Sends the position of a new item that is fully visible.
onImpression(position)
}
oldRange = newRange
}
fun onImpression(position: Int) {
// Send the impression event here.
}
newRange
contains the position of the item that is currently fully visible on the screen. To avoid triggering duplicate events, remove the previously triggered oldRange
and then trigger a new event. This way, the position of the new item that is fully visible is passed to the onImpression()
function. Then, by implementing the code that sends the event in this function, the process of sending the impression event is completed.
Summary
By using the above code, it is possible to monitor impression events on the Adapter side. This Project Manager created ImpressionTrackableAdapter
, which has the impression function, to improve convenience, and decided that the required Adapter inherits ImpressionTrackableAdapter
. I will attach the ImpressionTrackableAdapter
code to the toggle below, so feel free to copy and paste it if you need it.
Complete code
abstract class ImpressionTrackableAdapter<VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
private val globalOnScrollChangedListener =
ViewTreeObserver.OnScrollChangedListener { checkImpression() }
private var recyclerView: RecyclerView? = null
private var oldRange = IntRange.EMPTY
abstract fun onImpressionItem(position: Int)
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
this.recyclerView = recyclerView
recyclerView.viewTreeObserver.addOnScrollChangedListener(globalOnScrollChangedListener)
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
this.recyclerView = null
recyclerView.viewTreeObserver.removeOnScrollChangedListener(globalOnScrollChangedListener)
}
private fun checkImpression() {
if (recyclerView?.isRecyclerViewFullyVisible() != true) {
oldRange = IntRange.EMPTY
return
}
val layoutManager = recyclerView?.layoutManager as? LinearLayoutManager ?: return
val newFirst = layoutManager.findFirstCompletelyVisibleItemPosition()
val newLast = layoutManager.findLastCompletelyVisibleItemPosition()
val newRange = newFirst..newLast
for (position in newRange.minus(oldRange)) {
onImpressionItem(position)
}
oldRange = newRange
}
private fun RecyclerView.isRecyclerViewFullyVisible(): Boolean {
if (!this.isAttachedToWindow) return false
val rect = Rect()
val isVisibleRecyclerView = getGlobalVisibleRect(rect)
if (!isVisibleRecyclerView) return false
return (rect.bottom - rect.top) >= height
}
}
End
I hope this article will help you with triggering impression events when working with multiple RecyclerViews. If you have any questions or feedback, please feel free to contact us. Thank you for reading!
関連記事 | Related Posts
We are hiring!
【プロジェクトマネージャー】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。
【iOSエンジニア】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。