Android開発:複数のRecyclerViewでインプレッションイベントを効果的にトリガーする方法
Hand-Tomi
はじめに
KINTOテクノロジーズでmy routeのAndroidを開発しているHand-Tomiと申します。
最近、私たちのプロジェクトでは、RecyclerView内のRecyclerViewアイテムが完全に表示された際のイベントトリガーが必要になりました。これを実現する方法はいくつかありますが、RecyclerViewのAdapterに少しコードを追加することで解決できる方法を見つけました。この方法とその実装について、皆さんと共有し、意見交換をするためにこの記事を執筆しました。
RecyclerViewとは
RecyclerViewは、Androidアプリケーションで効率的な動的データセットの表示と管理を行うための拡張可能で柔軟なUIコンポーネントです。
この記事の目的
複数RecyclerViewのアイテムが完全に表示された際にイベントをトリガーする方法について説明しましょう。
例えば、左の画像(←)が表示されたときに、Title1
の Card1
と Card2
、そして Title2
の Card1
と Card2
がトリガーされることを想定します。そして、Title2
をスクロールして右の画像(→)のように表示を変えた際に Title2
の Card3
をトリガーする方法について解説します。
左の画像(←) | 右の画像(→) |
---|---|
単語
- 親RecyclerView : 縦スクロールが可能な全体のレイアウトに配置されたRecyclerViewです。
- 子RecyclerView : 「親RecyclerView」のアイテムとして横スクロールが可能なRecyclerViewです。
親RecyclerView | 子RecyclerView |
---|---|
スクロールイベントの受け取り
まず、アイテムの表示に変化があった場合、その表示状況を確認するために以下の二つイベントをトラッキングする必要があります。
- 親RecyclerViewが縦スクロールされる時
- 子RecyclerViewが横スクロールされる時
これらのイベントをトラッキングするためには「子RecyclerView」に、viewTreeObserver.addOnScrollChangedListener
を設定します。
recyclerView.viewTreeObserver.addOnScrollChangedListener {
// TODO: 表示状況の確認
}
viewTreeObserver
では全体のレイアウト変更や描画開始、タッチモードの変更など、ViewTreeのグローバルな変更を監視するリスナーを登録するために使用されます。viewTreeObserver
のaddOnScrollChangedListener
を登録することで画面に含まれているスクロール変更イベントを取得することができるようになります。
「子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の位置とサイズが格納されます。
- このメソッドは、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を扱う際のインプレッションイベントのトリガーに役立つことを願っています。ご質問やフィードバックがありましたら、お気軽にお寄せください。お読みいただき、ありがとうございました!
関連記事 | Related Posts
Hand-Tomi
GeneralAndroid Development: How to Effectively Trigger Impression Events With Multiple RecyclerViews
ソミ
Developmentmyroute Android AppでのJetpack Compose
Romie
DevelopmentCompose超初心者のPreview感動体験
Somi
DevelopmentJetpack Compose in myroute Android App
Jiawen Wu
AnalyticsMicrosoft Clarityを導入してみた
T. Koyama
DevelopmentCombineを使ってMVVMを実現した話
We are hiring!
【iOS/Androidエンジニア】モバイルアプリ開発G/東京・大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。
【オープンポジション】「気になる!」方はまずはこちらからご応募ください。/東京・名古屋・大阪
業務内容国内外のKINTOサービスや、トヨタグループの金融、モビリティサービスの内製開発組織である同社にて、ご経験・ご志向性に応じて配属を決定し、ご活躍いただきます。