Experimenting with Nested Scrolling

Posted

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).

Figure 1 - A side-by-side comparison of the Google Expeditions' Android app vs. the sample app I wrote for this blog post.

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.

Figure 2 - A side-by-side comparison of Chris Banes' Cheese Square app with nested scrolling disabled vs. enabled.

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:

Sample app layouts

Figure 3 - The sample app consists of a parent NestedScrollView and a child RecyclerView.

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 the RV up should cause the NSV to scroll up.
  • If the NSV is not at the bottom of its content, scrolling the RV down should cause the NSV 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:

  1. The RV’s onTouchEvent(ACTION_MOVE) method is called.
  2. The RV calls its own dispatchNestedPreScroll() method, which notifies the NSV that it is about to consume a portion of the scroll.
  3. The NSV’s onNestedPreScroll() method is called, giving the NSV an opportunity to react to the scroll event before the RV consumes it.
  4. The RV consumes the remainder of the scroll (or does nothing if the NSV consumed the entire event).
  5. The RV calls its own dispatchNestedScroll() method, which notifies the NSV that it has consumed a portion of the scroll.
  6. The NSV’s onNestedScroll() method is called, giving the NSV an opportunity to consume any remaining scroll pixels that have still not been consumed.
  7. The RV returns true from the current call to onTouchEvent(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.

Figure 4 - 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.

Figure 5 - On the left, the fling stops abruptly once the child reaches the top of its content. On the right, the fling completes in a single, fluid motion.

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! :)

+1 this blog!

Android Design Patterns is a website for developers who wish to better understand the Android application framework. The tutorials here emphasize proper code design and project maintainability.

Find a typo?

Submit a pull request! The code powering this site is open-source and available on GitHub. Corrections are appreciated and encouraged! Click here for instructions.

Apps by me

Shape Shifter simplifies the creation of AnimatedVectorDrawable path morphing animations. View on GitHub.
2048++ is hands down the cleanest, sleekest, most responsive 2048 app for Android!