RecyclerView 源码学习:一步一步自定义LayoutManager

Posted 自在时刻

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了RecyclerView 源码学习:一步一步自定义LayoutManager相关的知识,希望对你有一定的参考价值。

前言

这篇文章实现了一个简单的LayoutManager,重点在于从原理方面一步一步去了解如何自定义一个LayoutManager。麻雀虽小,但五脏俱全。从自定义LayoutManager的layout布局,到scroll滑动,再到添加简单的动画效果。 其实,自定义一个LayoutManager也没那么难。

基本概念

Recycler

LayoutManafger调用 getViewForPosition 获取一个item,Recycler会决定是从缓存返回还是生成新的item。在自定义LayoutManger的时候,要保证不可见的视图被传递给Recycler。

Scrap 或 Recycler

Recycler是二级缓存,一个scrap heap 和一个 recyle pool, scrap 中的数据是正确的数据,比如我们快速上下滑动列表时,在边缘的栏目一会显示一会消失,所以会放在scrap中。 而已经消失并不使用的item,会被放在recyle中,其中的数据也是不正确的。

每次LayoutManager去请求一个视图调用getViewForPosition的时候,会先从scrap heap中找,存在直接返回。否则去recyle pool 中找一个视图,然后重新在adapter中绑定数据。最终如果还没有缓存,调用我们在adapter中重写的onCreateViewHolder,生成一个新的ViewHolder绑定数据并返回。

使用 detachAndScrapView 将视图放进scrap中去,使用removeAndRecycleView 将可能不会再用的视图放回recycler并且后续如果使用,还要进行rebind

小结

其实,上面这些都是废话,只要知道要获得一个view和用完一个view,都要通过recycler。常用的方法有getViewForPosition ,detachAndScrapView 和 removeAndRecycleView

自定义LayoutManager

generateDefaultLayoutParams

作用:控制每个item的layoutParams
为每一个childView设置的LayoutParams在这个方法中返回。很简单,一般我们都直接返回一个WrapContent的lp

初始布局 onLayoutChildren

这个方法会在一个view 第一次执行layout的时候调用,同时也会在adaper的数据集改变并通知观察者(也就是view)的时候调用。所以在其中每一次布局的时候,要先将之前放置的无用的View放回recycler中,因为这些View我们在后续还可能使用,为了减少初始化以及bind的时间,我们调用detachAndScrapAttachedViews。此外,对于不会再用到的View,可以调用removeAndRecycleView进行回收。

        if (getItemCount() == 0) 
            offset = 0;
            detachAndScrapAttachedViews(recycler);
            return;
        

这里自定义的LayoutManager比较简单,假定全部的item都是相同的大小。所以可以在一开始进行测绘:

        if (getChildCount() == 0) 
            View scrap = recycler.getViewForPosition(0);
            addView(scrap);
            measureChildWithMargins(scrap, 0, 0);
            mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
            mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
            startLeft = (getHorizontalSpace() - mDecoratedChildWidth) / 2;
            startTop = (getVerticalSpace() - mDecoratedChildHeight) / 2;
            interval = 10;
            detachAndScrapView(scrap, recycler);
        

这里注意getItemCount和getChildCount的区别:前者是adapter中添加的数据的数目,而后者是当前recyclerView中已经添加的子View的数目。所以上述代码的含义就是,如果没有添加过子View,那么从recycler中取出一个并完成测绘:

recycler.getViewForPosition(0);
            addView(scrap);

测绘完成后,再重新放回recycler中,调用

detachAndScrapView(scrap, recycler);

最后,再将之前添加的全部子View放回recycler中,因为一会还要使用,为了避免rebind,调用

        detachAndScrapAttachedViews(recycler);

然后就可以进行layoutChildren的过程了。

先来一个简单的,如下:

int left = 100, top = 0;
        for (int i = 0; i< getItemCount(); i++) 
            if (outOfRange(top)) continue;
            View scrap = recycler.getViewForPosition(i);
            measureChildWithMargins(scrap, 0, 0);
            addView(scrap);
            layoutDecorated(scrap, left, top, left + mDecoratedChildWidth, top + mDecoratedChildHeight);
            top += mDecoratedChildHeight + interval;
        

基本效果就是这样:

处理滑动 canScroll 和 scrollXXXBy

基本的布局有了之后,就可以处理滑动了。
RecyclerView是一个ViewGroup,如果要处理滑动事件,必然要进行拦截,分析其中的onInterceptTouchEvent方法:

关键代码如下:

        final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
        final boolean canScrollVertically = mLayout.canScrollVertically();
        ...
        case MotionEvent.ACTION_MOVE:
        ...
        if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) 
                        mLastTouchX = mInitialTouchX + mTouchSlop * (dx < 0 ? -1 : 1);
                        startScroll = true;
                    
       if (canScrollVertically && Math.abs(dy) > mTouchSlop) 
                        mLastTouchY = mInitialTouchY + mTouchSlop * (dy < 0 ? -1 : 1);
                        startScroll = true;
                    
                    ...
       if (startScroll)                       setScrollState(SCROLL_STATE_DRAGGING);
                    
                    ...
       return mScrollState == SCROLL_STATE_DRAGGING;

这里可以知道,如果要拦截某个方向的滑动事件,那么要在mLayout也就是LayoutManager中重写相应的canScrollxxx方法。
比如我们要允许竖直方向的滑动,直接重写如下:

    @Override
    public boolean canScrollVertically() 
        return true;
    

再来看一下事件拦截以后,在onTouchEvent中怎么处理的:

if (scrollByInternal(
                    canScrollHorizontally ? dx : 0,
                        canScrollVertically ? dy : 0,
                            vtev)) 
getParent().requestDisallowInterceptTouchEvent(true);
                    

代码很多,关键在于在ACTION_MOVE事件中调用了scrollByInternal方法,其中又有如下方法:

            if (x != 0) 
                consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
                unconsumedX = x - consumedX;
            
            if (y != 0) 
                consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
                unconsumedY = y - consumedY;
            

就是调用了LayoutManager中自定义的scrollxxxBy方法,并且传入Recycler供我们获取和回收View,以及相应的坐标x和y。

除此之外,要注意这个scrollxxxBy方法还有个返回值,这个返回值就是我们当前处理了的滑动坐标。如果这个值小于传入的坐标,表明我们已经滑动到了尽头,这么说可能有点抽象,举个例子:

   @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) 
        return 0;
    

返回0的时候,无论怎样都会小于传入的dy,看一下效果:

可以看到,我向下和向上滑动的时候,上边沿和下边沿都会出现一个动画效果,表明已经到头了!就是由于返回值是0的缘故。

分析到这里,基本可以确定如何添加滚动效果了,关键在两点:

  1. canScrollXXX中返回true
  2. 在onTouchEvent中scrollXXXBy方法不断被调用,在其中完成LayoutChildren不断对子View进行放置,从而形成动画效果。

为了完成第二个目的,我们需要在代码中添加一些额外的属性,主要就是每个item的偏移量,这样,在获得dy的时候,可以在每个item原有偏移量的基础上进行移动以及回收不需要的view。

首先,用一个全局变量 List offsetList 来存储每一个item的偏移量,并在onLayoutChildren中进行初始化:

        for (int i = 0; i < getItemCount(); i++) 
            offsetList.add(property);
            property += mDecoratedChildHeight + interval;
        

滑动方面的方法:

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) 
        int willScroll = dy;
        offset += willScroll;
        layoutItems(recycler, state, dy);
        return willScroll;
    

并且,原先对item的layout的过程也要进行一些修改:

    private void layoutItems(RecyclerView.Recycler recycler, RecyclerView.State state, int dy) 
        for (int i = 0; i < getChildCount(); i++) 
            View view = getChildAt(i);
            int pos = getPosition(view);
            if (outOfRange(offsetList.get(pos) - offset)) 
                removeAndRecycleView(view, recycler);
            
        
        detachAndScrapAttachedViews(recycler);
        int left = 100;
        for (int i = 0; i< getItemCount(); i++) 
            int top = offsetList.get(i);
            if (outOfRange(top - offset)) continue;
            View scrap = recycler.getViewForPosition(i);
            measureChildWithMargins(scrap, 0, 0);
            if (dy >= 0)
                addView(scrap);
            else
                addView(scrap, 0);
            layoutDecorated(scrap, left, top - offset, left + mDecoratedChildWidth, top - offset + mDecoratedChildHeight);
        
    

上述代码中,对每一个item记录了一下它的位置,然后滑动过程中offset+=dy,并且每次滑动后都出发LayoutItems方法,并且每个item在初始化y值的基础上减去offset,得到新的布局的位置。
到此为止,就有了滑动的动画效果:

缩放效果

经常有这样一种需求,当滑动列表的时候,列表中间部分某些item会呈现出放大之类的动画效果。其实,这种效果的实现其实就是通过item的属性动画。

实现的思路也比较简单,定一条基准线middle如下:

在每一个进行layout的时候计算每一个item的坐标,距离middle中线最近的那个我们给它放大,就实现了一个类似选中当前重点的效果。当然,具体的动画效果我们可以自己去计算选择。

新的layoutItems代码如下:

    private void layoutItems(RecyclerView.Recycler recycler, RecyclerView.State state, int dy) 
        for (int i = 0; i < getChildCount(); i++) 
            View view = getChildAt(i);
            int pos = getPosition(view);
            if (outOfRange(offsetList.get(pos) - offset)) 
                removeAndRecycleView(view, recycler);
            
        
        detachAndScrapAttachedViews(recycler);
        int left = 100;
        View selectedView = null;
        float maxScale = Float.MIN_VALUE;
        for (int i = 0; i< getItemCount(); i++) 
            int top = offsetList.get(i);
            if (outOfRange(top - offset)) continue;
            View scrap = recycler.getViewForPosition(i);
            measureChildWithMargins(scrap, 0, 0);
            if (dy >= 0)
                addView(scrap);
            else
                addView(scrap, 0);

            int deltaY = Math.abs(top - offset - middle);
            scrap.setScaleX(1);
            scrap.setScaleY(1);
            float scale = 1 + (mDecoratedChildHeight / (deltaY + 1));
            if (scale > maxScale) 
                maxScale = scale;
                selectedView = scrap;
            

            layoutDecorated(scrap, left, top - offset, left + mDecoratedChildWidth, top - offset + mDecoratedChildHeight);
        

        if (selectedView != null) 
            maxScale = maxScale > 2 ? 2 : maxScale;
            selectedView.setScaleX(maxScale);
            selectedView.setScaleY(maxScale);
        
    

最后可以得到下面这样一个比较粗糙的效果:

最后,来贴一下完整的代码吧:

package rouchuan.circlelayoutmanager;

import android.content.Context;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;

import java.util.ArrayList;
import java.util.List;

/**
 * Created by yangyang on 2017/3/13.
 */

public class SimpleLayoutManager extends RecyclerView.LayoutManager 
    private int mDecoratedChildWidth;
    private int mDecoratedChildHeight;
    private int interval;
    private int middle;
    private int offset;
    private List<Integer> offsetList;
    public SimpleLayoutManager(Context context) 
        offsetList = new ArrayList<>();
    
    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() 
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) 
        if (getItemCount() == 0) 
            offset = 0;
            detachAndScrapAttachedViews(recycler);
            return;
        

        //初始化的过程,还没有childView,先取出一个测绘。 认为每个item的大小是一样的
        if (getChildCount() == 0) 
            View scrap = recycler.getViewForPosition(0);
            addView(scrap);
            measureChildWithMargins(scrap, 0, 0);
            mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
            mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
            interval = 10;
            middle = (getVerticalSpace() - mDecoratedChildHeight) / 2;
            detachAndScrapView(scrap, recycler);
        
        //回收全部attach 的 view 到 recycler 并重新排列
        int property = 0;
        for (int i = 0; i < getItemCount(); i++) 
            offsetList.add(property);
            property += mDecoratedChildHeight + interval;
        
        detachAndScrapAttachedViews(recycler);
        layoutItems(recycler, state, 0);


    

    @Override
    public boolean canScrollVertically() 
        return true;
    

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) 
        int willScroll = dy;
        offset += willScroll;
        if (offset < 0 || offset > offsetList.get(offsetList.size() - 1)) return 0;
        layoutItems(recycler, state, dy);
        return willScroll;
    



    private void layoutItems(RecyclerView.Recycler recycler, RecyclerView.State state, int dy) 
        for (int i = 0; i < getChildCount(); i++) 
            View view = getChildAt(i);
            int pos = getPosition(view);
            if (outOfRange(offsetList.get(pos) - offset)) 
                removeAndRecycleView(view, recycler);
            
        
        detachAndScrapAttachedViews(recycler);
        int left = 100;
        View selectedView = null;
        float maxScale = Float.MIN_VALUE;
        for (int i = 0; i< getItemCount(); i++) 
            int top = offsetList.get(i);
            if (outOfRange(top - offset)) continue;
            View scrap = recycler.getViewForPosition(i);
            measureChildWithMargins(scrap, 0, 0);
            if (dy >= 0)
                addView(scrap);
            else
                addView(scrap, 0);

            int deltaY = Math.abs(top - offset - middle);
            scrap.setScaleX(1);
            scrap.setScaleY(1);
            float scale = 1 + (mDecoratedChildHeight / (deltaY + 1));
            if (scale > maxScale) 
                maxScale = scale;
                selectedView = scrap;
            

            layoutDecorated(scrap, left, top - offset, left + mDecoratedChildWidth, top - offset + mDecoratedChildHeight);
        

        if (selectedView != null) 
            maxScale = maxScale > 2 ? 2 : maxScale;
            selectedView.setScaleX(maxScale);
            selectedView.setScaleY(maxScale);
        
    


    private boolean outOfRange(float targetOffSet) 
        return targetOffSet > getVerticalSpace() + mDecoratedChildHeight ||
                targetOffSet < -mDecoratedChildHeight;
    

    private int getHorizontalSpace() 
        return getWidth() - getPaddingLeft() - getPaddingRight();
    

    private int getVerticalSpace() 
        return getHeight() - getPaddingTop() - getPaddingBottom();
    

以上是关于RecyclerView 源码学习:一步一步自定义LayoutManager的主要内容,如果未能解决你的问题,请参考以下文章

(原创)[C#] 一步一步自定义拖拽(Drag&Drop)时的鼠标效果:基本原理及基本实现

Android一步一步带你实现RecyclerView的拖拽和侧滑删除功能

一步一步学习IdentityServer4 处理特殊需求之-登录等待页面

Mybatis源码解析,一步一步从浅入深:映射代理类的获取

一步一步学习IdentityServer3

一步一步构建Spring5源码