KINTO Tech Blog
General

Android開発:複数のRecyclerViewでインプレッションイベントを効果的にトリガーする方法

Cover Image for Android開発:複数のRecyclerViewでインプレッションイベントを効果的にトリガーする方法

はじめに

KINTOテクノロジーズでmy routeのAndroidを開発しているHand-Tomiと申します。

最近、私たちのプロジェクトでは、RecyclerView内のRecyclerViewアイテムが完全に表示された際のイベントトリガーが必要になりました。これを実現する方法はいくつかありますが、RecyclerViewのAdapterに少しコードを追加することで解決できる方法を見つけました。この方法とその実装について、皆さんと共有し、意見交換をするためにこの記事を執筆しました。

RecyclerViewとは

RecyclerViewは、Androidアプリケーションで効率的な動的データセットの表示と管理を行うための拡張可能で柔軟なUIコンポーネントです。

この記事の目的

複数RecyclerViewのアイテムが完全に表示された際にイベントをトリガーする方法について説明しましょう。

例えば、左の画像(←)が表示されたときに、Title1Card1Card2、そして Title2Card1Card2 がトリガーされることを想定します。そして、Title2 をスクロールして右の画像(→)のように表示を変えた際に Title2Card3 をトリガーする方法について解説します。

左の画像(←) 右の画像(→)
task_1 task_2

単語

  • 親RecyclerView : 縦スクロールが可能な全体のレイアウトに配置されたRecyclerViewです。
  • 子RecyclerView : 「親RecyclerView」のアイテムとして横スクロールが可能なRecyclerViewです。
親RecyclerView 子RecyclerView
word_1 word_2

スクロールイベントの受け取り

まず、アイテムの表示に変化があった場合、その表示状況を確認するために以下の二つイベントをトラッキングする必要があります。

  1. 親RecyclerViewが縦スクロールされる時
  2. 子RecyclerViewが横スクロールされる時

これらのイベントをトラッキングするためには「子RecyclerView」に、viewTreeObserver.addOnScrollChangedListenerを設定します。

recyclerView.viewTreeObserver.addOnScrollChangedListener { 
    // TODO: 表示状況の確認
}

viewTreeObserverでは全体のレイアウト変更や描画開始、タッチモードの変更など、ViewTreeのグローバルな変更を監視するリスナーを登録するために使用されます。viewTreeObserveraddOnScrollChangedListenerを登録することで画面に含まれているスクロール変更イベントを取得することができるようになります。

「子RecyclerView」がレイアウトに配置された際にイベントを取得するため、「子RecyclerView」のAdapter内でonAttachedToRecyclerView()メソッドに設定し、onDetachedFromRecyclerView()メソッドで解除するコードを記述します。

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 
}

上記のコードを実装することで、親または子のRecyclerViewがスクロールされた際、または初めて表示された際に、イベントがcheckImpression()関数に渡されます。

「子RecyclerView」の完全表示をチェック

「子RecyclerView」自体が完全に表示されていなければ、その中のアイテムも完全に表示されていないと見なされます。したがって、まず「子RecyclerView」が完全に表示されているかを確認する必要があります。この確認のために以下の関数を作成しました。

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
    • このメソッドは、Viewが画面上に一部でも表示されている場合にtrueを返し、全て表示されていない場合にはfalseを返します。
    • rectには、画面の左上を原点とするViewの位置とサイズが格納されます。
  • if (!this.isAttachedToWindow) return false
    • RecyclerViewがレイアウト階層に含まれていない場合、getGlobalVisibleRectメソッドはtrueを返すことがあります。そのため、レイアウト階層に含まれていない場合はチェック処理をスキップし、falseを返します。
  • (rect.bottom - rect.top) >= height
    • 表示されているViewの高さを確認し、Viewの高さを比べてViewが完全に表示されているか確認します。

この関数をcheckImpression()メソッドに組み込むことで、「子RecyclerView」が完全に表示されていない場合、処理をスキップします。

private fun checkImpression() {
    if (recyclerView?.isRecyclerViewFullyVisible() == false) {
        return
    }

    // TODO 「子RecyclerView」のアイテムが完全に表示しているか確認する
}

「子RecyclerView」で完全表示アイテムのPositionを取得

LinearLayoutManagerは、RecyclerViewのアイテムが画面に表示されているかどうかを確認できる関数を提供しています。

  • findFirstCompletelyVisibleItemPosition()
    • 画面に完全に表示されている最初のPositionを返します。
  • findLastCompletelyVisibleItemPosition()
    • 画面に完全に表示されている最初のPositionを返します。
  • findFirstVisibleItemPosition()
    • 画面に部分的にでも表示されている最初のPositionを返します。
  • findLastVisibleItemPosition()
    • 画面に部分的にでも表示されている最初のPositionを返します。

この記事では、アイテムが完全に表示しているかどうかを確認することです。そのためfindFirstCompletelyVisibleItemPosition()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 新しく表示されたアイテムがある場合、イベントをトリガーする
}

アイテムが新しく完全に表示された際のイベントトリガー

上のセッションで取得したPositionでそのままイベントをトリガーすると、現在完全に表示されているアイテム全てに対して何回もイベントをトリガーしてしまいます。

重複した情報を取得したいわけではないので「アイテムが新しく完全に表示された時」にイベントをトリガーする実装してみましょう。

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)) {
        // 新しく完全に表示されたアイテムのPositionが届きます。
        onImpression(position)
    }
    oldRange = newRange
}

fun onImpression(position: Int) {
  // ここでインプレッションイベントを送信します。
}

newRangeには、現在画面上で完全に表示されているアイテムの位置(Position)が格納されます。重複したイベントのトリガーを避けるために、以前にトリガーしたoldRangeを除外した後、新たなイベントをトリガーします。
このようにして、新しく完全に表示されたアイテムのPositionはonImpression()関数に渡されます。そして、この関数内でイベント送信のコードを実装することで、インプレッションイベントの送信処理が完了します。

まとめ

上記のコードを利用することで、アダプター側でインプレッションイベントの監視が可能になると思います
本PJでは便利性を向上させるためにインプレッション機能を備えたImpressionTrackableAdapterを作成し、必要なAdapterがImpressionTrackableAdapterを継承することにしました。
このImpressionTrackableAdapterのコードを下記のトグルに添付しておりますので、必要な方はぜひコピー&ペーストしてご利用ください。

全体コード
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
    }
}

終わり

この記事が、複数のRecyclerViewを扱う際のインプレッションイベントのトリガーに役立つことを願っています。ご質問やフィードバックがありましたら、お気軽にお寄せください。お読みいただき、ありがとうございました!

Facebook

関連記事 | Related Posts

We are hiring!

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

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

【プロジェクトマネージャー】モバイルアプリ開発G/大阪

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