如何捕捉RecyclerView项目,以便每个X项目将被视为一个单元来捕捉?

背景

可以使用以下方法将RecyclerView锁定到其中心:

LinearSnapHelper().attachToRecyclerView(recyclerView) 

例:

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() { 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中

  

也可以把它抓到其他的一边,就像在这里的一些图书馆所做的那样。

还有一些库允许有一个像ViewPager一样工作的RecyclerView,比如这里

问题

假设我有一个RecyclerView(水平在我的情况下)与许多项目,我希望它会把每个X项目(X是常数)作为一个单位,并捕捉到每个单位。

例如,如果我滚动一下,它可以捕捉到0项或X项,但不会捕捉到它们之间的东西。

在某种程度上,它的行为与一般的ViewPager类似,只是每个页面都有X个项目。

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

在这里输入图像说明

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

在这里输入图像说明

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

滚动更多(在同一方向)到下一个捕捉点将达到项目“6”,“9”,等等…

我试过了

我试图寻找替代的图书馆,我也尝试阅读关于这个文档,但我没有find任何可能有用的东西。

也许可以通过使用ViewPager,但我认为这不是最好的方式,因为ViewPager不会很好地回收它的项目,我认为它不像RecyclerView那么灵活。

问题

  1. 是否有可能设置RecyclerView捕捉每个X项目,将每个X项目作为单个页面来捕捉?

    当然,这些物品会为整个RecyclerView平均占用足够的空间。

  2. 假设有可能,当RecyclerView即将捕捉某个项目(包括该项目)之前,如何获得回调? 我问这个,因为这跟我在这里问的同一个问题有关。


Kotlin解决方案

在示例中,基于“Cheticamp”的工作Kotlin解决方案的答案( 此处 )无需validation您具有RecyclerView大小,并且可以选择具有网格而不是列表:

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() { 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, "<<<= 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  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  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" } } 

更新

尽管我已经标记了一个被接受的答案 ,因为它工作正常,我注意到它有严重的问题:

  1. 平滑滚动似乎不能正常工作(不滚动到正确的地方)。 只有滚动这项工作是这样的(但“拖影”效果):

     (recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos,0) 
  2. 当切换到RTL(从右到左)的语言环境,如希伯来语(“עברית”),它不让我滚动。

  3. 我注意到onCreateViewHolder被称为很多。 实际上,每次我滚动时都会调用它,即使是它应该回收ViewHolders的时间。 这意味着有过多的视图创建,也可能意味着有内存泄漏。

我试图解决这些问题,但迄今为止失败了。

如果这里有人知道如何解决这个问题,我会给予额外的新奖金


更新:因为我们修复了RTL / LTR,所以我在这篇文章中更新了Kotlin解决方案。

SnapHelper为你正在尝试的东西提供必要的框架,但是它需要扩展来处理视图块。 下面的类SnapToBlock扩展了SnapHelper以捕捉视图块。 在这个例子中,我已经使用了四个视图,但可以更多或更少。

更新:代码已经改变,以适应GridLayoutManager以及LinearLayoutManager 。 现在禁止投掷,所以对齐工作更多的列表一个ViewPager 。 现在支持水平和垂直滚动以及LTR和RTL布局。

更新:更改平滑滚动插值器更像ViewPager

更新:添加回调前/后贴紧。

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

以下是示例应用程序的快速video:

在这里输入图像说明

设置布局管理器如下:

 // 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)); 

添加以下内容将SnapToBlock附加到RecyclerView

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

mMaxFlingPages是允许同时抛出的最大块数(rowsCols *跨度)。

对于即将拍摄并完成拍摄时的回叫,请添加以下内容:

 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, "<<< 

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

I would do something like that

  1. Block scrolling inside RecyclerView (eg 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 🙂