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.

Google Developer Expert

I can proudly say that since January, I have become a Google Developer Expert for Android (GDE in short). The first -and at the moment only- one in Ireland.

Google Experts are a global network of experienced product strategists, designers, developers and marketing professionals actively supporting developers, startups and companies changing the world through web and mobile applications.

As part of the activities of the GDE program I spent some time at the Office Hours during Google I/O helping people and I also gave a 20 minutes talk in the sandbox about Playful Design (slides coming shortly).