百变RecyclerView之自定义LayoutManager

本篇记录如何通过自定义LayoutManager来实现带有视差效果的滚动列表,同时通过自定义SnapHelper让item拥有滚动停止在组件中心的特性。

效果图

其实这个效果在github上是有一个现成的库的,不过这个库并没有用到RecyclerView的recycle缓存,也就是说随着组件滚动,会一直调用Adapter的onCreateViewHolder,同样的也会一直创建新的ItemView,这算是一个不完善的地方吧。

本篇则记录如何通过自定义LayoutManager实现这种视差效果以及如何在LayoutManager中实现ItemView的复用。

自定义LayoutManager

首先我们看一下自定义LayoutManager所需要的几个步骤

  • 创建类继承自LayoutManager
  • 复写generateDefaultLayoutParams,确定item的默认布局
  • 复写onLayoutChildren,实现itemview的创建/布局/回收/复用
  • 复写canScrollVertically/scrollVerticallyBy,实现容器的纵向滚动
  • 实现ScrollVectorProvider接口,确定容器的滚动方向,配合LinearSmoothScroller和SnapHelper使用

创建一个SmartLayoutManager类继承自LayoutManager,复写generateDefaultLayoutParams方法。

public class SmartLayoutManager extends RecyclerView.LayoutManager {
    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
    }
}

自定义LayoutManager起步非常简单,这样就可以了,但是恰恰因为如此,造成RecyclerView留给我们发挥的空间太大,自定义LayoutManager需要自己实现的东西也就比较多。

然后复写onLayoutChildren方法,这个方法类似于自定义ViewGroup的layout方法,用于布局所有的子控件,只不过这个方法的实现需要在容器每次滚动的时候调用一次,同时也需要在这个方法中实现itemview的回收和复用。 这里需要用到几个方法 detachAndScrapAttachedViews 将清理容器中所有的子view removeAndRecycleView 将指定view移除并放入recycle缓存中 removeAndRecycleViewAt 将指定view移除并放入recycle缓存中 recycler.getViewForPosition 从recycle缓存中拿到指定position的缓存itemview,如果没有则调用onCreateViewHolder来进行创建

接下来复写onLayoutChildren方法来进行子view的布局

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        final View view = recycler.getViewForPosition(0);
        addView(view);
        measureChildWithMargins(view, 0, 0);
        mDecoratedChildWidth = getDecoratedMeasuredWidth(view);
        mDecoratedChildHeight = getDecoratedMeasuredHeight(view);
        removeAndRecycleView(view, recycler);
        detachAndScrapAttachedViews(recycler);
        fill(recycler, state);
    }
    private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
        //todo
    }

前面提到onLayoutChildren不单要在初始化的时候布局子view,同样在容器滚动的时候也要使用,所以这里把布局子view的代码放到一另个方法中,只在onLayoutChildren中留下一部分代码。 先看下这部分代码的作用,鉴于这个设计的使用情景,所有的itemview都是同样的尺寸,考虑到节省测量开销,对于itemview的尺寸这里只测量一次,作为所有itemview的尺寸,也就是说在初始化布局时,先把第一个view加入容器中,测量得到宽高后,再从容器中移除并加入recycle缓存。

接下来实现fill方法,这个方法是用来实现所有itemview的创建回收和布局的。

    private void fill(Recycler recycler, State state) {
        int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            removeAndRecycleViewAt(i, recycler);
        }
        int left = (getWidth() - getPaddingLeft() - getPaddingRight() - mDecoratedChildWidth) / 2;
        int top = (getHeight() - getPaddingBottom() - getPaddingTop() - mDecoratedChildHeight) / 2;
        for (int i = 0-3; i <=0+ 3; i++) {
            if (i < 0) {
                continue;
            }
            if (i > getItemCount() - 1) {
                break;
            }
            final View view = recycler.getViewForPosition(i);
            addView(view);
            measureChildWithMargins(view, 0, 0);
            layoutDecoratedWithMargins(view, left, top + i * mDecoratedChildHeight, left + mDecoratedChildWidth, top + i * mDecoratedChildHeight + mDecoratedChildHeight);
        }
    }

这个方法看上去也是很好理解的,首先移除并回收了容器中所有的itemview,接下来根据当前容器中心的item的position来向前后各推3个偏移,也就说在这个LayoutManger中,最多只会存在7个itemview,而这7个itemview通过recycle进行复用,Adapter自始至终只会创建7个viewholder。 当然这里的数量暂时是写死的,以后可以作为参数开放出去。

到目前为止,SmartLayoutManager只实现了itemview的静态布局,想要它支持滚动,仍需要复写两个个方法,这里已纵向滚动为例

    @Override
    public boolean canScrollVertically() {
        return true;
    }
    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        int newOffset = mScrollOffset + dy;
        newOffset = Math.max(0, Math.min(newOffset, (getItemCount() - 1) * mDecoratedChildHeight));
        dy = newOffset - mScrollOffset;
        mScrollOffset += dy;
        offsetChildrenVertical(-dy);
        fill(recycler, state);
        return dy;
    }

canScrollVertically通知LayoutManager可以纵向滚动,当存在纵向移动的手势时,就会回调scrollVerticallyBy方法。 scrollVerticallyBy方法将纵向手势偏移尺寸传入,经过一些处理后将新的偏移返回,这个处理可以是范围限制或阻尼处理等。 我们这个效果不用加阻尼,所以只通过Max和Min方法限制了滚动范围,以0和所有item高度之和作为滚动范围,算出合法的dy后返回,同时使用offsetChildrenVertical通知LayoutManager进行滚动。 这时候的RecyclerView其实已经能够滚动了,但仍然是固定视图的滚动,达不到设想中的视差效果,所以在这个方法的最后重新调用了一次fill方法,对当前界面进行重新调整。

当然fill方法需要根据在scrollVerticallyBy方法中更新到的滚动偏移量mScrollOffset来进行一些调整

    private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
        int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            removeAndRecycleViewAt(i, recycler);
        }
        float centerOffset = 1f * mScrollOffset / mDecoratedChildHeight;
        int centerPosition = Math.round(centerOffset);
        int left = (getWidth() - getPaddingLeft() - getPaddingRight()  - mDecoratedChildWidth) / 2;
        int top = (getHeight() - getPaddingBottom() - getPaddingTop()  - mDecoratedChildHeight) / 2;
        for (int i = centerPosition -3; i <= centerPosition + 3; i++) {
            if (i < 0) {
                continue;
            }
            if (i > getItemCount() - 1) {
                break;
            }
            final View view = recycler.getViewForPosition(i);
            addView(view);
            measureChildWithMargins(view, 0, 0);
            float offset = i - centerOffset;
            int itemPxFromCenter = getCardOffsetByPositionDiff(offset);//纯数学算法,处理为中间速度快,两端速度慢
            final float scale = (float) (2 * (2 * -StrictMath.atan(Math.abs(offset) + 1.0) / Math.PI + 1));
            final float translateYGeneral = mDecoratedChildHeight * (1 - scale) / 2f;
            float translateY = Math.signum(offset) * translateYGeneral;
            layoutDecoratedWithMargins(view, left, (int) (top + itemPxFromCenter + translateY), left + mDecoratedChildWidth, (int) (top + itemPxFromCenter + mDecoratedChildHeight + translateY));
            view.setScaleX(scale);
            view.setScaleY(scale);
            ViewCompat.setZ(view, 1 / (1 + Math.abs(offset)));
        }
    }

nice,现在的RecyclerView已经可以滚动了,而且有了非常赞的视差效果,但是还差一个东西,开篇的示例图中不难发现,每次滚动结束后都会有一个item停在屏幕的最中心,而且手动拖拽释放后也会有离得最近的item弹回到屏幕中间。

自定义SmartHelper

一般情况下这种效果会通过监听Touch事件并计算速度来进处理fling手势,但是RecyclerView提供了更简便的方式来处理这个情景,只需要实现一个SmartHelper即可。

public class SmartSnapHelper extends SnapHelper {
    @Nullable
    @Override
    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
        return new int[0];
    }

    @Nullable
    @Override
    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
        return null;
    }

    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
        return 0;
    }
}

findTargetSnapPosition用来根据速度计算fling手势结束后RecyclerView的目标position findSnapView返回当前状态下组件中心的view calculateDistanceToFinalSnap返回当前状态下组件滚到到目标item所需要的偏移量

public class SmartSnapHelper extends SnapHelper {
    @Nullable
    @Override
    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
        int[] ints = new int[2];
        if (layoutManager instanceof SmartLayoutManager) {
            SmartLayoutManager lm = (SmartLayoutManager) layoutManager;
            int offsetForCurrentView = lm.getOffsetForCurrentView(targetView);
            ints[1] = -offsetForCurrentView;
        }
        return ints;
    }
    @Nullable
    @Override
    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager instanceof SmartLayoutManager) {
            SmartLayoutManager lm = (SmartLayoutManager) layoutManager;
            return lm.getCenterChildView();
        }
        return null;
    }
    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
        if (layoutManager instanceof SmartLayoutManager) {
            SmartLayoutManager lm = (SmartLayoutManager) layoutManager;
            if ((float) velocityY == 0) {
                return 0;
            }
            int sign = (float) velocityY > 0 ? 1 : -1;
            float mTriggerOffset = 0.25f;
            int i = (int) (sign * Math.ceil(((float) velocityY * sign * 0.2 / lm.mDecoratedChildHeight) - mTriggerOffset));
            return lm.centerPosition() + i;
        }
        return 0;
    }
}

使用方式也很简单

new SmartSnapHelper().attachToRecyclerView(recyclerView);

只要加上这个,LayoutManager在滚动的时候就会自动根据SmartSnapHelper的逻辑来进行计算,滚动停止时就会停在组件中间位置。

完整代码地址:https://github.com/NightFarmer/SmartLayoutManager

关于SnapHelper,google其实提供了一个叫做LinearSnapHelper的类,这个类配合LinearLayoutManager就能实现简单的item吸附效果,用起来非常方便。

文章目录
,