Android Tip: RecyclerView snapping with SnapHelper

    As you can see at Google Play app, the first visible item on the left of horizontal list views is always displayed completely when user scroll screen:

Project configuration

    By building the list view by a RecyclerView, this trick called snapping list items to the center/left/right/top/bottom after scroll. From version 24.2.0, the design support library introduced two new classes (SnapHelper and LinearSnapHelper) that should be used to handle snapping in a RecyclerView.
    Through this post, I will present the way to use 2 classes and building a snapping RecyclerView.
    Before start coding, make sure that you use buildToolsVersion "24.0.2" and support library version 24.2.0 (or later) for your project. Adding some necessary dependencies,  your application-level build.gradle can be like this:
build.gradle
apply plugin: 'com.android.application'

android {
    compileSdkVersion 24
    buildToolsVersion "24.0.2"

    defaultConfig {
        applicationId "info.devexchanges.snaprecyclerview"
        minSdkVersion 14
        targetSdkVersion 24
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:recyclerview-v7:24.2.0'
    compile 'com.android.support:cardview-v7:24.2.0'
    compile 'com.android.support:appcompat-v7:24.2.0'
}

Creating center snapping RecyclerView

    Suppose we have this layout for main activity (only included a RecyclerView):
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="info.devexchanges.snaprecyclerview.MainActivity">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</RelativeLayout>
Now, in Java code, if you use the default LinearSnapHelper, you can only snap to the center. The only code needed is:
SnapHelper snapHelper = new LinearSnapHelper();
snapHelper.attachToRecyclerView(recyclerView);
And this is full code for our main activity:
MainActivity.java
package info.devexchanges.snaprecyclerview;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.SnapHelper;
import android.view.Gravity;

import java.util.ArrayList;

public class MainActivity extends AppCompatActivity {

    private ArrayList<Item> items;
    private RecyclerView recyclerView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        recyclerView = (RecyclerView) findViewById(R.id.recycler_view);

        createApps();

        /**
         * Center snapping
         */
        SnapHelper snapHelper = new LinearSnapHelper();
        snapHelper.attachToRecyclerView(recyclerView);

        SnapRecyclerAdapter adapter = new SnapRecyclerAdapter(this, items);
        recyclerView.setAdapter(adapter);
    }

    private void createApps() {
        items = new ArrayList<>();
        items.add(new Item("Google+", R.drawable.google_plus));
        items.add(new Item("Facebook", R.drawable.facebook));
        items.add(new Item("LinkedIn", R.drawable.linkedin));
        items.add(new Item("Youtube", R.drawable.youtube));
        items.add(new Item("Instagram", R.drawable.instagram));
        items.add(new Item("Skype", R.drawable.skype));
        items.add(new Item("Twitter", R.drawable.twitter));
        items.add(new Item("Wikipedia", R.drawable.wikipedia));
        items.add(new Item("Whats app", R.drawable.what_apps));
        items.add(new Item("Pokemon Go", R.drawable.pokemon_go));
    }
}
    Running this activity, we'll have this result:

More snapping options

    To replicate the Google Play behavior, we need to override the calculateDistanceToFinalSnap() and findSnapView() methods of the LinearSnapHelper to find the start view and calculate the distance needed to snap it to the correct position. To make this easier to do, I created a GravitySnapHelper class that supports snapping in 4 directions (start, top, end, bottom):
GravitySnapHelper.java
/*
 * Copyright (C) 2016 The Android Open Source Project
 * Copyright (C) 2016 Rúben Sousa
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific languag`e governing permissions and
 * limitations under the License.
 */

package info.devexchanges.snaprecyclerview;

import android.annotation.SuppressLint;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.LinearSnapHelper;
import android.support.v7.widget.OrientationHelper;
import android.support.v7.widget.RecyclerView;
import android.view.Gravity;
import android.view.View;


public class GravitySnapHelper extends LinearSnapHelper {

    private OrientationHelper verticalHelper;
    private OrientationHelper horizontalHelper;
    private int gravity;
    private boolean isSupportRtL;

    @SuppressLint("RtlHardcoded")
    public GravitySnapHelper(int gravity) {
        this.gravity = gravity;
        if (this.gravity == Gravity.LEFT) {
            this.gravity = Gravity.START;
        } else if (this.gravity == Gravity.RIGHT) {
            this.gravity = Gravity.END;
        }
    }

    @Override
    public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
            throws IllegalStateException {
        if (recyclerView != null) {
            isSupportRtL = recyclerView.getContext().getResources().getBoolean(R.bool.is_rtl);
        }
        super.attachToRecyclerView(recyclerView);
    }

    @Override
    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager,
                                              @NonNull View targetView) {
        int[] out = new int[2];

        if (layoutManager.canScrollHorizontally()) {
            if (gravity == Gravity.START) {
                out[0] = distanceToStart(targetView, getHorizontalHelper(layoutManager));
            } else { // END
                out[0] = distanceToEnd(targetView, getHorizontalHelper(layoutManager));
            }
        } else {
            out[0] = 0;
        }

        if (layoutManager.canScrollVertically()) {
            if (gravity == Gravity.TOP) {
                out[1] = distanceToStart(targetView, getVerticalHelper(layoutManager));
            } else { // BOTTOM
                out[1] = distanceToEnd(targetView, getVerticalHelper(layoutManager));
            }
        } else {
            out[1] = 0;
        }
        return out;
    }

    @Override
    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager instanceof LinearLayoutManager) {
            switch (gravity) {
                case Gravity.START:
                    return findStartView(layoutManager, getHorizontalHelper(layoutManager));
                case Gravity.TOP:
                    return findStartView(layoutManager, getVerticalHelper(layoutManager));
                case Gravity.END:
                    return findEndView(layoutManager, getHorizontalHelper(layoutManager));
                case Gravity.BOTTOM:
                    return findEndView(layoutManager, getVerticalHelper(layoutManager));
            }
        }

        return super.findSnapView(layoutManager);
    }

    private int distanceToStart(View targetView, OrientationHelper helper) {
        if (isSupportRtL) {
            return distanceToEnd(targetView, helper);
        }
        return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();
    }

    private int distanceToEnd(View targetView, OrientationHelper helper) {
        if (isSupportRtL) {
            return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();
        }
        return helper.getDecoratedEnd(targetView) - helper.getEndAfterPadding();
    }

    private View findStartView(RecyclerView.LayoutManager layoutManager,
                               OrientationHelper helper) {

        if (layoutManager instanceof LinearLayoutManager) {
            int firstChild = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();

            if (firstChild == RecyclerView.NO_POSITION) {
                return null;
            }

            View child = layoutManager.findViewByPosition(firstChild);

            if (helper.getDecoratedEnd(child) >= helper.getDecoratedMeasurement(child) / 2
                    && helper.getDecoratedEnd(child) > 0) {
                return child;
            } else {
                if (((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition()
                        == layoutManager.getItemCount() - 1) {
                    return null;
                } else {
                    return layoutManager.findViewByPosition(firstChild + 1);
                }
            }
        }

        return super.findSnapView(layoutManager);
    }

    private View findEndView(RecyclerView.LayoutManager layoutManager,
                             OrientationHelper helper) {

        if (layoutManager instanceof LinearLayoutManager) {
            int lastChild = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();

            if (lastChild == RecyclerView.NO_POSITION) {
                return null;
            }

            View child = layoutManager.findViewByPosition(lastChild);

            if (helper.getDecoratedStart(child) + helper.getDecoratedMeasurement(child) / 2
                    <= helper.getTotalSpace()) {
                return child;
            } else {
                if (((LinearLayoutManager) layoutManager).findFirstCompletelyVisibleItemPosition()
                        == 0) {
                    return null;
                } else {
                    return layoutManager.findViewByPosition(lastChild - 1);
                }
            }
        }

        return super.findSnapView(layoutManager);
    }

    private OrientationHelper getVerticalHelper(RecyclerView.LayoutManager layoutManager) {
        if (verticalHelper == null) {
            verticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
        }
        return verticalHelper;
    }

    private OrientationHelper getHorizontalHelper(RecyclerView.LayoutManager layoutManager) {
        if (horizontalHelper == null) {
            horizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
        }
        return horizontalHelper;
    }
}
    This GravitySnapHelper.java file was written by rubensousa on Github.
     If you want the same behavior as the Google Play app, now you only need to change the previous code in onCreate() of MainActivityto:
SnapHelper snapHelper = new GravitySnapHelper(Gravity.START);
snapHelper.attachToRecyclerView(recyclerView);
    And we'll have this output:
    To snapping on the right side, change the code above to:
SnapHelper snapHelper = new GravitySnapHelper(Gravity.END);
snapHelper.attachToRecyclerView(recyclerView);
    And this is the result:
    Moreover, combine with changing the LinearLayoutManager orientation, we'll have top/bottom snapping later:
        /**
         * Top snapping
         */
        SnapHelper snapHelper = new GravitySnapHelper(Gravity.TOP);
        snapHelper.attachToRecyclerView(recyclerView);

        /**
         * Bottom snapping
         */
        //SnapHelper snapHelper = new GravitySnapHelper(Gravity.TOP);
        //snapHelper.attachToRecyclerView(recyclerView);

        // HORIZONTAL for Gravity START/END and VERTICAL for TOP/BOTTOM
        recyclerView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
        recyclerView.setHasFixedSize(true);
    Top snapping output:
    Bottom snapping output:

Conclusions

    Up to now, I have presented the snapping RecyclerView in 4 directions (start, top, end, bottom). Design support library is always updated by Google and now we are equipped with one more interesting feature to designing a good looking UI and well-behaving in UX. Hope this post can provide to you more information about RecyclerView and material design. Finally, you can get full project code on Github.

Share


Previous post
« Prev Post
Next post
Next Post »