回收视图中的快速滚动

我正在尝试使用新的 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;
}
}

由于几个原因,我对这种方法不是很满意。首先,为了实现某种类型的滚动,必须对其进行子类化,这似乎与“回收视图”的哲学背道而驰。其次,如果我只想使用默认的 LinearLayoutManager,这就变得有些复杂,因为我必须搞清楚它的内部结构,以便理解它当前的滚动状态,并精确地计算出它滚动到哪里。最后,这甚至没有考虑到所有可能的滚动场景,如果你移动列表,然后暂停,然后举起一个手指,没有抛出事件发生(速度太低) ,所以列表保持在一半的位置。这可以通过向 RecyclerView添加一个在滚动状态的侦听器来解决,但是这也让人感觉非常古怪。

我觉得我肯定漏掉了什么,有更好的办法吗?

65576 次浏览

我最终想出了一些与上述略有不同的东西。这并不理想,但对我来说效果还可以接受,可能对其他人也有帮助。我不会接受这个答案,因为我希望其他人能带来一些更好的、不那么蹩脚的东西(我可能误解了 RecyclerView的实现,错过了一些简单的实现方法,但与此同时,这对于政府工作来说已经足够好了!)

实现的基本原理如下: RecyclerView中的滚动在 RecyclerViewLinearLayoutManager之间是分离的。我有两个案子要处理:

  1. 用户抛出视图。默认行为是 RecyclerView将抛出传递给内部 Scroller,然后由它执行滚动魔术。这是有问题的,因为然后 RecyclerView通常解决在一个不扣紧的立场。我通过覆盖 RecyclerView fling()实现来解决这个问题,并且不是抛出,而是平滑地滚动 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;
}


}

这里有一个更简单的方法,可以让你在一个放纵的事件中顺利地滚动到某个位置:

@Override
public boolean fling(int velocityX, int velocityY) {


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

通过调用 moothScrollToposition (int position)覆盖 fling 方法,其中“ int position”是您希望在适配器中看到的视图的位置。你需要以某种方式获得这个职位的价值,但这取决于你的需求和实现。

一个非常简单的实现快速定位行为的方法-

    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;
}


}
});

唯一的小缺点是,当你滚动不到部分可见单元格的一半时,它不会向后弹出——但是如果这不困扰你,那么这是一个干净简单的解决方案。

我找到了一个更干净的方法。@ Catherine (OP)让我知道这是否可以改进,或者你觉得比你的进步了:)

这是我使用的滚动侦听器。

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

这种逻辑非常通用,人们可以在许多其他情况下使用它。我的情况下,回收视图是水平的,并拉伸整个水平宽度没有边距(基本上回收视图的中心 X 坐标是屏幕的中心)或不均匀的填充。

如果有人正面临问题,请发表友好的评论。

在回收视图上浪费了一点时间之后,这就是我到目前为止想到的,也是我现在正在使用的。它有一个小缺陷,但我不会泄露(暂时) ,因为你可能不会注意到。

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

它只支持水平回收视图和对齐到中心,也可以缩小视图的距离,根据他们从中心。用作回收视图的替代。

编辑: 08/2016 把它变成了一个仓库:
Https://github.com/lauw/android-snappingrecyclerview
我将在开发更好的实现时继续这样做。

更新一下谦虚小子的回答:

这个滚动侦听器对于中心锁定确实有效 Https://github.com/humblerookie/centerlockrecyclerview/

但是这里有一个更简单的方法,在回收视图的开始和结束处添加填充,使其元素居中:

mRecycler.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
int childWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, CHILD_WIDTH_IN_DP, getResources().getDisplayMetrics());
int offset = (mRecycler.getWidth() - childWidth) / 2;


mRecycler.setPadding(offset, mRecycler.getPaddingTop(), offset, mRecycler.getPaddingBottom());


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
mRecycler.getViewTreeObserver().removeOnGlobalLayoutListener(this);
} else {
mRecycler.getViewTreeObserver().removeGlobalOnLayoutListener(this);
}
}
});

我已经实现了一个水平方向回收视图的工作解决方案,它只读取 TouchEvent、第一次移动和 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;
}}

我觉得挺好的,有什么问题就告诉我。

我需要一些与上述所有答案略有不同的东西。

主要要求是:

  1. 当用户甩手或者只是松开他的手指时,它的工作原理是一样的。
  2. 使用本机滚动机制来获得与常规 RecyclerView相同的“感觉”。
  3. 当它停止时,它开始平滑地滚动到最近的管理单元点。
  4. 无需使用自定义 LayoutManagerRecyclerView。只有一个 RecyclerView.OnScrollListener,然后由 recyclerView.addOnScrollListener(snapScrollListener)连接。这样代码就干净多了。

还有两个非常具体的要求,在下面的例子中应该很容易改变,以适应您的情况:

  1. 水平运动。
  2. 将项的左边缘快照到 RecyclerView中的特定点。

此解决方案使用本机 LinearSmoothScroller。区别在于,在最后一个步骤中,当找到“目标视图”时,它将更改偏移量的计算,以便将其捕获到特定位置。

public class SnapScrollListener extends RecyclerView.OnScrollListener {


private static final float MILLIS_PER_PIXEL = 200f;


/** The x coordinate of recycler view to which the items should be scrolled */
private final int snapX;


int prevState = RecyclerView.SCROLL_STATE_IDLE;
int currentState = RecyclerView.SCROLL_STATE_IDLE;


public SnapScrollListener(int snapX) {
this.snapX = snapX;
}


@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
currentState = newState;
if(prevState != RecyclerView.SCROLL_STATE_IDLE && currentState == RecyclerView.SCROLL_STATE_IDLE ){
performSnap(recyclerView);
}
prevState = currentState;


}


private void performSnap(RecyclerView recyclerView) {
for( int i = 0 ;i < recyclerView.getChildCount() ; i ++ ){
View child = recyclerView.getChildAt(i);
final int left = child.getLeft();
int right = child.getRight();
int halfWidth = (right - left) / 2;
if (left == snapX) return;
if (left - halfWidth <= snapX && left + halfWidth >= snapX) { //check if child is over the snapX position
int adapterPosition = recyclerView.getChildAdapterPosition(child);
int dx = snapX - left;
smoothScrollToPositionWithOffset(recyclerView, adapterPosition, dx);
return;
}
}
}


private void smoothScrollToPositionWithOffset(RecyclerView recyclerView, int adapterPosition, final int dx) {
final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
if( layoutManager instanceof LinearLayoutManager) {


LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) {
@Override
public PointF computeScrollVectorForPosition(int targetPosition) {
return ((LinearLayoutManager) layoutManager).computeScrollVectorForPosition(targetPosition);
}


@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
final int distance = (int) Math.sqrt(dx * dx + dy * dy);
final int time = calculateTimeForDeceleration(distance);
if (time > 0) {
action.update(-dx, -dy, time, mDecelerateInterpolator);
}
}


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


scroller.setTargetPosition(adapterPosition);
layoutManager.startSmoothScroll(scroller);


}
}

还有一种更干净的选择是使用定制的 LayoutManager, 你可以去查 Https://github.com/apptik/multiview/tree/master/layoutmanagers

它正在开发中,但运行良好。 这里有一张快照: Https://oss.sonatype.org/content/repositories/snapshots/io/apptik/multiview/layoutmanagers/

例如:

recyclerView.setLayoutManager(new SnapperLinearLayoutManager(getActivity()));

我还需要一个时髦的回收视角。我想让回收者视图项对齐到列的左侧。它最终实现了一个 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());

对于 LinearSnapHelper,现在这是非常容易的。

你要做的就是:

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

就是这么简单! 请注意,LinearSnapHelper是从24.2.0版本开始添加到支持库中的。

这意味着你必须将它添加到你的应用程序模块的 build.gradle

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

编辑: AndroidX LinearSnapHelper

如果您需要拍摄支持开始,顶部,结束或底部,使用 GravitySnapHelper (https://github.com/rubensousa/RecyclerViewSnap/blob/master/app/src/main/java/com/github/rubensousa/recyclerviewsnap/GravitySnapHelper.java)。

拍摄中心:

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

从 GravitySnapHelper 抓拍开始:

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


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

使用 GravitySnapHelper 拍摄顶部:

topRecyclerView.setLayoutManager(new LinearLayoutManager(this));


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