Snapping items on a horizontal list

The Problem

We want to have a horizontal scrolling view that holds several items and that snaps to the selected one once the scroll stops, placing it centered on the screen.

Essentially something that works like this:

ezgif.com-crop

In old versions of Android we had the Gallery class that allowed us to do something similar, but it has been deprecated. It is also similar to what you can do with a ViewPager, but those are normally designed for full width Fragments.

The Solution (TL;DR)

What we will do is to listen for the event of scrolling stop on the list. Then we will check which item is currently in the middle of the screen and make the list scroll until it is positioned on the center.

The code for this example is available on GitHub as the project SnappingList

The detailed solution

You probably want something more detailed, so let’s get into it.

Some considerations

We are going to use a RecyclerView because the OnScrollListener allows us to know the pixels that have been scrolled, and that is not possible with a standard ListView. Its OnScrollListener does not provide enough information. Besides, a RecyclerView is more fancy.

We will know the with of the items on the list beforehand. This is very important to be able to calculate the center of the screen and which item is the selected one.

We are going to have two extra items in the list, one at the beginning and one at the end, to be able to position the first and last items centered on the screen. The width of these extra items need to be large enough for it.

The list items

As we mentioned, we will have two different items in the list. The normal ones and the ones we will add at the beginning and the end.

The layout for the extra items is defined in the layout file list_item_padding.xml and looks like this:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout  xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="@dimen/padding_item_width"
    android:layout_height="match_parent">
</FrameLayout>

On the other hand, the normal items we are going to use are just a square shape in the background and a number in the center. They are defined in the layout list_item.xml which looks like this:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout  xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="@dimen/item_width"
    android:background="@drawable/bgr"
    android:layout_height="match_parent">

    <TextView
        style="@android:style/TextAppearance.DeviceDefault.Large"
        android:id="@+id/item_text"
        android:layout_gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</FrameLayout>

These layouts have the width defined as a dimension. This allows us to tweak it for different screen sizes and also to be able to read the width in code without the need of waiting for the views to be measured. The values we are using for the example are:

<resources>
    <dimen name="item_width">200dp</dimen>
    <dimen name="padding_item_width">300dp</dimen>
</resources>

The Adapter

Then, we are going to create an adapter that just adds the two extra items. We call it ExtraItemsAdapter and the code is like this:

public class ExtraItemsAdapter
        extends RecyclerView.Adapter<ViewHolder> {

  private static final int VIEW_TYPE_PADDING = 1;
  private static final int VIEW_TYPE_ITEM = 2;

  private final int mNumItems;

  public ExtraItemsAdapter(int numItems) {
    mNumItems = numItems;
  }

  @Override
  public int getItemCount() {
    return mNumItems+2; // We have to add 2 paddings
  }

  @Override
  public int getItemViewType(int position) {
    if (position == 0 || position == getItemCount()-1) {
      return VIEW_TYPE_PADDING;
    }
    return VIEW_TYPE_ITEM;
  }

  @Override
  public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    // create a new view
    if (viewType == VIEW_TYPE_ITEM) {
      View v = LayoutInflater.from(parent.getContext())
        .inflate(R.layout.list_item, parent, false);
      return new ViewHolder(v);
    }
    else {
      View v = LayoutInflater.from(parent.getContext())
        .inflate(R.layout.list_item_padding, parent, false);
      return new ViewHolder(v);
    }
  }

  @Override
  public void onBindViewHolder(ViewHolder holder, int pos) {
    if (getItemViewType(pos) == VIEW_TYPE_ITEM) {
      // We bind the item to the view
      holder.text.setText(String.valueOf(pos));
    }
  }
}

The adapter receives a number that says how many elements to display as a parameter of the constructor.When asked for the item count it will return that number+2.

When asked to create a view, it uses the item view type to check if it is either a padding or a normal item.

To use this on a real world example, you should pass a list of items instead of just an integer and do proper binding inside onBindViewHolder.

Controlling the RecyclerView

Now that the basics are set, we can move into the really interesting part: handling the RecyclerView.

As we mentioned before, the idea is that when the scroll is stopped, we calculate which item is the current position, then, make the list scroll to that position.

To be able to calculate all that, we need some initialization, mainly to know the width of the items (both normal and extra ones) and also the padding needed, which is calculated based on the screen width and the item width.

We also keep track of the current amount of scroll -in pixels- of the RecyclerView in the allPixels variable.

All this can be done inside the onCreate method of the Activity.

Display display = getWindowManager().getDefaultDisplay();
Point size = new Point();
display.getSize(size);
firstItemWidth = getResources().getDimension(R.dimen.padding_item_width);
itemWidth = getResources().getDimension(R.dimen.item_width);
padding = (size.x - itemWidth) / 2;

allPixels = 0;

We also need to initialize the RecyclerView by creating and setting a LayoutManager.

final RecyclerView items = (RecyclerView) findViewById(R.id.item_list);
LinearLayoutManager itemslayoutManager = new LinearLayoutManager(getApplicationContext());
itemslayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
items.setLayoutManager(itemslayoutManager);

Finally, we set the OnScrollListener to the RecyclerView:

items.setOnScrollListener(new RecyclerView.OnScrollListener() {

  @Override
  public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
    super.onScrollStateChanged(recyclerView, newState);
    synchronized (this) {
      if (newState == RecyclerView.SCROLL_STATE_IDLE) {
       calculatePositionAndScroll(recyclerView);
      }
    }
  }

  @Override
  public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    super.onScrolled(recyclerView, dx, dy);
    allPixels += dx;
  }
});

This listener does two things:

  • When the scroll stops (the scroll state changes to SCROLL_STATE_IDLE), it calls calculatePositionAndScroll to make the list snap.
  • When the list scrolls, it updates the value of allPixels.

Then we have the utility method calculatePositionAndScroll which is where the action is:

private void calculatePositionAndScroll(RecyclerView recyclerView) {
  int expectedPosition = Math.round((allPixels + padding - firstItemWidth) / itemWidth);
  // Special cases for the padding items
  if (expectedPosition == -1) {
    expectedPosition = 0;
  }
  else if (expectedPosition >= recyclerView.getAdapter().getItemCount() - 2) {
    expectedPosition--;
  }
  scrollListToPosition(recyclerView, expectedPosition);
}

private void scrollListToPosition(RecyclerView recyclerView, int expectedPosition) {
  float targetScrollPos = expectedPosition * itemWidth + firstItemWidth - padding;
  float missingPx = targetScrollPos - allPixels;
  if (missingPx != 0) {
    recyclerView.smoothScrollBy((int) missingPx, 0);
  }
}

To calculate the position we use the values of allPixels, padding,  firstItemWidth and itemWith. Note that we are rounding the result.

We check for the special cases of the first and last items -the extra ones- and then tell the list to scroll to that position.

Scrolling to the position calculates the point in which the list needs to be positioned for a specific item, subtracts the value of allPixels from it and tells the list to do a smooth scroll.

Finally, we initialize the adapter with the number of items we want to be displayed.

ExtraItemsAdapter adapter = new ExtraItemsAdapter(NUM_ITEMS);
items.setAdapter(adapter);

While the code works, there are a couple of situations we want to fix to make it nicer.

Finishing touches

The first problem is that rotation does not work properly. RecyclerView does remember the state, but the value of allPixels gets reset when the Activity is destroyed. This is very easy to fix, we just need to save and restore it using the methods from the Activity.

@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
  super.onRestoreInstanceState(savedInstanceState);
  allPixels = savedInstanceState.getFloat(BUNDLE_LIST_PIXELS);
}

@Override
protected void onSaveInstanceState(Bundle outState) {
  super.onSaveInstanceState(outState);
  outState.putFloat(BUNDLE_LIST_PIXELS, allPixels);
}

The other finishing touch is to make the list start in a selected position, since now it starts at the beginning of the list, where an extra item is.

If we call calculatePositionAndScroll directly it will create a wrong state where the value of allPixels is incorrect because the view has not been completely measured yet. To fix that we have to call that method once the layout has been completed and we do that via the OnGlobalLayoutListener class.

ViewTreeObserver vto = items.getViewTreeObserver();
vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
  @Override
  public void onGlobalLayout() {
    items.getViewTreeObserver().removeOnGlobalLayoutListener(this);
    calculatePositionAndScroll(items);
  }
});

I recommend that you place this code inside onResume (as oposed to onCreate) to ensure that it is called after onRestoreInstanceState. Although the layout is most likely never completed before onResume is called, the logic of positioning the list to the right position makes more sense inside onResume.

Final considerations

There are some considerations for this to be used on real world applications. Nothing major, but still worth commenting:

  • We chose the size of the items for an optimal view on a Nexus 5 in landscape. You should consider qualifying the dimensions based on the screen smallestWidth.
  • Removing the ViewTreeObserver has two different methods, one that is valid for minSDK < 16 and another one for SDK 16 on. This example only considers minSDK 16.

You can see all the code in the SnappingList project on GitHub.

13 thoughts on “Snapping items on a horizontal list

  1. This example troubles me a little. The deep coupling between the Activity and the view could be avoided with some small changes.

    With a simple extension of RecyclerView, actions such as determining the available pixels and triggering the initial layout offset could be handled as part of the normal layout/measure passes of the view. This would eliminate the need for a ViewTreeObserver (always a bit scary) and measurements would be scoped to the actual view size (instead of the display size).

    Extending the view would allow you to better scope state persistence as well, as you can include it in the view’s persisted state instead of the Activity.

    I haven’t tried this, but an ItemDecoration might also provide you with more flexible offsets that can be managed by the RecyclerView, without disrupting adapter positions. You can apply large padding to just the first/last items and let the LayoutManager take care of the rest.

    • Thanks for the comment. Indeed the coupling is not good from a reusability point of view. In this case I aimed for less classes and less code to just illustrate the point.

      I also had in mind to make it in a way that is actually reusable and make is some sort of library, but I had this example lying around this for a while and I realized that if I wait until I do those changes It could take forever and I wanted to talk about it.

      All in all, very valid points. If I have time work on this project again in the future I’ll apply your suggestions.

  2. Thanks for the article and the code – a nice time saver for those of us trying to do the same thing.

    One way to avoid the brittle ViewTreeObserver is to call the post() method on the view – it will be executed after the view is fully initialized and measured.
    i.e.
    items.post(new Runnable() {
    @Override
    public void run() {
    calculatePositionAndScroll(items);
    }
    });

    • Thanks! Glad it was useful.
      In my previous experience, your solution does not always work. The post method makes it run on the UI thread, but there is no guarantee of it being executed after the view is measured.

  3. After implementing your solution, I can’t get the first item and the last item to be clickable. Not the paddings but the real items. Every other item in the middle is clickable, but not the first and the last.

    Any fix for this?

    • it is similar, but I did want to show the previous and next item in my case, not exactly like a ViewPager. Nevertheless, interesting, thanks for sharing.

  4. I am trying to implement this in vertical recycler view. But i could not just get my head into it. can u please give me some ways to implement it in vertical recycler view.

    Regards,
    Suresh

  5. Great post, my only question is whether we can create a recycler view that populates data from right to left as opposed to left to right?

  6. how can i get the position of center item? i want to zoom the center item or keep the center item’s layout different from all the other items.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

This site uses Akismet to reduce spam. Learn how your comment data is processed.