Detecting List Items Observed by User
Scrollable sets of items are one of the main UI elements for every app. Quite often, the business wants to know if the user has viewed and perceived some specific item. This means that we need to figure out if the user spent enough time to accept the content. Let’s find an Android solution using RecyclerView and RxJava.
Why and what?
Our team met with the following requirement: identify which item of the RecyclerView list was viewed and perceived by the user. Perceived in this context means that user held the item in the viewport for at least 250 milliseconds. The image below illustrates this with an example.
Technically, this means that we need to send “list item id## was viewed” tracking events to the analytics SDK (it can be Firebase, Google Analytics, etc.) based on some conditions. Below I’ve formalized the requirements we need to meet to implement this logic:
- Distinct: skip the event when the visible item set is equal to the one been just processed. The use case is multiple callbacks from the swipe gesture;
- Timeout: fire the event only after specific timeout, 250ms in our case;
- Skip the previous event if a distinct event arrived before the timeout: the previous tracking event should be skipped if the user didn’t hold the item for the defined timeout and scrolled to another list item;
- Reset: reset the state of the logic defined above if the current Activity is stopped. We need this to track view event again when the user comes back.
RecyclerView and visible items
The RecyclerView itself is only a structure to provide a limited window to the list of items. To measure, position, and determine visibility state, we need to use the LayoutManager abstract class. One of the most common implementations of it is a LinearLayoutManager. It makes your RecyclerView look and feel like a good old ListView. To achieve the basic list item visibility detection, we can go with these two methods to be called on every scroll:
To detect scroll events in the RecyclerView, we need to add a scroll listener, RecyclerView.OnScrollListener, which provides us with onScroll() callback. The annoying thing about this callback is that it is called multiple times during one swipe action done by a user.
However, these classes don’t tell us how long the user was looking at the current item. We need to do it on our own.
Approach #1: Scroll callbacks and visible items state
The most obvious way to detect items perceived by the user is to check the scroll state and mark your list items “viewed”. In this way, you will need to add a timestamp to every item. This timestamp should be set when item comes to the viewport. Then you’d also perform a check and optionally trigger tracking if needed when the list item gets out of the viewport. Additionally, you will need to keep a list of currently available items to compare with ones appeared/disappeared after a scroll event.
This would allow you to catch the “view” event only when user scrolls out the item, but not immediately when the timeout (250ms in our case) fires. Moreover, you need a separate trick to “force” the tracking when your current Activity is stopped (so force tracking in the onStop() callback and not on scroll).
Another trade-off of this pattern is the amount of ScrollListener callbacks you need to process on every swipe. It becomes an issue because on every callback you will need to do the visible items and timeout check, which might adversely affect app performance.
Approach #2: Scroll callbacks and RxJava Subscribers
Discussing Approach #1, my colleague Simon Percic saw the possible use case for RxJava to solve this problem in more elegant way. Indeed, we can implement event bus functionality using PublishSubject and post a new event to observe every time the list item appears in the viewport. To achieve the timeout effect and to not track the same item several times, we can use filtering operators available in Rx.
To isolate this piece of logic from the main code, we created a separate ThrottleTrackingBus class with all required callbacks inside. This class should be instantiated in the onResume() callback of the target Activity/Fragment and unsubscribed in onPause().
Below is the set of filters we used to meet the requirements:
- distinctUntilChanged to skip equal events in case of multiple scroll callbacks;
- throttleWithTimeout/debounce to pass an event with a delay and drop current event if another event arrives before the timeout.
Our bus itself requires the following setup:
- Keep the PublishSubject instance to apply filters on view events and fire the tracking callback. Update: you can use PublishRelay as well. It omits a terminal state behaviour in case of onComplete() or onError();
- Keep the Subscription instance to unsubscribe and avoid leaks when the Activity/Fragment is not visible any more.
Complete solution: View Tracking Bus with RxJava
The code snippet below illustrates the RxJava solution we developed. Check out the GroceryStore project from GitHub to see a complete demo project.
The logic behind this code is the following. Each RecyclerView scroll event calls the postViewEvent() method, which puts the provided VisibleState to the bus. Since that bus has a distinctUntilChanged, it won’t post any new VisibleState which is equal to the current one. Since it has a throttle, it won’t be posted if another one comes right after it. If no new event comes within 250 ms, the event will be propagated down the chain and in onCallback, we’ll finally call the provided function to track the VisibleState.
Thanks for reading!
I hope this post improved your knowledge of RxJava and RecyclerView APIs. Feel free to use this ready-to-go solution for scrolled items tracking and suggest your improvements.
Check out my other blog posts to learn more about RxJava and app tracking: