Experimenting with Nested Scrolling
One of the coolest projects I worked on during my 3 years at Google was Google Expeditions, a virtual reality app that allows teachers to lead students on immersive virtual field trips around the world. I especially enjoyed working on the app’s field trip selector screen, which renders a SurfaceView
behind a beautifully designed card-based layout that allows the user to quickly switch between different VR experiences.
It’s been awhile since I’ve written Android code (I’ve spent a majority of the past year building Android developer tools like Shape Shifter and avocado
), so the other day I challenged myself to rewrite parts of the screen as an exercise. Figure 1 shows a side-by-side comparison of Google Expeditions’ field trip selector screen and the resulting sample app I wrote (available on GitHub and Google Play).
As I was working through the code, I was reminded of some of the challenges I faced with Android’s nested scrolling APIs when I first wrote the screen a couple of years ago. Introduced in API 21, the nested scrolling APIs make it possible for a scrollable parent view to contain scrollable children views, enabling us to create the scrolling gestures that Material Design formalizes on its scrolling techniques patterns page. Figure 2 shows a common use case of these APIs involving a parent CoordinatorLayout
and a child NestedScrollView
. Without nested scrolling, the NestedScrollView
scrolls independently of its surroundings. Once enabled, however, the CoordinatorLayout
and NestedScrollView
take turns intercepting and consuming the scroll, creating a ‘collapsing toolbar’ effect that looks more natural.
How exactly do the nested scrolling APIs work? For starters, you need a parent view that implements NestedScrollingParent
and a child view that implements NestedScrollingChild
. In Figure 3 below, the NestedScrollView
(NSV
) is the parent and the RecyclerView
(RV
) is the child:
Let’s say the user attempts to scroll the RV
above. Without nested scrolling, the RV
will immediately consume the scroll event, resulting in the undesirable behavior we saw in Figure 2. What we really want is to create the illusion that the two views are scrolling together as a single unit. More specifically:1
- If the
RV
is at the top of its content, scrolling theRV
up should cause theNSV
to scroll up. - If the
NSV
is not at the bottom of its content, scrolling theRV
down should cause theNSV
to scroll down.
As you might expect, the nested scrolling APIs provide a way for the NSV
and RV
to communicate with each other throughout the duration of the scroll, so that each view can confidently determine who should consume each scroll event. This becomes clear when you consider the sequence of events that takes place when the user drags their finger on top of the RV
:
- The
RV
’sonTouchEvent(ACTION_MOVE)
method is called. - The
RV
calls its owndispatchNestedPreScroll()
method, which notifies theNSV
that it is about to consume a portion of the scroll. - The
NSV
’sonNestedPreScroll()
method is called, giving theNSV
an opportunity to react to the scroll event before theRV
consumes it. - The
RV
consumes the remainder of the scroll (or does nothing if theNSV
consumed the entire event). - The
RV
calls its owndispatchNestedScroll()
method, which notifies theNSV
that it has consumed a portion of the scroll. - The
NSV
’sonNestedScroll()
method is called, giving theNSV
an opportunity to consume any remaining scroll pixels that have still not been consumed. - The
RV
returnstrue
from the current call toonTouchEvent(ACTION_MOVE)
, consuming the touch event.2
Unfortunately, simply using a NSV
and RV
was not enough to get the scrolling behavior I wanted. Figure 4 shows the two problematic bugs that I needed to fix. The cause of both problems is that the RV
is consuming scroll and fling events when it shouldn’t be. On the left, the RV
should not begin scrolling until the card has reached the top of the screen. On the right, flinging the RV
downwards should collapse the card in a single smooth motion.
Fixing these two problems is relatively straightforward now that we understand how the nested scrolling APIs work. All we need to do is create a CustomNestedScrollView
class and customize its scrolling behavior by overriding the onNestedPreScroll()
and onNestedPreFling()
methods:
/**
* A NestedScrollView with our custom nested scrolling behavior.
*/
public class CustomNestedScrollView extends NestedScrollView {
// The NestedScrollView should steal the scroll/fling events away from
// the RecyclerView if: (1) the user is dragging their finger down and
// the RecyclerView is scrolled to the top of its content, or (2) the
// user is dragging their finger up and the NestedScrollView is not
// scrolled to the bottom of its content.
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
final RecyclerView rv = (RecyclerView) target;
if ((dy < 0 && isRvScrolledToTop(rv)) || (dy > 0 && !isNsvScrolledToBottom(this))) {
// Scroll the NestedScrollView's content and record the number of pixels consumed
// (so that the RecyclerView will know not to perform the scroll as well).
scrollBy(0, dy);
consumed[1] = dy;
return;
}
super.onNestedPreScroll(target, dx, dy, consumed);
}
@Override
public boolean onNestedPreFling(View target, float velX, float velY) {
final RecyclerView rv = (RecyclerView) target;
if ((velY < 0 && isRvScrolledToTop(rv)) || (velY > 0 && !isNsvScrolledToBottom(this))) {
// Fling the NestedScrollView's content and return true (so that the RecyclerView
// will know not to perform the fling as well).
fling((int) velY);
return true;
}
return super.onNestedPreFling(target, velX, velY);
}
/**
* Returns true iff the NestedScrollView is scrolled to the bottom of its
* content (i.e. if the card's inner RecyclerView is completely visible).
*/
private static boolean isNsvScrolledToBottom(NestedScrollView nsv) {
return !nsv.canScrollVertically(1);
}
/**
* Returns true iff the RecyclerView is scrolled to the top of its
* content (i.e. if the RecyclerView's first item is completely visible).
*/
private static boolean isRvScrolledToTop(RecyclerView rv) {
final LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition() == 0
&& lm.findViewByPosition(0).getTop() == 0;
}
}
We’re nearly there! For the perfectionists out there, however, Figure 5 shows one more bug that would be nice to fix. In the video on the left, the fling stops abruptly once the child reaches the top of its content. What we want is for the fling to finish in a single, fluid motion, as shown in the video on the right.
The crux of the problem is that up until recently, the support library did not provide a way for nested scroll children to transfer leftover nested fling velocity up to a nested scroll parent. I won’t go into too much detail here because Chris Banes has already written a detailed blog post explaining the issue and how it should be fixed.3 But to summarize, all we need to do is update our parent and child views to implement the new-and-improved NestedScrollingParent2
and NestedScrollingChild2
interfaces, which were specifically added to address this problem in v26 of the support library.
Unfortunately NestedScrollView
still implements the older NestedScrollingParent
interface, so I had to create my own NestedScrollView2
class that implements the NestedScrollingParent2
interface to get things working.4 The final, bug-free NestedScrollView
implementation is given below:
/**
* A NestedScrollView that implements the new-and-improved NestedScrollingParent2
* interface and that defines its own customized nested scrolling behavior. View
* source code for the NestedScrollView2 class here: j.mp/NestedScrollView2
*/
public class CustomNestedScrollView2 extends NestedScrollView2 {
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) {
final RecyclerView rv = (RecyclerView) target;
if ((dy < 0 && isRvScrolledToTop(rv)) || (dy > 0 && !isNsvScrolledToBottom(this))) {
scrollBy(0, dy);
consumed[1] = dy;
return;
}
super.onNestedPreScroll(target, dx, dy, consumed, type);
}
// Note that we no longer need to override onNestedPreFling() here; the
// new-and-improved nested scrolling APIs give us the nested flinging
// behavior we want already by default!
private static boolean isNsvScrolledToBottom(NestedScrollView nsv) {
return !nsv.canScrollVertically(1);
}
private static boolean isRvScrolledToTop(RecyclerView rv) {
final LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition() == 0
&& lm.findViewByPosition(0).getTop() == 0;
}
}
That’s all I’ve got for now! Thanks for reading, and don’t forget to leave a comment below if you have any questions!
1 This blog post uses the same terminology that the framework uses to describe scroll directions. That is, dragging your finger toward the bottom of the screen causes the view to scroll up, and dragging your finger towards the top of the screen causes the view to scroll down. ↩
2 It’s worth noting that nested flings are handled in a very similar fashion. The child detects a fling in its onTouchEvent(ACTION_UP)
method and notifies the parent by calling its own dispatchNestedPreFling()
and dispatchNestedFling()
methods. This triggers calls to the parent’s onNestedPreFling()
and onNestedFling()
methods and gives the parent an opportunity to react to the fling before and after the child consumes it. ↩
3 I recommend watching the second half of Chris Banes’ Droidcon 2016 talk for more information on this topic as well. ↩
4 If enough people star this bug, maybe this won’t be necessary in the future! :) ↩