如何捕捉RecyclerView项目,以便将每个X项目视为单个要捕捉的单位?

and*_*per 16 android snapping android-viewpager android-recyclerview

背景

可以使用以下方法将RecyclerView捕捉到其中心:

LinearSnapHelper().attachToRecyclerView(recyclerView)
Run Code Online (Sandbox Code Playgroud)

例:

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)
    }
}
Run Code Online (Sandbox Code Playgroud)

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"/>
Run Code Online (Sandbox Code Playgroud)

它也可以将其捕捉到其他方面,就像在某些库中所做的那样,例如这里.

还有一些库允许使用可以像ViewPager一样工作的RecyclerView,例如此处.

问题

假设我有一个包含许多项目的RecyclerView(在我的情况下为水平),我希望它将每个X项目(X是常量)视为一个单元,并对齐每个单元.

例如,如果我滚动一下,它可以捕捉到0项或X项,但不能捕捉到它们之间的某些内容.

在某种程度上,它的行为与普通ViewPager的情况类似,只是每个页面中都有X个项目.

例如,如果我们从上面编写的示例代码继续,假设X == 3,则捕捉将来自此空闲状态:

在此输入图像描述

到这个空闲状态(如果我们滚动得足够多,否则将保持在以前的状态):

在此输入图像描述

更多的投掷或滚动应该像在ViewPager上一样处理,就像我上面提到的库一样.

向下一个捕捉点滚动更多(朝同一方向)将是到达项目"6","9",依此类推......

我尝试了什么

我试图搜索替代库,我也尝试阅读有关此问题的文档,但我没有找到任何可能有用的文档.

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()
            }

        })
    }

}
Run Code Online (Sandbox Code Playgroud)

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"
    }
}
Run Code Online (Sandbox Code Playgroud)

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)
    
    Run Code Online (Sandbox Code Playgroud)
  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.


更新:关于第3点,这似乎是因为recyclelerView有一个视图池,它会很快被填充.为了解决这个问题,我们可以通过使用recyclerView.getRecycledViewPool() .setMaxRecycledViews(viewType, Integer.MAX_VALUE)我们在其中的每种视图类型来简单地扩大池大小.真的需要这个奇怪的事情.我已经将它发布到谷歌(这里这里),但被拒绝,默认情况下池应该是无限的.最后,我决定至少要求为所有视图类型(这里)提供更方便的功能.

Che*_*amp 28

SnapHelper为您尝试的内容提供必要的框架,但需要扩展它以处理视图块.SnapToBlock下面的类扩展SnapHelper为捕捉到视图块.在这个例子中,我对一个块使用了四个视图,但它可以更多或更少.

更新:代码已更改以适应GridLayoutManager以及LinearLayoutManager.现在禁止投掷,因此捕捉更有效ViewPager.现在支持水平和垂直滚动以及LTR和RTL布局.

更新:更改了平滑滚动插补器更像ViewPager.

更新:为前/后捕捉添加回调.

更新:添加对RTL布局的支持.

以下是示例应用的快速视频:

在此输入图像描述

如下设置布局管理器:

// 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));
Run Code Online (Sandbox Code Playgroud)

添加以下内容以附加SnapToBlockRecyclerView.

SnapToBlock snapToBlock = new SnapToBlock(mMaxFlingPages);
snapToBlock.attachToRecyclerView(recyclerView);
Run Code Online (Sandbox Code Playgroud)

mMaxFlingPages 是允许一次抛出的最大块数(rowsCols*spans).

对于即将完成快照并已完成快照的回叫,请添加以下内容:

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

    @Override
    public void onBlockSnapped(int snapPosition) {
        ...
    }
});
Run Code Online (Sandbox Code Playgroud)

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";
}
Run Code Online (Sandbox Code Playgroud)

SnapBlockCallback上面定义的接口可用于报告要捕捉的块开始处的视图的适配器位置.如果视图不在屏幕上进行调用,则可能无法实例化与该位置关联的视图.

  • @androiddeveloper 关于方向变化问题:等待将 `SnapToBlock` 附加到 `RecyclerVIew`,直到布置 `RecyclerView`。全局布局侦听器将是一个很好的地方。`SnapHelper` 中的 `attachToRecyclerView()` 调用了 `snapToTargetExistingView()`,但目标视图必须存在才能捕捉。 (2认同)