RecyclerView中的流畅滚动

74

我正在尝试使用新的RecyclerView类来实现滚动时组件自动对齐到特定元素的场景(旧的Android Gallery就是这样一种列表,其中有一个居中锁定的项)。

到目前为止,我采用的方法如下:

我有一个接口ISnappyLayoutManager,其中包含一个方法getPositionForVelocity,该方法计算给定初始抛掷速度视图应该在哪个位置结束滚动。

public interface ISnappyLayoutManager {
    int getPositionForVelocity(int velocityX, int velocityY);  
}

我有一个类,SnappyRecyclerView,它是RecyclerView的子类,并以一种使视图在精确的位置上飞出的方式覆盖了它的fling()方法:

public final class SnappyRecyclerView extends RecyclerView {

    /** other methods deleted **/

    @Override
    public boolean fling(int velocityX, int velocityY) {
        LayoutManager lm = getLayoutManager();

        if (lm instanceof ISnappyLayoutManager) {
            super.smoothScrollToPosition(((ISnappyLayoutManager) getLayoutManager())
                    .getPositionForVelocity(velocityX, velocityY));
        }
        return true;
    }
}

对于几个原因,我对这种方法并不满意。首先,这似乎违反了“RecyclerView”的哲学,因为必须对其进行子类化以实现某种类型的滚动。其次,如果我想使用默认的LinearLayoutManager,这变得有些复杂,因为我必须在其内部操作以了解其当前滚动状态,并计算出它确切的滚动位置。最后,这甚至不能处理所有可能的滚动情况,因为如果您移动列表然后暂停,然后抬起手指,就没有快速滑动事件发生(速度太低),因此列表保持在一半位置。这可能可以通过向RecyclerView添加滚动状态侦听器来处理,但那也感觉非常糟糕。

我觉得我一定漏掉了什么。有更好的方法吗?

13个回答

82

有了LinearSnapHelper,现在很容易实现。

你只需要这样做:

SnapHelper helper = new LinearSnapHelper();
helper.attachToRecyclerView(recyclerView);

如此简单!请注意,LinearSnapHelper是从24.2.0版本开始添加到Support Library中的。

这意味着您必须将其添加到应用程序模块的build.gradle中。

compile "com.android.support:recyclerview-v7:24.2.0"

更新:AndroidX LinearSnapHelper


5
很不幸,它跳到了列表项的中间。 - JPLauber
10
值得注意的是,如果有人遇到与此解决方案相同的问题,则需注意以下内容:如果在设置RecyclerView时出现“IllegalStateException: An instance of OnFlingListener already set”的错误,请在snapHelper.attachToRecyclerView(recyclerView)之前调用recyclerView.setOnFlingListener(null)。 - Analizer
SnapHelper如何控制快照的速度? - Tyler Pfaff
3
实现将目标子视图的中心与附加的RecyclerView的中心对齐。如果你想改变这种行为,可以重写calculateDistanceToFinalSnap(RecyclerView.LayoutManager, View)方法。 - Jake
如何在程序中进行自动对齐?因为在我们轻触或滚动一点之前,它不会自动对齐,有什么解决方法吗? - NotABot

61
我最终想出的方案与上面稍有不同。虽然不是理想的解决方案,但对我来说工作得还算可以,可能对其他人也有帮助。我不会接受这个答案,因为我希望其他人能提供更好、更不破坏原有功能的方法(也有可能是我误解了RecyclerView实现,错过了一些简单的方法),但在此期间,这个方案已经足够好用了!
实现的基本思路如下:RecyclerView中的滚动有点分散在RecyclerViewLinearLayoutManager之间。我需要处理两种情况:
1. 用户快速滑动视图。默认行为是,RecyclerView将该滑动传递给内部的Scroller,然后执行滚动效果。这种方式存在问题,因为RecyclerView通常无法停留在捕捉位置。为了解决这个问题,我重写了RecyclerViewfling()实现。我不再使用快速滑动,而是平滑地滚动LinearLayoutManager到一个位置。
2. 用户松开手指的速度不足以启动滚动。这种情况下不会发生快速滑动。如果视图未处于捕捉位置,我希望检测到此情况。为了实现这一点,我重写了onTouchEvent方法。 SnappyRecyclerView:
public final class SnappyRecyclerView extends RecyclerView {

    public SnappyRecyclerView(Context context) {
        super(context);
    }

    public SnappyRecyclerView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public SnappyRecyclerView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    public boolean fling(int velocityX, int velocityY) {
        final LayoutManager lm = getLayoutManager();        

      if (lm instanceof ISnappyLayoutManager) {
            super.smoothScrollToPosition(((ISnappyLayoutManager) getLayoutManager())
                    .getPositionForVelocity(velocityX, velocityY));
            return true;
        }
        return super.fling(velocityX, velocityY);
    }        

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        // We want the parent to handle all touch events--there's a lot going on there, 
        // and there is no reason to overwrite that functionality--bad things will happen.
        final boolean ret = super.onTouchEvent(e);
        final LayoutManager lm = getLayoutManager();        

      if (lm instanceof ISnappyLayoutManager
                && (e.getAction() == MotionEvent.ACTION_UP || 
                    e.getAction() == MotionEvent.ACTION_CANCEL)
                && getScrollState() == SCROLL_STATE_IDLE) {
            // The layout manager is a SnappyLayoutManager, which means that the 
            // children should be snapped to a grid at the end of a drag or 
            // fling. The motion event is either a user lifting their finger or 
            // the cancellation of a motion events, so this is the time to take 
            // over the scrolling to perform our own functionality.
            // Finally, the scroll state is idle--meaning that the resultant 
            // velocity after the user's gesture was below the threshold, and 
            // no fling was performed, so the view may be in an unaligned state 
            // and will not be flung to a proper state.
            smoothScrollToPosition(((ISnappyLayoutManager) lm).getFixScrollPos());
        }        

      return ret;
    }
}

一种用于快速布局管理器的接口:

/**
 * An interface that LayoutManagers that should snap to grid should implement.
 */
public interface ISnappyLayoutManager {        

    /**
     * @param velocityX
     * @param velocityY
     * @return the resultant position from a fling of the given velocity.
     */
    int getPositionForVelocity(int velocityX, int velocityY);        

    /**
     * @return the position this list must scroll to to fix a state where the 
     * views are not snapped to grid.
     */
    int getFixScrollPos();        

}

这是一个关于LayoutManager的示例,它继承了LinearLayoutManager,从而得到了一个具有流畅滚动效果的LayoutManager

public class SnappyLinearLayoutManager extends LinearLayoutManager implements ISnappyLayoutManager {
    // These variables are from android.widget.Scroller, which is used, via ScrollerCompat, by
    // Recycler View. The scrolling distance calculation logic originates from the same place. Want
    // to use their variables so as to approximate the look of normal Android scrolling.
    // Find the Scroller fling implementation in android.widget.Scroller.fling().
    private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1)
    private static float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9));
    private static double FRICTION = 0.84;

    private double deceleration;

    public SnappyLinearLayoutManager(Context context) {
        super(context);
        calculateDeceleration(context);
    }

    public SnappyLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
        super(context, orientation, reverseLayout);
        calculateDeceleration(context);
    }

    private void calculateDeceleration(Context context) {
        deceleration = SensorManager.GRAVITY_EARTH // g (m/s^2)
                * 39.3700787 // inches per meter
                // pixels per inch. 160 is the "default" dpi, i.e. one dip is one pixel on a 160 dpi
                // screen
                * context.getResources().getDisplayMetrics().density * 160.0f * FRICTION;
    }

    @Override
    public int getPositionForVelocity(int velocityX, int velocityY) {
        if (getChildCount() == 0) {
            return 0;
        }
        if (getOrientation() == HORIZONTAL) {
            return calcPosForVelocity(velocityX, getChildAt(0).getLeft(), getChildAt(0).getWidth(),
                    getPosition(getChildAt(0)));
        } else {
            return calcPosForVelocity(velocityY, getChildAt(0).getTop(), getChildAt(0).getHeight(),
                    getPosition(getChildAt(0)));
        }
    }

    private int calcPosForVelocity(int velocity, int scrollPos, int childSize, int currPos) {
        final double dist = getSplineFlingDistance(velocity);

        final double tempScroll = scrollPos + (velocity > 0 ? dist : -dist);

        if (velocity < 0) {
            // Not sure if I need to lower bound this here.
            return (int) Math.max(currPos + tempScroll / childSize, 0);
        } else {
            return (int) (currPos + (tempScroll / childSize) + 1);
        }
    }

    @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, State state, int position) {
        final LinearSmoothScroller linearSmoothScroller =
                new LinearSmoothScroller(recyclerView.getContext()) {

                    // I want a behavior where the scrolling always snaps to the beginning of 
                    // the list. Snapping to end is also trivial given the default implementation. 
                    // If you need a different behavior, you may need to override more
                    // of the LinearSmoothScrolling methods.
                    protected int getHorizontalSnapPreference() {
                        return SNAP_TO_START;
                    }

                    protected int getVerticalSnapPreference() {
                        return SNAP_TO_START;
                    }

                    @Override
                    public PointF computeScrollVectorForPosition(int targetPosition) {
                        return SnappyLinearLayoutManager.this
                                .computeScrollVectorForPosition(targetPosition);
                    }
                };
        linearSmoothScroller.setTargetPosition(position);
        startSmoothScroll(linearSmoothScroller);
    }

    private double getSplineFlingDistance(double velocity) {
        final double l = getSplineDeceleration(velocity);
        final double decelMinusOne = DECELERATION_RATE - 1.0;
        return ViewConfiguration.getScrollFriction() * deceleration
                * Math.exp(DECELERATION_RATE / decelMinusOne * l);
    }

    private double getSplineDeceleration(double velocity) {
        return Math.log(INFLEXION * Math.abs(velocity)
                / (ViewConfiguration.getScrollFriction() * deceleration));
    }

    /**
     * This implementation obviously doesn't take into account the direction of the 
     * that preceded it, but there is no easy way to get that information without more
     * hacking than I was willing to put into it.
     */
    @Override
    public int getFixScrollPos() {
        if (this.getChildCount() == 0) {
            return 0;
        }

        final View child = getChildAt(0);
        final int childPos = getPosition(child);

        if (getOrientation() == HORIZONTAL
                && Math.abs(child.getLeft()) > child.getMeasuredWidth() / 2) {
            // Scrolled first view more than halfway offscreen
            return childPos + 1;
        } else if (getOrientation() == VERTICAL
                && Math.abs(child.getTop()) > child.getMeasuredWidth() / 2) {
            // Scrolled first view more than halfway offscreen
            return childPos + 1;
        }
        return childPos;
    }

}

1
很棒的答案!对于其他需要自定义滚动行为的地方也是一个不错的起点,你可以在那里自定义用户的快速滑动/滚动结束位置。唯一的问题是Constant.INCHES_PER_METER不存在,所以我自己将其设置为39.3700787 - Mac_Cain13
@UwaisA 你有什么想法可以同时考虑偏移量吗? 如果我打算使用scrollToPositionWithOffset(pos,offset)而不是smoothScrollToPosition()? - ShahrozKhan91
1
@ShahrozKhan91 不好意思,说实话我也不知道 - 我不理解这个类是如何工作的,只是发现那是我需要变化的参数,然后进行了一些试错。 - Uwais A
当向上滚动列表时(负速度),垂直超滚问题可以通过以下更改解决: - Brett Duncavage
我按下回车键太早了,失去了编辑上一条评论的时间。这是更改,修复了在向上滚动列表时的垂直超滚动: // 如果我们往下飞出(即返回到列表上方),则第 0 个孩子 // 是我们上面的孩子,因此需要进行偏移。 if (velocityY < 0) { childPos += 1; }我猜在注释中编写代码并不好 :/ - Brett Duncavage
显示剩余17条评论

14

我还需要一个快速的可回收视图。我想要让可回收视图项向列的左侧捕捉。最终我实现了一个SnapScrollListener并将其设置在可回收视图上。这是我的代码:

SnapScrollListener:

class SnapScrollListener extends RecyclerView.OnScrollListener {

    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        if (RecyclerView.SCROLL_STATE_IDLE == newState) {
            final int scrollDistance = getScrollDistanceOfColumnClosestToLeft(mRecyclerView);
            if (scrollDistance != 0) {
                mRecyclerView.smoothScrollBy(scrollDistance, 0);
            }
        }
    }

}

戳的计算:

private int getScrollDistanceOfColumnClosestToLeft(final RecyclerView recyclerView) {
    final LinearLayoutManager manager = (LinearLayoutManager) recyclerView.getLayoutManager();
    final RecyclerView.ViewHolder firstVisibleColumnViewHolder = recyclerView.findViewHolderForAdapterPosition(manager.findFirstVisibleItemPosition());
    if (firstVisibleColumnViewHolder == null) {
        return 0;
    }
    final int columnWidth = firstVisibleColumnViewHolder.itemView.getMeasuredWidth();
    final int left = firstVisibleColumnViewHolder.itemView.getLeft();
    final int absoluteLeft = Math.abs(left);
    return absoluteLeft <= (columnWidth / 2) ? left : columnWidth - absoluteLeft;
}

如果第一个可见视图向左滚动超过屏幕宽度的一半,下一个可见列将会自动对齐到左侧。

设置监听器:

mRecyclerView.addOnScrollListener(new SnapScrollListener());

1
很好的方法,但在调用smoothScrollBy()之前,您应该检查getScrollDistanceOfColumnClosestToLeft()是否返回非零值,否则您将得到无限的onScrollStateChanged(SCROLL_STATE_IDLE)调用。 - Mihail Ignatiev

14

我已经找到了一种更加简洁的方法来实现这个。@Catherine(原贴作者),如果你觉得这个方法比你的更好,或者你有任何改进意见,请让我知道 :)

以下是我使用的滚动监听器。

https://github.com/humblerookie/centerlockrecyclerview/

这里省略了一些小的假设,例如:

1)初始和最终内边距:水平滚动中的第一个和最后一个项目需要分别设置初始和最终内边距,以便在滚动到第一个和最后一个时将其置于中心位置。例如,在onBindViewHolder中,您可以执行类似以下操作:

@Override
public void onBindViewHolder(ReviewHolder holder, int position) {
holder.container.setPadding(0,0,0,0);//Resetpadding
     if(position==0){
//Only one element
            if(mData.size()==1){
                holder.container.setPadding(totalpaddinginit/2,0,totalpaddinginit/2,0);
            }
            else{
//>1 elements assign only initpadding
                holder.container.setPadding(totalpaddinginit,0,0,0);
            }
        }
        else
        if(position==mData.size()-1){
            holder.container.setPadding(0,0,totalpaddingfinal,0);
        } 
}

 public class ReviewHolder extends RecyclerView.ViewHolder {

    protected TextView tvName;
    View container;

    public ReviewHolder(View itemView) {
        super(itemView);
        container=itemView;
        tvName= (TextView) itemView.findViewById(R.id.text);
    }
}

这个逻辑非常通用,可以用于许多其他情况。在我的情况下,回收视图是水平的,并且没有边距(基本上,recyclerview的中心X坐标是屏幕的中心),也没有不均匀的填充。

如果有人遇到问题,请留言评论。


8
这里有一个更简单的方法,可以在快速滑动时平稳地滚动到特定位置:
@Override
public boolean fling(int velocityX, int velocityY) {

    smoothScrollToPosition(position);
    return super.fling(0, 0);
}

通过调用smoothScrollToPosition(int position)方法重写fling方法,其中“int position”为您想要在适配器中查看的视图的位置。您需要以某种方式获取位置的值,但这取决于您的需求和实现方式。


6

在研究了一段时间RecyclerView之后,这是我目前为止想出来并正在使用的。它有一个小缺陷,但我不会透露(因为你可能不会注意到)。

https://gist.github.com/lauw/fc84f7d04f8c54e56d56

它只支持水平RecyclerView,并且可以将视图捕捉到中心,并根据它们离中心的距离缩小视图的比例。可以用作RecyclerView的替代品。

编辑:2016年08月
已将其制作成存储库:
https://github.com/lauw/Android-SnappingRecyclerView
在开发更好的实现方法时,我会继续保留此版本。


这个解决方案是迄今为止最好的,谢谢!:D 另外,我找到了你的bean。 - Pkmmte
谢谢!SnappingRecyclerView 初次显示时会有一小段延迟。第一项从左侧开始,然后才会显示在中心位置。有什么解决方法吗? - wouter88
运行得非常好!希望实现方式改为使用RecyclerView提供的现代方法。 - Akshay Chordiya
@wouter88 你可以像这样从另一个位置开始: "mSnappingRecyclerView.getLayoutManager().scrollToPosition(YOUR_POSITION);" - Ali_Ai_Dev


5
实现吸附位置行为的一个非常简单的方法是 -
    recyclerView.setOnScrollListener(new OnScrollListener() {
        private boolean scrollingUp;

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            // Or use dx for horizontal scrolling
            scrollingUp = dy < 0;
        }

        @Override
        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            // Make sure scrolling has stopped before snapping
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                // layoutManager is the recyclerview's layout manager which you need to have reference in advance
                int visiblePosition = scrollingUp ? layoutManager.findFirstVisibleItemPosition()
                        : layoutManager.findLastVisibleItemPosition();
                int completelyVisiblePosition = scrollingUp ? layoutManager
                        .findFirstCompletelyVisibleItemPosition() : layoutManager
                        .findLastCompletelyVisibleItemPosition();
                // Check if we need to snap
                if (visiblePosition != completelyVisiblePosition) {
                    recyclerView.smoothScrollToPosition(visiblePosition);
                    return;
                }

        }
    });

唯一的小缺点是,当你滚动部分可见单元格不足半个屏幕时,它不会自动回弹。但如果这对你来说没有影响,那么这是一个干净简单的解决方案。

我认为这不会滚动到中心。它(smoothScrollToPosition)只是将视图带到可见区域。 - humblerookie

4

正如README的先前版本中所描述的那样,GravitySnapHelper库:

If you need snapping support to start, top, end or bottom, use GravitySnapHelper.

Snapping center:

SnapHelper snapHelper = new LinearSnapHelper();
snapHelper.attachToRecyclerView(recyclerView);

Snapping start with GravitySnapHelper:

startRecyclerView.setLayoutManager(new LinearLayoutManager(this,
                LinearLayoutManager.HORIZONTAL, false));

SnapHelper snapHelperStart = new GravitySnapHelper(Gravity.START);
snapHelperStart.attachToRecyclerView(startRecyclerView);

Snapping top with GravitySnapHelper:

topRecyclerView.setLayoutManager(new LinearLayoutManager(this));

SnapHelper snapHelperTop = new GravitySnapHelper(Gravity.TOP);
snapHelperTop.attachToRecyclerView(topRecyclerView);

3

我已经为RecyclerView实现了水平方向的工作解决方案,它只需要在第一次MOVE和UP时读取onTouchEvent的坐标。在UP时计算我们需要前往的位置。

public final class SnappyRecyclerView extends RecyclerView {

private Point   mStartMovePoint = new Point( 0, 0 );
private int     mStartMovePositionFirst = 0;
private int     mStartMovePositionSecond = 0;

public SnappyRecyclerView( Context context ) {
    super( context );
}

public SnappyRecyclerView( Context context, AttributeSet attrs ) {
    super( context, attrs );
}

public SnappyRecyclerView( Context context, AttributeSet attrs, int defStyle ) {
    super( context, attrs, defStyle );
}


@Override
public boolean onTouchEvent( MotionEvent e ) {

    final boolean ret = super.onTouchEvent( e );
    final LayoutManager lm = getLayoutManager();
    View childView = lm.getChildAt( 0 );
    View childViewSecond = lm.getChildAt( 1 );

    if( ( e.getAction() & MotionEvent.ACTION_MASK ) == MotionEvent.ACTION_MOVE
            && mStartMovePoint.x == 0) {

        mStartMovePoint.x = (int)e.getX();
        mStartMovePoint.y = (int)e.getY();
        mStartMovePositionFirst = lm.getPosition( childView );
        if( childViewSecond != null )
            mStartMovePositionSecond = lm.getPosition( childViewSecond );

    }// if MotionEvent.ACTION_MOVE

    if( ( e.getAction() & MotionEvent.ACTION_MASK ) == MotionEvent.ACTION_UP ){

        int currentX = (int)e.getX();
        int width = childView.getWidth();

        int xMovement = currentX - mStartMovePoint.x;
        // move back will be positive value
        final boolean moveBack = xMovement > 0;

        int calculatedPosition = mStartMovePositionFirst;
        if( moveBack && mStartMovePositionSecond > 0 )
            calculatedPosition = mStartMovePositionSecond;

        if( Math.abs( xMovement ) > ( width / 3 )  )
            calculatedPosition += moveBack ? -1 : 1;

        if( calculatedPosition >= getAdapter().getItemCount() )
            calculatedPosition = getAdapter().getItemCount() -1;

        if( calculatedPosition < 0 || getAdapter().getItemCount() == 0 )
            calculatedPosition = 0;

        mStartMovePoint.x           = 0;
        mStartMovePoint.y           = 0;
        mStartMovePositionFirst     = 0;
        mStartMovePositionSecond    = 0;

        smoothScrollToPosition( calculatedPosition );
    }// if MotionEvent.ACTION_UP

    return ret;
}}

对我来说运行良好,如果有什么问题请告诉我。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接