Detecting List Items Observed by User
Improve your knowledge of RxJava and RecyclerView APIs with this tutorial.
Scrollable sets of items are one of the main UI elements of every app. Quite often a business wants to know if a user has viewed and perceived a specific item. From here, we need to figure out if a user spent enough time in order 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 the user held the item in the viewport for at least 250 milliseconds. The image below illustrates this with an example.
Technically, this means we need to send “list item id## was viewed” tracking events to the analytics SDK (it can be Firebase, Google Analytics, etc) based on a few conditions. Below I have formalized the requirements we need to meet to implement this logic:
- Distinct: skip the event when the visible item set is equal to one that has just been processed. Use case is multiple callbacks from the swipe gesture;
- Timeout: fire the event only after a specific timeout, 250ms in our case;
- Skip previous event if a distinct event has arrived before the timeout: a previous tracking event should be skipped if the user hasn’t held the item for the defined timeout and scrolled to another list item;
- Reset: reset the state of the logic defined above in case the current Activity is stopped. We need this to track the view again when our 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 is a LinearLayoutManager. It makes your RecyclerView look and feel like a good old ListView. To achieve basic list item visibility detection, we can go with these two methods to be called on every scroll:
int findFirstCompletelyVisibleItemPosition()
int findLastCompletelyVisibleItemPosition()
To detect scroll events in RecyclerView we need to add 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 completed by a user.
However, these classes don’t provide us with information about how long a user was looking at the current item. We need to do this 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 the item comes to the viewport. 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 the currently visible item list to compare them with those that have appeared/disappeared after a scroll event.
However, this means you would only be able to catch the “view” event when the user scrolls out the item, but not immediately when the timeout (250ms in our case) will fire. Moreover, you need a separate trick to “force” the tracking when your current Activity is stopped (so force tracking in onStop() callback and not on scroll).
Another trade-off of this pattern is the amount of ScrollListener callbacks you need to process for every swipe. It becomes an issue because with every callback you will need to do a visible items and timeouts check, which might impact app performance.
Approach #2: Scroll callbacks and RxJava Subscribers
Discussing Approach #1, my colleague Simon Percic revealed a possible use case for RxJava to solve this problem in a more elegant way. Indeed, we can implement event bus functionality using PublishSubject and post a new event to observe each 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 put it to the separate TrackingBus class with all required callbacks inside. This class should be instantiated in 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 the 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 tracking callback. 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 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 the GroceryStore project from GitHub to see the complete demo project.
import java.util.concurrent.TimeUnit;
import rx.Subscription;
import rx.functions.Action1;
import rx.subjects.PublishSubject;
import rx.subjects.Subject;
public class ThrottleTrackingBus {
private static final int THRESHOLD_MS = 250;
private Subject publishSubject;
private Subscription subscription;
private final Action1 onSuccess;
public ThrottleTrackingBus(final Action1 onSuccess,
final Action1 onError) {
this.onSuccess = onSuccess;
this.publishSubject = PublishSubject.create();
this.subscription = publishSubject
.distinctUntilChanged()
.throttleWithTimeout(THRESHOLD_MS, TimeUnit.MILLISECONDS)
.subscribe(this::onCallback, onError);
}
public void postViewEvent(final VisibleState visibleState) {
publishSubject.onNext(visibleState);
}
public void unsubscribe() {
subscription.unsubscribe();
}
private void onCallback(VisibleState visibleState) {
onSuccess.call(visibleState);
}
public static class VisibleState {
final int firstCompletelyVisible;
final int lastCompletelyVisible;
public VisibleState(int firstCompletelyVisible,
int lastCompletelyVisible) {
this.firstCompletelyVisible = firstCompletelyVisible;
this.lastCompletelyVisible = lastCompletelyVisible;
}
// TODO please implement equals and hashCode, required for the distinction logic
}
}
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.
Feedback welcome!
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. You can find me on Twitter at @sergiizhuk.
We're hiring! Do you like working in an ever evolving organization such as Zalando? Consider joining our teams as a Frontend Engineer!