How to snap RecyclerView items so that every X items would be considered like a single unit to snap to?
Asked Answered
M

3

32

Background

It's possible to snap a RecyclerView to its center using :

LinearSnapHelper().attachToRecyclerView(recyclerView)

Example:

MainActivity.kt

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val inflater = LayoutInflater.from(this)

        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
                val textView = holder.itemView as TextView
                textView.setBackgroundColor(if (position % 2 == 0) 0xffff0000.toInt() else 0xff00ff00.toInt())
                textView.text = position.toString()
            }

            override fun getItemCount(): Int {
                return 100
            }

            override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
                val view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false) as TextView
                val cellSize = recyclerView.width / 3
                view.layoutParams.height = cellSize
                view.layoutParams.width = cellSize
                view.gravity = Gravity.CENTER
                return object : RecyclerView.ViewHolder(view) {}
            }
        }
        LinearSnapHelper().attachToRecyclerView(recyclerView)
    }
}

activity_main.xml

<android.support.v7.widget.RecyclerView
    android:id="@+id/recyclerView" xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"
    app:layoutManager="android.support.v7.widget.LinearLayoutManager"/>

It's also possible to snap it to other sides, as was done in some libraries, such as here.

There are also libraries that allow to have a RecyclerView that can work like a ViewPager, such as here.

The problem

Supposed I have a RecyclerView (horizontal in my case) with many items, and I want that it will treat every X items (X is constant) as a single unit, and snap to each of those units.

For example, if I scroll a bit, it could snap to either the 0-item, or the X-item, but not to something in between them.

In a way, it's similar in its behavior to a case of a normal ViewPager, just that each page would have X items in it.

For example, if we continue from the sample code I wrote above,suppose X==3 , the snapping would be from this idle state:

enter image description here

to this idle state (in case we scrolled enough, otherwise would stay in previous state) :

enter image description here

Flinging or scrolling more should be handled like on ViewPager, just like the library I've mentioned above.

Scrolling more (in the same direction) to the next snapping point would be to reach item "6" , "9", and so on...

What I tried

I tried to search for alternative libraries, and I also tried to read the docs regarding this, but I didn't find anything that might be useful.

It might also be possible by using a ViewPager, but I think that's not the best way, because ViewPager doesn't recycle its items well, and I think it's less flexible than RecyclerView in terms of how to snap.

The questions

  1. Is it possible to set RecyclerView to snap every X items, to treat each X items as a single page to snap to?

    Of course, the items will take enough space for the whole RecyclerView, evenly.

  2. Supposed it is possible, how would I get a callback when the RecyclerView is about to snap to a certain item, including having this item, before it got snapped? I ask this because it's related to the same question I asked here.


Kotlin solution

A working Kotlin solution based on "Cheticamp" answer (here), without the need to verify that you have the RecyclerView size, and with the choice of having a grid instead of a list, in the sample:

MainActivity.kt

class MainActivity : AppCompatActivity() {
    val USE_GRID = false
    //        val USE_GRID = true
    val ITEMS_PER_PAGE = 4
    var selectedItemPos = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val inflater = LayoutInflater.from(this)

        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
                val textView = holder.itemView as TextView
                textView.setBackgroundColor(if (position % 2 == 0) 0xffff0000.toInt() else 0xff00ff00.toInt())
                textView.text = if (selectedItemPos == position) "selected: $position" else position.toString()
            }

            override fun getItemCount(): Int {
                return 100
            }

            override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
                val view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false) as TextView
                view.layoutParams.width = if (USE_GRID)
                    recyclerView.width / (ITEMS_PER_PAGE / 2)
                else
                    recyclerView.width / 4
                view.layoutParams.height = recyclerView.height / (ITEMS_PER_PAGE / 2)
                view.gravity = Gravity.CENTER
                return object : RecyclerView.ViewHolder(view) {
                }
            }
        }
        recyclerView.layoutManager = if (USE_GRID)
            GridLayoutManager(this, ITEMS_PER_PAGE / 2, GridLayoutManager.HORIZONTAL, false)
        else
            LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
        val snapToBlock = SnapToBlock(recyclerView, ITEMS_PER_PAGE)
        snapToBlock.attachToRecyclerView(recyclerView)
        snapToBlock.setSnapBlockCallback(object : SnapToBlock.SnapBlockCallback {
            override fun onBlockSnap(snapPosition: Int) {
                if (selectedItemPos == snapPosition)
                    return
                selectedItemPos = snapPosition
                recyclerView.adapter.notifyDataSetChanged()
            }

            override fun onBlockSnapped(snapPosition: Int) {
                if (selectedItemPos == snapPosition)
                    return
                selectedItemPos = snapPosition
                recyclerView.adapter.notifyDataSetChanged()
            }

        })
    }

}

SnapToBlock.kt

/**@param maxFlingBlocks Maxim blocks to move during most vigorous fling*/
class SnapToBlock constructor(private val maxFlingBlocks: Int) : SnapHelper() {
    private var recyclerView: RecyclerView? = null
    // Total number of items in a block of view in the RecyclerView
    private var blocksize: Int = 0
    // Maximum number of positions to move on a fling.
    private var maxPositionsToMove: Int = 0
    // Width of a RecyclerView item if orientation is horizonal; height of the item if vertical
    private var itemDimension: Int = 0
    // Callback interface when blocks are snapped.
    private var snapBlockCallback: SnapBlockCallback? = null
    // When snapping, used to determine direction of snap.
    private var priorFirstPosition = RecyclerView.NO_POSITION
    // Our private scroller
    private var scroller: Scroller? = null
    // Horizontal/vertical layout helper
    private var orientationHelper: OrientationHelper? = null
    // LTR/RTL helper
    private var layoutDirectionHelper: LayoutDirectionHelper? = null

    @Throws(IllegalStateException::class)
    override fun attachToRecyclerView(recyclerView: RecyclerView?) {
        if (recyclerView != null) {
            this.recyclerView = recyclerView
            val layoutManager = recyclerView.layoutManager as LinearLayoutManager
            orientationHelper = when {
                layoutManager.canScrollHorizontally() -> OrientationHelper.createHorizontalHelper(layoutManager)
                layoutManager.canScrollVertically() -> OrientationHelper.createVerticalHelper(layoutManager)
                else -> throw IllegalStateException("RecyclerView must be scrollable")
            }
            scroller = Scroller(this.recyclerView!!.context, sInterpolator)
            initItemDimensionIfNeeded(layoutManager)
        }
        super.attachToRecyclerView(recyclerView)
    }

    // Called when the target view is available and we need to know how much more
    // to scroll to get it lined up with the side of the RecyclerView.
    override fun calculateDistanceToFinalSnap(layoutManager: RecyclerView.LayoutManager, targetView: View): IntArray {
        val out = IntArray(2)
        initLayoutDirectionHelperIfNeeded(layoutManager)
        if (layoutManager.canScrollHorizontally())
            out[0] = layoutDirectionHelper!!.getScrollToAlignView(targetView)
        if (layoutManager.canScrollVertically())
            out[1] = layoutDirectionHelper!!.getScrollToAlignView(targetView)
        if (snapBlockCallback != null)
            if (out[0] == 0 && out[1] == 0)
                snapBlockCallback!!.onBlockSnapped(layoutManager.getPosition(targetView))
            else
                snapBlockCallback!!.onBlockSnap(layoutManager.getPosition(targetView))
        return out
    }

    private fun initLayoutDirectionHelperIfNeeded(layoutManager: RecyclerView.LayoutManager) {
        if (layoutDirectionHelper == null)
            if (layoutManager.canScrollHorizontally())
                layoutDirectionHelper = LayoutDirectionHelper()
            else if (layoutManager.canScrollVertically())
            // RTL doesn't matter for vertical scrolling for this class.
                layoutDirectionHelper = LayoutDirectionHelper(false)
    }

    // We are flinging and need to know where we are heading.
    override fun findTargetSnapPosition(layoutManager: RecyclerView.LayoutManager, velocityX: Int, velocityY: Int): Int {
        initLayoutDirectionHelperIfNeeded(layoutManager)
        val lm = layoutManager as LinearLayoutManager
        initItemDimensionIfNeeded(layoutManager)
        scroller!!.fling(0, 0, velocityX, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE)
        return when {
            velocityX != 0 -> layoutDirectionHelper!!.getPositionsToMove(lm, scroller!!.finalX, itemDimension)
            else -> if (velocityY != 0)
                layoutDirectionHelper!!.getPositionsToMove(lm, scroller!!.finalY, itemDimension)
            else RecyclerView.NO_POSITION
        }
    }

    // We have scrolled to the neighborhood where we will snap. Determine the snap position.
    override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
        // Snap to a view that is either 1) toward the bottom of the data and therefore on screen,
        // or, 2) toward the top of the data and may be off-screen.
        val snapPos = calcTargetPosition(layoutManager as LinearLayoutManager)
        val snapView = if (snapPos == RecyclerView.NO_POSITION)
            null
        else
            layoutManager.findViewByPosition(snapPos)
        if (snapView == null)
            Log.d(TAG, "<<<<findSnapView is returning null!")
        Log.d(TAG, "<<<<findSnapView snapos=" + snapPos)
        return snapView
    }

    // Does the heavy lifting for findSnapView.
    private fun calcTargetPosition(layoutManager: LinearLayoutManager): Int {
        val snapPos: Int
        initLayoutDirectionHelperIfNeeded(layoutManager)
        val firstVisiblePos = layoutManager.findFirstVisibleItemPosition()
        if (firstVisiblePos == RecyclerView.NO_POSITION)
            return RecyclerView.NO_POSITION
        initItemDimensionIfNeeded(layoutManager)
        if (firstVisiblePos >= priorFirstPosition) {
            // Scrolling toward bottom of data
            val firstCompletePosition = layoutManager.findFirstCompletelyVisibleItemPosition()
            snapPos = if (firstCompletePosition != RecyclerView.NO_POSITION && firstCompletePosition % blocksize == 0)
                firstCompletePosition
            else
                roundDownToBlockSize(firstVisiblePos + blocksize)
        } else {
            // Scrolling toward top of data
            snapPos = roundDownToBlockSize(firstVisiblePos)
            // Check to see if target view exists. If it doesn't, force a smooth scroll.
            // SnapHelper only snaps to existing views and will not scroll to a non-existant one.
            // If limiting fling to single block, then the following is not needed since the
            // views are likely to be in the RecyclerView pool.
            if (layoutManager.findViewByPosition(snapPos) == null) {
                val toScroll = layoutDirectionHelper!!.calculateDistanceToScroll(layoutManager, snapPos)
                recyclerView!!.smoothScrollBy(toScroll[0], toScroll[1], sInterpolator)
            }
        }
        priorFirstPosition = firstVisiblePos
        return snapPos
    }

    private fun initItemDimensionIfNeeded(layoutManager: RecyclerView.LayoutManager) {
        if (itemDimension != 0)
            return
        val child = layoutManager.getChildAt(0) ?: return
        if (layoutManager.canScrollHorizontally()) {
            itemDimension = child.width
            blocksize = getSpanCount(layoutManager) * (recyclerView!!.width / itemDimension)
        } else if (layoutManager.canScrollVertically()) {
            itemDimension = child.height
            blocksize = getSpanCount(layoutManager) * (recyclerView!!.height / itemDimension)
        }
        maxPositionsToMove = blocksize * maxFlingBlocks
    }

    private fun getSpanCount(layoutManager: RecyclerView.LayoutManager): Int = (layoutManager as? GridLayoutManager)?.spanCount ?: 1

    private fun roundDownToBlockSize(trialPosition: Int): Int = trialPosition - trialPosition % blocksize

    private fun roundUpToBlockSize(trialPosition: Int): Int = roundDownToBlockSize(trialPosition + blocksize - 1)

    override fun createScroller(layoutManager: RecyclerView.LayoutManager): LinearSmoothScroller? {
        return if (layoutManager !is RecyclerView.SmoothScroller.ScrollVectorProvider)
            null
        else object : LinearSmoothScroller(recyclerView!!.context) {
            override fun onTargetFound(targetView: View, state: RecyclerView.State?, action: RecyclerView.SmoothScroller.Action) {
                val snapDistances = calculateDistanceToFinalSnap(recyclerView!!.layoutManager, targetView)
                val dx = snapDistances[0]
                val dy = snapDistances[1]
                val time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)))
                if (time > 0)
                    action.update(dx, dy, time, sInterpolator)
            }

            override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float = MILLISECONDS_PER_INCH / displayMetrics.densityDpi
        }
    }

    fun setSnapBlockCallback(callback: SnapBlockCallback?) {
        snapBlockCallback = callback
    }

    /*
        Helper class that handles calculations for LTR and RTL layouts.
     */
    private inner class LayoutDirectionHelper {
        // Is the layout an RTL one?
        private val mIsRTL: Boolean

        constructor() {
            mIsRTL = ViewCompat.getLayoutDirection(recyclerView) == ViewCompat.LAYOUT_DIRECTION_RTL
        }

        constructor(isRTL: Boolean) {
            mIsRTL = isRTL
        }

        /*
            Calculate the amount of scroll needed to align the target view with the layout edge.
         */
        fun getScrollToAlignView(targetView: View): Int = if (mIsRTL)
            orientationHelper!!.getDecoratedEnd(targetView) - recyclerView!!.width
        else
            orientationHelper!!.getDecoratedStart(targetView)

        /**
         * Calculate the distance to final snap position when the view corresponding to the snap
         * position is not currently available.
         *
         * @param layoutManager LinearLayoutManager or descendent class
         * @param targetPos     - Adapter position to snap to
         * @return int[2] {x-distance in pixels, y-distance in pixels}
         */
        fun calculateDistanceToScroll(layoutManager: LinearLayoutManager, targetPos: Int): IntArray {
            val out = IntArray(2)
            val firstVisiblePos = layoutManager.findFirstVisibleItemPosition()
            if (layoutManager.canScrollHorizontally()) {
                if (targetPos <= firstVisiblePos)  // scrolling toward top of data
                    if (mIsRTL) {
                        val lastView = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition())
                        out[0] = orientationHelper!!.getDecoratedEnd(lastView) + (firstVisiblePos - targetPos) * itemDimension
                    } else {
                        val firstView = layoutManager.findViewByPosition(firstVisiblePos)
                        out[0] = orientationHelper!!.getDecoratedStart(firstView) - (firstVisiblePos - targetPos) * itemDimension
                    }
            }
            if (layoutManager.canScrollVertically() && targetPos <= firstVisiblePos) { // scrolling toward top of data
                val firstView = layoutManager.findViewByPosition(firstVisiblePos)
                out[1] = firstView.top - (firstVisiblePos - targetPos) * itemDimension
            }
            return out
        }

        /*
            Calculate the number of positions to move in the RecyclerView given a scroll amount
            and the size of the items to be scrolled. Return integral multiple of mBlockSize not
            equal to zero.
         */
        fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int {
            var positionsToMove: Int
            positionsToMove = roundUpToBlockSize(Math.abs(scroll) / itemSize)
            if (positionsToMove < blocksize)
            // Must move at least one block
                positionsToMove = blocksize
            else if (positionsToMove > maxPositionsToMove)
            // Clamp number of positions to move so we don't get wild flinging.
                positionsToMove = maxPositionsToMove
            if (scroll < 0)
                positionsToMove *= -1
            if (mIsRTL)
                positionsToMove *= -1
            return if (layoutDirectionHelper!!.isDirectionToBottom(scroll < 0)) {
                // Scrolling toward the bottom of data.
                roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove
            } else roundDownToBlockSize(llm.findLastVisibleItemPosition()) + positionsToMove
            // Scrolling toward the top of the data.
        }

        fun isDirectionToBottom(velocityNegative: Boolean): Boolean = if (mIsRTL) velocityNegative else !velocityNegative
    }

    interface SnapBlockCallback {
        fun onBlockSnap(snapPosition: Int)
        fun onBlockSnapped(snapPosition: Int)
    }

    companion object {
        // Borrowed from ViewPager.java
        private val sInterpolator = Interpolator { input ->
            var t = input
            // _o(t) = t * t * ((tension + 1) * t + tension)
            // o(t) = _o(t - 1) + 1
            t -= 1.0f
            t * t * t + 1.0f
        }

        private val MILLISECONDS_PER_INCH = 100f
        private val TAG = "SnapToBlock"
    }
}

Update

Even though I've marked an answer as accepted, as it works fine, I've noticed it has serious issues:

  1. Smooth scrolling doesn't seem to work fine (doesn't scroll to correct place). Only scrolling that work is as such (but with the "smearing" effect) :

    (recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos,0)
    
  2. When switching to RTL (Right to left) locale such as Hebrew ("עברית"), it doesn't let me scroll at all.

  3. I've noticed that onCreateViewHolder is called a lot. In fact it is called every time I scroll, even for times it should have recycled the ViewHolders. This means there is an excessive creation of views, and it might also mean there is a memory leak.

I've tried to fix those myself, but failed so far.

If anyone here knows how to fix it, I will grant the extra, new bounty


Update: as we got a fix for RTL/LTR, I've updated the Kotlin solution within this post.


Update: about point #3 , this seems to be because there is a pool of views for the recyclerView, which gets filled too soon. To handle this, we can simply enlarge the pool size, by using recyclerView.getRecycledViewPool() .setMaxRecycledViews(viewType, Integer.MAX_VALUE) for each view type we have in it. Weird thing that this is really needed. I've posted about it to Google (here and here) but was rejected that the pool should be unlimited by default. In the end, I decided to at least request to have a more convinient function to do it for all view types (here).

Maculate answered 27/11, 2017 at 15:7 Comment(12)
I am not sure what you are seeing with the scrolling. I dusted off my snap-to-block project and I see the same behavior with and without the SnapToBlock code. Is it a matter that the smooth scrolling is too fast?Tenebrific
@Tenebrific I've edited the question. Please read it again. What I saw before is probably because of something else.Maculate
@Tenebrific BTW, if you have another working solution, instead of what the accepted solution has, yet doesn't have these issues, this can also be greatMaculate
I just ran across the documentation for PagerSnapHelper and thought you would be interested.. I don't remember it being documented there when I looked at this last year for the initial question. It came out in rev. 25.1.0, so it's over a year old.Tenebrific
I looked over PagerSnapHelper and I don't think it would have worked. LinearSnapHelper is closer, but it still would have required about the same amount of work.Tenebrific
@androiddeveloper did you solved problem with third bug (excessive creation of viewHolders)? Even adding pure LinearSnapHelper to most basic recyclerView creates enormous number of ViewHolders. That's just wrong and i cannot find any solution for that.Kerbstone
@PawełKanarek Even though I've left this, I can tell that you might be able to overcome this, by making the view pool larger. For each of the view types, you could use recyclerView.getRecycledViewPool() .setMaxRecycledViews(viewType, Integer.MAX_VALUE)Maculate
@androiddeveloper That's brilliant. Works like a charm! Previously i could create 100+ viewholders on sample RecyclerView app from official android developer samples, just by adding snapHelper to recyclerView. But with increased ViewPool size i've got constant 18 viewHolders. Thanks a lot! Edit: do you know maybe why setMaxRecycledViews(viewType, Integer.MAX_VALUE) isn't default option? I mean without this, snapHelper destroys idea of recyclerview, and i don't see any recommendations on setting that value in documentation.Kerbstone
@PawełKanarek I have no idea. It doesn't make sense to me either, as it's impossible for the pool to know how many views it will really hold. I always thought that it shouldn't be limited at all. Maybe they did it to encourage efficiency of views ? In the past, I've reported and requested about this here: issuetracker.google.com/issues/71784689 , issuetracker.google.com/issues/72301192 , issuetracker.google.com/issues/110979418 .Maculate
@androiddeveloper Weird that Google staff thinks that's intended behavior, it's look like a obvious bug. As for you thanks again, you saved me from a day of struggle. Here is nice article about recyclerview for you: android.jlelse.eu/…Kerbstone
@PawełKanarek Yes I thought it's a serious bug because I thought the pool is already unlimited, so creating more views mean something is terribly wrong and could cause a memory leak. I was surprised to see that there is even a function to limit the pool size. The views are already on the screen (and a bit more outside), taking memory, so what difference does it make to have the pool host them... Also the whole point of recyclerView is to recycle views that were created, and this behavior breaks it. I've updated my post. Anyway, please consider starring the request of a better function there.Maculate
Hi I have recycler view with 4 pages each page contains 12 items. I am running with an issue as when I scroll recycler view speedily, It is moving to last page instead of next page. Can you please help me on this? I would like to make recycler view to move next page irrespective of scroll speed.Ethical
T
54

SnapHelper supplies the necessary framework for what you are attempting, but it needs to be extended to handle blocks of views. The class SnapToBlock below extends SnapHelper to snap to blocks of views. In the example, I have used four views to a block but it can be more or less.

Update: The code has been change to accommodate GridLayoutManager as well as LinearLayoutManager. Flinging is now inhibited so the snapping works more list a ViewPager. Horizontal and vertical scrolling is now supported as well as LTR and RTL layouts.

Update: Changed smooth scroll interpolator to be more like ViewPager.

Update: Adding callbacks for pre/post snapping.

Update: Adding support for RTL layouts.

Here is a quick video of the sample app:

enter image description here

Set up the layout manager as follows:

// For LinearLayoutManager horizontal orientation
recyclerView.setLayoutManager(new LinearLayoutManager(this, RecyclerView.HORIZONTAL, false));

// For GridLayoutManager vertical orientation
recyclerView.setLayoutManager(new GridLayoutManager(this, SPAN_COUNT, RecyclerView.VERTICAL, false));

Add the following to attach the SnapToBlock to the RecyclerView.

SnapToBlock snapToBlock = new SnapToBlock(mMaxFlingPages);
snapToBlock.attachToRecyclerView(recyclerView);

mMaxFlingPages is the maximum number of blocks (rowsCols * spans) to allow to be flung at one time.

For call backs when a snap is about to be made and has been completed, add the following:

snapToBlock.setSnapBlockCallback(new SnapToBlock.SnapBlockCallback() {
    @Override
    public void onBlockSnap(int snapPosition) {
        ...
    }

    @Override
    public void onBlockSnapped(int snapPosition) {
        ...
    }
});

SnapToBlock.java

/*  The number of items in the RecyclerView should be a multiple of block size; otherwise, the
    extra item views will not be positioned on a block boundary when the end of the data is reached.
    Pad out with empty item views if needed.

    Updated to accommodate RTL layouts.
 */

public class SnapToBlock extends SnapHelper {
    private RecyclerView mRecyclerView;

    // Total number of items in a block of view in the RecyclerView
    private int mBlocksize;

    // Maximum number of positions to move on a fling.
    private int mMaxPositionsToMove;

    // Width of a RecyclerView item if orientation is horizonal; height of the item if vertical
    private int mItemDimension;

    // Maxim blocks to move during most vigorous fling.
    private final int mMaxFlingBlocks;

    // Callback interface when blocks are snapped.
    private SnapBlockCallback mSnapBlockCallback;

    // When snapping, used to determine direction of snap.
    private int mPriorFirstPosition = RecyclerView.NO_POSITION;

    // Our private scroller
    private Scroller mScroller;

    // Horizontal/vertical layout helper
    private OrientationHelper mOrientationHelper;

    // LTR/RTL helper
    private LayoutDirectionHelper mLayoutDirectionHelper;

    // Borrowed from ViewPager.java
    private static final Interpolator sInterpolator = new Interpolator() {
        public float getInterpolation(float t) {
            // _o(t) = t * t * ((tension + 1) * t + tension)
            // o(t) = _o(t - 1) + 1
            t -= 1.0f;
            return t * t * t + 1.0f;
        }
    };

    SnapToBlock(int maxFlingBlocks) {
        super();
        mMaxFlingBlocks = maxFlingBlocks;
    }

    @Override
    public void attachToRecyclerView(@Nullable final RecyclerView recyclerView)
        throws IllegalStateException {

        if (recyclerView != null) {
            mRecyclerView = recyclerView;
            final LinearLayoutManager layoutManager =
                (LinearLayoutManager) recyclerView.getLayoutManager();
            if (layoutManager.canScrollHorizontally()) {
                mOrientationHelper = OrientationHelper.createHorizontalHelper(layoutManager);
                mLayoutDirectionHelper =
                    new LayoutDirectionHelper(ViewCompat.getLayoutDirection(mRecyclerView));
            } else if (layoutManager.canScrollVertically()) {
                mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager);
                // RTL doesn't matter for vertical scrolling for this class.
                mLayoutDirectionHelper = new LayoutDirectionHelper(RecyclerView.LAYOUT_DIRECTION_LTR);
            } else {
                throw new IllegalStateException("RecyclerView must be scrollable");
            }
            mScroller = new Scroller(mRecyclerView.getContext(), sInterpolator);
            initItemDimensionIfNeeded(layoutManager);
        }
        super.attachToRecyclerView(recyclerView);
    }

    // Called when the target view is available and we need to know how much more
    // to scroll to get it lined up with the side of the RecyclerView.
    @NonNull
    @Override
    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager,
                                              @NonNull View targetView) {
        int[] out = new int[2];

        if (layoutManager.canScrollHorizontally()) {
            out[0] = mLayoutDirectionHelper.getScrollToAlignView(targetView);
        }
        if (layoutManager.canScrollVertically()) {
            out[1] = mLayoutDirectionHelper.getScrollToAlignView(targetView);
        }
        if (mSnapBlockCallback != null) {
            if (out[0] == 0 && out[1] == 0) {
                mSnapBlockCallback.onBlockSnapped(layoutManager.getPosition(targetView));
            } else {
                mSnapBlockCallback.onBlockSnap(layoutManager.getPosition(targetView));
            }
        }
        return out;
    }

    // We are flinging and need to know where we are heading.
    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager,
                                      int velocityX, int velocityY) {
        LinearLayoutManager lm = (LinearLayoutManager) layoutManager;

        initItemDimensionIfNeeded(layoutManager);
        mScroller.fling(0, 0, velocityX, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE,
                        Integer.MIN_VALUE, Integer.MAX_VALUE);

        if (velocityX != 0) {
            return mLayoutDirectionHelper
                .getPositionsToMove(lm, mScroller.getFinalX(), mItemDimension);
        }

        if (velocityY != 0) {
            return mLayoutDirectionHelper
                .getPositionsToMove(lm, mScroller.getFinalY(), mItemDimension);
        }

        return RecyclerView.NO_POSITION;
    }

    // We have scrolled to the neighborhood where we will snap. Determine the snap position.
    @Override
    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
        // Snap to a view that is either 1) toward the bottom of the data and therefore on screen,
        // or, 2) toward the top of the data and may be off-screen.
        int snapPos = calcTargetPosition((LinearLayoutManager) layoutManager);
        View snapView = (snapPos == RecyclerView.NO_POSITION)
            ? null : layoutManager.findViewByPosition(snapPos);

        if (snapView == null) {
            Log.d(TAG, "<<<<findSnapView is returning null!");
        }
        Log.d(TAG, "<<<<findSnapView snapos=" + snapPos);
        return snapView;
    }

    // Does the heavy lifting for findSnapView.
    private int calcTargetPosition(LinearLayoutManager layoutManager) {
        int snapPos;
        int firstVisiblePos = layoutManager.findFirstVisibleItemPosition();

        if (firstVisiblePos == RecyclerView.NO_POSITION) {
            return RecyclerView.NO_POSITION;
        }
        initItemDimensionIfNeeded(layoutManager);
        if (firstVisiblePos >= mPriorFirstPosition) {
            // Scrolling toward bottom of data
            int firstCompletePosition = layoutManager.findFirstCompletelyVisibleItemPosition();
            if (firstCompletePosition != RecyclerView.NO_POSITION
                && firstCompletePosition % mBlocksize == 0) {
                snapPos = firstCompletePosition;
            } else {
                snapPos = roundDownToBlockSize(firstVisiblePos + mBlocksize);
            }
        } else {
            // Scrolling toward top of data
            snapPos = roundDownToBlockSize(firstVisiblePos);
            // Check to see if target view exists. If it doesn't, force a smooth scroll.
            // SnapHelper only snaps to existing views and will not scroll to a non-existant one.
            // If limiting fling to single block, then the following is not needed since the
            // views are likely to be in the RecyclerView pool.
            if (layoutManager.findViewByPosition(snapPos) == null) {
                int[] toScroll = mLayoutDirectionHelper.calculateDistanceToScroll(layoutManager, snapPos);
                mRecyclerView.smoothScrollBy(toScroll[0], toScroll[1], sInterpolator);
            }
        }
        mPriorFirstPosition = firstVisiblePos;

        return snapPos;
    }

    private void initItemDimensionIfNeeded(final RecyclerView.LayoutManager layoutManager) {
        if (mItemDimension != 0) {
            return;
        }

        View child;
        if ((child = layoutManager.getChildAt(0)) == null) {
            return;
        }

        if (layoutManager.canScrollHorizontally()) {
            mItemDimension = child.getWidth();
            mBlocksize = getSpanCount(layoutManager) * (mRecyclerView.getWidth() / mItemDimension);
        } else if (layoutManager.canScrollVertically()) {
            mItemDimension = child.getHeight();
            mBlocksize = getSpanCount(layoutManager) * (mRecyclerView.getHeight() / mItemDimension);
        }
        mMaxPositionsToMove = mBlocksize * mMaxFlingBlocks;
    }

    private int getSpanCount(RecyclerView.LayoutManager layoutManager) {
        return (layoutManager instanceof GridLayoutManager)
            ? ((GridLayoutManager) layoutManager).getSpanCount()
            : 1;
    }

    private int roundDownToBlockSize(int trialPosition) {
        return trialPosition - trialPosition % mBlocksize;
    }

    private int roundUpToBlockSize(int trialPosition) {
        return roundDownToBlockSize(trialPosition + mBlocksize - 1);
    }

    @Nullable
    protected LinearSmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
            return null;
        }
        return new LinearSmoothScroller(mRecyclerView.getContext()) {
            @Override
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
                int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
                                                                   targetView);
                final int dx = snapDistances[0];
                final int dy = snapDistances[1];
                final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
                if (time > 0) {
                    action.update(dx, dy, time, sInterpolator);
                }
            }

            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
            }
        };
    }

    public void setSnapBlockCallback(@Nullable SnapBlockCallback callback) {
        mSnapBlockCallback = callback;
    }

    /*
        Helper class that handles calculations for LTR and RTL layouts.
     */
    private class LayoutDirectionHelper {

        // Is the layout an RTL one?
        private final boolean mIsRTL;

        @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
        LayoutDirectionHelper(int direction) {
            mIsRTL = direction == View.LAYOUT_DIRECTION_RTL;
        }

        /*
            Calculate the amount of scroll needed to align the target view with the layout edge.
         */
        int getScrollToAlignView(View targetView) {
            return (mIsRTL)
                ? mOrientationHelper.getDecoratedEnd(targetView) - mRecyclerView.getWidth()
                : mOrientationHelper.getDecoratedStart(targetView);
        }

        /**
         * Calculate the distance to final snap position when the view corresponding to the snap
         * position is not currently available.
         *
         * @param layoutManager LinearLayoutManager or descendent class
         * @param targetPos     - Adapter position to snap to
         * @return int[2] {x-distance in pixels, y-distance in pixels}
         */
        int[] calculateDistanceToScroll(LinearLayoutManager layoutManager, int targetPos) {
            int[] out = new int[2];

            int firstVisiblePos;

            firstVisiblePos = layoutManager.findFirstVisibleItemPosition();
            if (layoutManager.canScrollHorizontally()) {
                if (targetPos <= firstVisiblePos) { // scrolling toward top of data
                    if (mIsRTL) {
                        View lastView = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition());
                        out[0] = mOrientationHelper.getDecoratedEnd(lastView)
                            + (firstVisiblePos - targetPos) * mItemDimension;
                    } else {
                        View firstView = layoutManager.findViewByPosition(firstVisiblePos);
                        out[0] = mOrientationHelper.getDecoratedStart(firstView)
                            - (firstVisiblePos - targetPos) * mItemDimension;
                    }
                }
            }
            if (layoutManager.canScrollVertically()) {
                if (targetPos <= firstVisiblePos) { // scrolling toward top of data
                    View firstView = layoutManager.findViewByPosition(firstVisiblePos);
                    out[1] = firstView.getTop() - (firstVisiblePos - targetPos) * mItemDimension;
                }
            }

            return out;
        }

        /*
            Calculate the number of positions to move in the RecyclerView given a scroll amount
            and the size of the items to be scrolled. Return integral multiple of mBlockSize not
            equal to zero.
         */
        int getPositionsToMove(LinearLayoutManager llm, int scroll, int itemSize) {
            int positionsToMove;

            positionsToMove = roundUpToBlockSize(Math.abs(scroll) / itemSize);

            if (positionsToMove < mBlocksize) {
                // Must move at least one block
                positionsToMove = mBlocksize;
            } else if (positionsToMove > mMaxPositionsToMove) {
                // Clamp number of positions to move so we don't get wild flinging.
                positionsToMove = mMaxPositionsToMove;
            }

            if (scroll < 0) {
                positionsToMove *= -1;
            }
            if (mIsRTL) {
                positionsToMove *= -1;
            }

            if (mLayoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
                // Scrolling toward the bottom of data.
                return roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove;
            }
            // Scrolling toward the top of the data.
            return roundDownToBlockSize(llm.findLastVisibleItemPosition()) + positionsToMove;
        }

        boolean isDirectionToBottom(boolean velocityNegative) {
            //noinspection SimplifiableConditionalExpression
            return mIsRTL ? velocityNegative : !velocityNegative;
        }
    }

    public interface SnapBlockCallback {
        void onBlockSnap(int snapPosition);

        void onBlockSnapped(int snapPosition);

    }

    private static final float MILLISECONDS_PER_INCH = 100f;
    @SuppressWarnings("unused")
    private static final String TAG = "SnapToBlock";
}

The SnapBlockCallback interface defined above can be used to report the adapter position of the view at the start of the block to be snapped. The view associated with that position may not be instantiated when the call is made if the view is off screen.

Tenebrific answered 30/11, 2017 at 18:57 Comment(61)
Can you please show how to have the callback of when it's about to snap, and see which item/view/viewHolder it's about to snap to? Also, can you please show how you've found this solution? Where did you read about it?Maculate
@androiddeveloper See updated answer. I didn't really find this answer anywhere and may be reinventing the wheel since I didn't really look for another solution. Your question mentions LinearSnapHelper which is very similar to what you are trying to do. I proceeded from that basis to write this solution by backtracking to SnapHelper and working forward.Tenebrific
Will it also work with GridLayoutManager? Or does it need extra changes for it? I ask because I might need it later for it too. About onBlockSnapped, just want to make sure: does it get called when it starts to snap, before the snapping finishes ? I ask because of its name... Is it also possible to know when it has finished snapping? Does it work as I wrote, like a ViewPager, so that when you fling fast, you still move just one page? Or should I use the library I've mentioned?Maculate
I just tested the answer's code. It crashes as soon as I try scrolling, with ArithmeticException: divide by zero on the line of int positionsToMove = velocityX / mItemWidth; .Maculate
To fix the exception, I had to run it in viewTreeObserver.addOnPreDrawListener callback. However, it doesn't work like a ViewPager. If I fling, it can scroll a lot till it snaps. That being said, it works nicely with the snapping. Please tell me how I can make it work like on a ViewPager, and also how to make it work for GridLayoutManager . About the callback, I think it's faulty too. Sometimes it's called twice. Sometimes it's called only when it reaches the cell, instead of about-to-reach-the-cell.Maculate
This seems much better. How did you do it (I want to learn from what you did) ? What did you add to make it work like ViewPager? The issue of the layout can be fixed by lazy-set of it, in findSnapView and in 'findTargetSnapPosition'. You can just add a check if mItemDimension is 0, and set it there. I will now grant this solution the bounty. However, I've noticed that the snapping after a fling doesn't look as natural as on ViewPager. It moves... in linear animation movementMaculate
@androiddeveloper I thought about the lazy-set but decided to just take care of it in the constructor. Working like ViewPager limits the movement to one screen forward or back and takes place in findTargetSnapPosition(). SnapHelper uses RecyclerView's LinearViewScroller and works like you see. ViewPager uses its own interpolator.Tenebrific
@androiddeveloper Updated the code to use ViewPager's scroller interpolator.Tenebrific
Seems the scrolling works well now. The callback is still buggy: it is called after the snapping has finished, not before. Here's a sample project: ufile.io/e0ga8 . I've updated the code, to show you that it indeed is called after. You will notice it by letting it snap. If it was working, you would have seen the selected item (the first that it's about to snap to) marked as "selected: #" before it finishes to snap. Also, as I wrote, you can set the value of mItemDimension in the other functions instead.Maculate
I've also noticed that if I change the orientation while it's snapping, it stops snapping, so that the RecyclerView stays on a non-snapped state. Seems to be an issue with LinearSnapHelper too, so I've reported about it here: issuetracker.google.com/issues/70130727Maculate
@androiddeveloper I was able to get the callback invoked before snap but I can't reliably reproduce it. I moved the callback to calculateDistanceToFinalSnap() so it is always invoked after a snap. Code was updated.Tenebrific
Well I need it before and after, but more importantly, before. See here about how I did it for ViewPager: https://mcmap.net/q/454603/-how-to-get-soon-to-be-selected-page-while-settling-snapping-feature-on-recyclerview-and-viewpager (its adapter already has setPrimaryItem for the after-snapping). I don't see what was fixed on your code. It still only calls the callback after the snapping, as before.Maculate
@androiddeveloper Callbacks for pre/post snapping added.Tenebrific
Now it works perfectly. Any idea though how to handle orientation change while it's snapping? Maybe I should call it to stop scrolling, and set the scrolling to the item it was about to snap to?Maculate
@androiddeveloper Regarding the orientation change issue: Wait to attach SnapToBlock to the RecyclerVIew until the RecyclerView is laid out. The global layout listener would be a good place to do it. attachToRecyclerView() in SnapHelper calls snapToTargetExistingView(), but the target view must exist for it to snap.Tenebrific
Seems to fix it too, but is there a way to make it snap right away ?Maculate
BTW, I've just tested it with GridLayoutManager. Also works perfectly. This is great work. Marked this answer as accepted.Maculate
I think I handled the issue of snapping after orientation change, together with the need for working after the recyclerView was laid-out. Here's my current code: ufile.io/xsewkMaculate
Very nice! As for snapping right away, try saving and restoring the snap position across the orientation change. Upon restore, scroll to the saved position and immediately request a layout. That may work, @androiddeveloperTenebrific
I think it should work, but I don't think requesting a layout is needed, because selection of an item should go straight away. I just thought maybe there is a way to stop the snap before the activity gets re-created. Need to check it. For now, what I wrote handles it anyway.Maculate
Say, why createSnapScroller is marked as deprecated? Shouldn't you use something else? I think it's safe to replace it with createScroller : developer.android.com/reference/android/support/v7/widget/…Maculate
@androiddeveloper That should work. I don't think I was on 26.1.0 at that time. Looks like just a name change.Tenebrific
ok I've changed it and made a Kotlin solution and I've put it in the end of the question, including the rest of the changes I've talked about.Maculate
I've found some serious issues with the code, now that I'm using it. Update the question and included new bounty. Please check it out.Maculate
@androiddeveloper Updated code. This should correct some of the issues that you have seen. The scrolling is a little more straightforward with this update.Tenebrific
I think you've removed an important call of initItemDimensionIfNeeded in findTargetSnapPosition (otherwise it crashes). Updated your code. Also, about RTL, it still doesn't work. I try to scroll to the next group of items, and it snaps back to the first one. Here's the project: ufile.io/9q10mMaculate
@androiddeveloper I thought RTL scrolling was working but I guess not. Updated code to actually scroll a RTL layout. A calculation was off in getScrollToAlignView().Tenebrific
I don't see it fixed. In fact, now it's bad for LTR locale (English) too: It snaps on every single item, instead of every 4 items as I've set it to be.Maculate
@androiddeveloper It is definitely working in my environment. I wonder if I don't have something wrong in the answer. There is an edge case where snapping is incorrect if the width of the RecyclerView is wide enough to completely display more than one head item (every 4th item in your case), so the recycler width would be >=8. Is that the case? If not, I will post my demo project to GitHub so you can take a look.Tenebrific
@androiddeveloper Cleaned up the answer and put a video in of the scrolling LTR and RTL.Tenebrific
Can you please update the Github project then? Please start from the project I've made, since it allows to easily change it to GridLayoutManager instead of LinearLayoutManager.Maculate
@androiddeveloper I don't have a GItHub project for this. The code you need is in the answer.Tenebrific
@androiddeveloper I looked at your project and the call to the CTOR for SnapToBlock did not look right. I modified MainActivity and made some explanatory comments and have an updated SnapToBlock that are available here. The SnapToBlock is a little different from what is posted in the answer. If this one works, I can update the answer.Tenebrific
@androiddeveloper Posted a simplified version of the code. The CTOR is simpler with SnapToBlock making a determination of the operational parameters in attachToRecyclerView(). The scrolling was also not 100%, but I think it is all OK now.Tenebrific
You don't have Github project for this? I thought this one is yours: github.com/DevExchanges/SnappingRecyclerview . No? In any case, please publish to Github, so that all could use it easily.Maculate
What's "rowsCols * spans" ? Is it the number of cells per page? Meaning ITEMS_PER_PAGE in my example?Maculate
Also, RTL still doesn't work well. I thought you've fixed it, and granted the bounty. Please fix this issue. I think you've made the wrong check if the current layout works in RTL/LTR. Here's a way to check if the current locale is RTL : TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL . In your code, it doesn't check it, so it stays on LTR even if I change the locale of the device to Hebrew.Maculate
Changing your code to check the direction of the current locale fixes the issue.Maculate
I've noticed another issue though: if I fling, it can scroll over more than one page, as opposed to a ViewPager, which I wanted to mimic . It worked fine before. Can you please check it out? Maybe have a flag to make it work this way?Maculate
@androiddeveloper Just call the CTOR with "1". That means fling at most one page.Tenebrific
@androiddeveloper Where does that current locale check for LTR/RTL go in the code?Tenebrific
About the paging, thank you. Works well now. I would have suggested to have non-positive value in case there shouldn't be a limit, but this works fine for me. About the locale check, I've put it in a new CTOR of LayoutDirectionHelper , which doesn't have parameters, that gets created in the block of if (layoutManager.canScrollHorizontally()) . I still think it's not the best solution of locale check. Made a question about this here: https://mcmap.net/q/454604/-how-to-detemine-the-current-direction-of-a-view-rtl-ltr/878126Maculate
@Tenebrific I think the layout direction check should stay as you wrote, except that the call to it should be after the view is ready to tell you its direction. The reason, according to my tests, is that it's always LTR by default, even if the device locale is RTL.Maculate
If instead of having a field mIsRTL, you just check the direction of the recyclerView, it works fine. Another alternative is to initialize the mLayoutDirectionHelper field (using your check of ViewCompat.getLayoutDirection ) in all places that it gets used: findTargetSnapPosition , calcTargetPosition, calculateDistanceToFinalSnap . At these points, the view knows its correct layout direction.Maculate
Anyway, I've updated the Kotlin version of this solution, to handle RTL correctly, with lazy init of the field. This will handle device locale changes nicely.Maculate
@Tenebrific Can you please check on the excessive creation of the ViewHolder when doing a fling gesture?Maculate
@androiddeveloper What are you considering excessive creation of ViewHolders? I don't see excessive behavior on my end but the number of ViewHolders created is definitely more than the visible views which is OK AFAIK. Do you have something that you can share that displays this behavior?Tenebrific
@Tenebrific OK. I've made a project and a sample video to show the issue: files.fm/u/vvahh9pt . As you can see, each fling to go to the next page will cause new ViewHolders to be created (about 4 in this case, as there are 4 per page). They are not recycled. I also wrote that I've noticed a similar case of a normal RecyclerView, but it requires a different approach to reproduce it: issuetracker.google.com/issues/71784689Maculate
@androiddeveloper I saw this before but forgot to mention it to you. You are calling RecyclerView.Adapter#notifyDataSetChanged when it is preferable to call RecyclerView.Adapter#notifyItemChanged with snapPosition as an argument. This will take care of this issue.Tenebrific
@Tenebrific It doesn't matter. It occurs even if I don't call any of those (when choosing to smooth scroll). Using notifyItemChanged (on both previous and new items), it creates a new ViewHolder upon every scroll between pages. Also, for some reason, calling notifyItemChanged on this sample causes weird artifacts (flashing), while on the real app it causes some smearing while scrolling via code.Maculate
@androiddeveloper That is odd. I just read your bug report. Does this happen pre-Lollipop? Google made some significant changes in Lollipop to RecyclerView fetching.Tenebrific
@androiddeveloper If you can, set hasStableIds(true). See hasStableIds.Tenebrific
@Tenebrific Adding hasStableIds(true) , here are the results: on Android 4.4 (emulator), Google issue seem to be fixed (can't create more and more VH, no matter how much I try) and the snapping sample has the VH creation when doing smooth scrolling and about half the time when switching between pages. On Android 8.1 (Pixel 2), Google issue still exists (smooth scroll creates new VH), and the snapping sample creates new VH on both smooth scrolling and switching between pages (though it usually creates just one VH this time)Maculate
I think I've found the problem, and the solution. It seems the default behavior of the pool of RecyclerView, is to hold up to 5 views (it's inside the RecyclerView code, in a constant called DEFAULT_MAX_SCRAP) . A possible solution is to raise it. You can raise it even to Integer.MAX_VALUE if you wish.Maculate
What I don't get is why the hasStableIds(true) has issues when I request it to scroll. It can get very messy sometimes.Maculate
@androiddeveloper I posted a demonstration project to GitHub that has an updated version of SnapToBlock. It should be functionally the same as what is in the answer but it can handle decorations and margins which the first could not. In that demo, I set the view holder pool counts to something reasonable which seems to address at least some of the problem.Tenebrific
@Tenebrific ok found a bug: github.com/Cheticamp/SnapToBlockDemo/issues/1 . Still don't get why in my case I see issues when scrolling with stable ids, and not on your sample. Is it different than what I recently used? I've now tried to use it, and it also has this issue (using scrollToPositionWithOffset) , but only on my POC... Can you please check scrollToPositionWithOffset or any similar scrolling that's not smooth?Maculate
@androiddeveloper I have a fix for the GitHub issue what I will post at a later time. On the scrollToPositionWithOffset issue, the OnScrollListener does not get a callback after this call (!), so the snapping routines aren't invoked. There are several questions regarding this on Stack Overflow (here for example). I am not sure what the fix would be other than doing a smoothScrollToPosition after the scrollToPositionWithOffset completes. Somehow OnScrollListener must be invoked.Tenebrific
@androiddeveloper Also, what are "issues when scrolling with stable ids"? SnapToBlock is different from what is posted in the answer to the question although it is substantially the same.Tenebrific
@Tenebrific when i scroll from left to right sometimes it starts scrolling and stops only at the start of the recyclerview, first item. Any ideas why it scrolls automatically to the first item, it works for RTL? Also i am using GRID 3x5Erbium
Thank you so much for this solution! I was struggling with the same requirement for a while now. For future reference I took the Kotlin port from @androiddeveloper, cleaned it up a little and made a gist with references to you guys. Check it out, and let me know if that's ok. gist.github.com/Moes81/…Illdefined
C
3

This library it is useful https://github.com/TakuSemba/MultiSnapRecyclerView

    //Adding multisnap to the recyclerview
    val multiSnapHelper = MultiSnapHelper(MultiSnapHelper.DEFAULT_GRAVITY, 1, 200F)
    multiSnapHelper.attachToRecyclerView(recyclerView)

this code above is for your activity via code

    <com.takusemba.multisnaprecyclerview.MultiSnapRecyclerView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:msrv_gravity="start" or center, end
    app:msrv_interval="2" items to scroll over
    app:msrv_ms_per_inch="100" /> // speed of scrolling through.

and this is the same way but in xml it is your choice

all this information its from the documentation

Cruikshank answered 4/1, 2021 at 3:53 Comment(1)
it does not support reverselayoutSeptuagenarian
H
2

I would do something like that

  1. Block scrolling inside RecyclerView (e.g How to disable RecyclerView scrolling?)

  2. Create Gesture Fling Detecor and attach it to RecyclerView

  3. Inside Gesture Detector detect fling events events
  4. On Fling event, detect side (left right)
  5. Scroll RecyclerView to position (first Visible item + your const * (left?-1:1))

should work :)

Hartzog answered 27/11, 2017 at 15:54 Comment(1)
I don't want to block scrolling. Scrolling should work fine as before. Or maybe I misunderstood? Can you please show code of how to do it all?Maculate

© 2022 - 2024 — McMap. All rights reserved.