KINTO Tech Blog
General

Android Development: How to Effectively Trigger Impression Events With Multiple RecyclerViews

Cover Image for 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 (→)
task_1 task_2

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
word_1 word_2

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.

  1. When the parent RecyclerView is scrolled vertically
  2. When a child RecyclerView is scrolled horizontally
    To track these events, set viewTreeObserver.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, or false if it is not.
    • rect stores the position and size of the view, with the origin at the top left of the screen.
  • if (!this.isAttachedToWindow) return false
    • The getGlobalVisibleRect method may return true if RecyclerView is not included in the layout layer. Therefore, it skips checking and returns `false<4>} if it is not in the layout layer.
  • (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 the checkImpression() method, the processing is skipped if the child RecyclerView is not 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 used findFirstCompletelyVisibleItemPosition() and findLastCompletelyVisibleItemPosition().
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!

Facebook

関連記事 | Related Posts

Somi
Somi
Cover Image for Jetpack Compose in myroute Android App

Jetpack Compose in myroute Android App

ソミ
ソミ
Cover Image for myroute Android AppでのJetpack Compose

myroute Android AppでのJetpack Compose

Romie
Romie
Cover Image for A Beginner’s Story of Inspiration With Compose Preview

A Beginner’s Story of Inspiration With Compose Preview

Romie
Romie
Cover Image for Compose超初心者のPreview感動体験

Compose超初心者のPreview感動体験

T. Koyama
T. Koyama
Cover Image for Using Combine to Achieve MVVM

Using Combine to Achieve MVVM

Hand-Tomi
Hand-Tomi
Cover Image for Potential Bug Triggers in Android Development Due to Regional Preferences

Potential Bug Triggers in Android Development Due to Regional Preferences

We are hiring!

【iOS/Androidエンジニア】モバイルアプリ開発G/東京・大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【モバイルアプリUI/UXデザイナー(リードクラス)】my route開発G/東京

my route開発グループについてmy route開発グループでは、グループ会社であるトヨタファイナンシャルサービス株式会社の事業メンバーと共に、my routeの開発・運用に取り組んでおります。現在はシステムの安定化を図りながら、サービス品質の向上とビジネスの伸長を目指しています。