Android 使用RecyclerView实现ViewPager效果

简介

开发中经常会遇到需要做一个页菜单效果,类似淘宝、京东之类的app的左右滑动分页菜单效果,这种效果虽然可以用ViewPager去做但是总觉得不够优雅。今天来介绍一个使用RecyclerView的实现方式,这种方式会好用很多,因为横向的功能列表往往是根据配置来的,可能还有排序。

效果图(1)如下:


device-2023-10-16-154426.gif

问题分析

1.RecyclerView并没有提供类似的布局的LayoutManager,这里我们需要一个排列效果为“每8个item为一个Page,每个page分为2行4列”,排列应该是下面这样的,这里我们需要自定义LayoutManager。效果图(2)


1698023081871.jpg

2.RecyclerView提供了辅助类SnapHelper,用于辅助RecyclerView在滚动结束时将Item对齐到某个位置。SnapHelper是一个抽象类,官方提供了LinearSnapHelper和PagerSnapHelper两个子类,但是这2个辅助类都是拿屏幕中间的item进行居中对齐,显然这2个辅助类我们也用不了,所以这里需要自定义SnapHolper来实现ViewPager翻页的效果。

拿来主义者

如果你不想了解实现过程,只想拿来使用,https://github.com/844646669/ViewPageRecyclerView 这个GitHub地址,下载下来后,直接coyp CustomLayoutManager和CustomSnapHelper这两个类,使用方法如下:

recyclerView.setLayoutManager(new CustomLayoutManager(2,4));
recyclerView.setAdapter(new Adapter2());
new CustomSnapHelper().attachToRecyclerView(recyclerView);

自定义LayoutManager实现特殊的布局效果

创建一个CustomLayoutManager类,继承RecyclerView.LayoutManager,会强制重写generateDefaultLayoutParams方法,这个方法是给RecyclerView子View添加默认LayoutParams的,如果RecyclerView.Adapter的onCreateViewHolder方法中创建的view没有LayoutParams,那么添加到RecyclerView中就会调用这个方法为其添加默认的LayoutParams对象。

public class CustomLayoutManager extends RecyclerView.LayoutManager {
    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return null;
    }
}

一般来说,没有什么特殊需求的话,可以直接让子item自己决定自己的宽高

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

到这里,这个CustomLayoutManager已经可以使用了,只是运行后会发现界面是空白的,这是因为我们没有布局任何item。所以我们需要再重写onLayoutChildren方法。这个方法就是用于给每个item布局的,讲大白话就是为RecyclerView添加Item并控制每个item的位置,这个方法有2个参数,recycler参数是用于View的回收和再利用的,state参数是用于判断状态的,这里我们不需要用到。

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    super.onLayoutChildren(recycler, state);
}

在学习onLayoutChildren之前,我们需要先了解一下几个常用的方法:

1、getItemCount()

这个是RecyclerView.LayoutManager自带的一个方法,用与获取当前列表总共有多少个Item,该方法最终调用Adapter中的getItemCount方法获取item个数。

2、getViewForPosition(int position)

这个是recycler的一个方法,可以根据索引获取对应的Item。

3、measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed)

这个是RecyclerView.LayoutManager自带的一个方法,用于测量子Item(child)的,需要注意的是后面的widthUsed和heightUsed,这2个参数是宽度和高度的使用量不是剩余量,这里很容易忽略。

4、getDecoratedMeasuredWidth(@NonNull View child) 和 getDecoratedMeasuredHeight(@NonNull View child)

这个是RecyclerView.LayoutManager自带的一个方法,在measureChildWithMargins方法测量之后获取Item测量的结果

5、layoutDecorated(@NonNull View child, int left, int top, int right, int bottom)

这个是RecyclerView.LayoutManager自带的一个方法,用于将Item布局到对应的位置

常用方法看完了,回到上面onLayoutChildren中,这里我们需要将子Item按照 ”效果图(2)“中那样把每个item添加到RecyclerView中去:

int line = 2; //每一页line行
int col = 4; //每一行 col列
//每个item对应的位置信息
private List<Rect> rectFList = new ArrayList<>();
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    int itemCount = getItemCount(); //获取item数量
    if (itemCount <= 0) {
        return;
    }
    View fistView = recycler.getViewForPosition(0);//获取第一个item
    int width = getWidth();//获取RecyclerView的宽度
    //这里考虑到RecyclerView可能有内边距,所以实际可用宽度应该是减去左右内边距
    int canUseWidth = width - getPaddingLeft() - getPaddingRight();
    /**
     * measureChildWithMargins方法的后两个参数是宽度和高度的使用量,
     * 这里每一行是有4列,所以宽度的使用量应该是3/4 canUseWidth
     */
    int usedWidth = Math.round(canUseWidth * ((col - 1f)/col));
    int usedHeight = 0;//高度不受限制
    //测量第一个Item,这里只测量第一个item,是默认所有的Item大小都是一样的
    measureChildWithMargins(fistView,usedWidth, usedHeight);
    //获取测量后Item的宽高
    int measureWidth = getDecoratedMeasuredWidth(fistView);
    int measureHeight = getDecoratedMeasuredHeight(fistView);
    int colWidth = Math.round((canUseWidth + 0f) / col); //计算出每一列的宽度

    //计算所有Item的位置情况
    rectFList.clear();
    for (int i = 0; i < itemCount; i++) {
        int tarPage = getTarPage(i); //获取第i个Item对应的页码(1、2、3)
        int tarLine = getTarLine(i); //获取对应的行码 (0、1、2、3)
        int tarCol = getTarCol(i); //获取对应的列码(0、1、2、3)
        //计算第tarPage页的偏移量 (每页宽度 * 页码) +  左边内边距
        int xOffset = canUseWidth * (tarPage - 1) + getPaddingLeft();
        //计算 第 i个Item的 上下左右位置
        int left = colWidth * tarCol + xOffset;
        int top = measureHeight * tarLine;
        int right = left + colWidth;
        int button = top + measureHeight;
        Rect rect = new Rect(left, top,right, button);
        rectFList.add(rect);
    }

    //将所有的Item添加到RecyclerView中并设置对应的位置
    for (int i = 0; i < itemCount; i++) {
        Rect rect = rectFList.get(i);
        View view = recycler.getViewForPosition(i);
        measureChildWithMargins(view,usedWidth, usedHeight);
        int viewWidth = getDecoratedMeasuredWidth(view);
        int viewHeight = getDecoratedMeasuredHeight(view);
        addView(view);//添加到RecyclerView中去
        //设置对应Item在RecyclerView中的位置
        layoutDecorated(view,rect.left,rect.top,rect.left + viewWidth, rect.top + viewHeight);
    }
}

/**
 * 获取第position个Item在其所在Page对应的行数(0、1、... (line -1))
 * @param position
 * @return
 */
private int getTarLine(int position) {
    int line = (int) Math.ceil((position + 1f) / this.col);
    line = (line - 1)%this.line;
    return (int) line;
}

/**
 * 获取第position个Item在其所在Page对应的列数(0、1、... (col -1))
 * @param position
 * @return
 */
private int getTarCol(int position) {
    int col = (int) Math.ceil((position) % this.col);
    return col;
}

/**
 * 获取第position个Item在所在的page(1、2、3、4.。。)
 * @param position
 * @return
 */
public int getTarPage(int position) {
    int onePageCount = line * col;
    return (int) Math.ceil((position + 1f) / onePageCount);
}

/**
 * 这个返回true可以自动根据所有Item的宽高,重新测试RecyclerView的宽高
 * @return
 */
@Override
public boolean isAutoMeasureEnabled() {
    return true;
}

到这里,RecyclerView已经按我们的要求把每个Item都排列好了,效果如下,但是还不能左右滑动。


1697530920190.jpg

需要给CustomLayoutManager添加左右滑动支持。需要重写canScrollHorizontally 和 scrollHorizontallyBy方法.

1、boolean canScrollHorizontally() 和 boolean canScrollVertically()

这2个方法返回横向和纵向是否支持滑动,返回true则支持,false不支持

2、scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state)和scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state)

这2个方法就是用来控制滑动逻辑,第一个参数就是滑动距离

这里我们的控件只需要支持左右滑动,所以只需要重写canScrollHorizontally 和 scrollHorizontallyBy方法。

canScrollHorizontally方法很简单,只需要返回true就可以了

@Override
public boolean canScrollHorizontally() {
    return true;
}

scrollHorizontallyBy也不难,第一个参数是本次滑动距离,我们只需要根据做大滑动值和最小滑动值计算出本次真实的滑动值,并调用offsetChildrenHorizontal方法执行滑动就可以了。

int mXOffset = 0;
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
    int itemCount = getItemCount(); //获取item数量
    if (itemCount == 0) return 0;
    //获取最后一个item对应的page值就等于获取到总的page数量
    int pageCount = getTarPage(itemCount - 1);
    int width = getWidth();//获取RecyclerView的宽度
    //这里考虑到RecyclerView可能有内边距,所以实际可用宽度应该是减去左右内边距
    int canUseWidth = width - getPaddingLeft() - getPaddingRight();
    int minOffset = 0;//最小滑动值是0
    //每页宽度 * (页面数量 - 1) 得到最大滑动距离 (只有1页的时候是不能滑动的)
    int maxOffset = canUseWidth * (pageCount - 1);
    int realDx = dx; //真实滑动距离
    if (mXOffset + dx < minOffset) { //如果滑动距离小于最小值,则修正真实滑动距离
        realDx = 0 - mXOffset;
    }
    if (mXOffset + dx > maxOffset) { //如果滑动距离小于最大值,则修正真实滑动距离
        realDx = maxOffset - mXOffset;
    }
    //执行滑动操作
    offsetChildrenHorizontal(-realDx);
    //计算新的mXOffset
    mXOffset = mXOffset + realDx;

    //返回值是本次滑动的真实距离,RecyclerView是支持嵌套滚动的,这里涉及到嵌套滚动的内容就不做赘述了
    return realDx;
}

到这里,我们的列表就可以正常滑动了,但是这样是有缺陷的,就是我们一开始就把所有的Item都添加到RecyclerView中去,而同一时间界面上只会显示其中的部分Item,大部分Item都是不可见的,如果Item数量特别多的时候就会创建很多个Item导致不必要的内存占用如如果Item可以回收和再利用的话,每次只添加一小部分的Iitem到RecyclerView中去,这样可以达到性能优化的目的。

RecyclerView的回收复用

其实RecyclerView内部已经为我们实现了回收复用所需要的所有条件,但需要在LayoutManager中控制控制每个Item是否可以继续使用或者是把它放到回收池中去。同时获取Item时也可以先去回收池中查找是否已经创建了对应类型的Item,如果有,则可以直接使用不需要再重新创建。很明显,我们上面的代码中,我们一开始就把所有的Item都布局到RecyclerView中去,滑动时也只是简单的进行内容滑动,并没有对移出屏幕的Item做回收处理。更别说对Item的复用了。

RecyclerView的回收池

RecyclerView默认提供了mCachedViews和mRecyclerPool两级缓存,用来保存这些需要回收的Item,这两个缓存的区别是:mCachedViews是一级缓存,它的size是2,只能保存两个Item,这里保存的始终是最新被移除的Item,当mCachedViews满了之后,按照先进先出的原则把老的Item存放到mRecyclerPool中去,mRecyclerPool默认size是5.这就是RecyclerVeiw默认提供的二级缓存,除此之外,还预留了mViewCacheExtension一个扩展回收池,不过一般不会用到。使用removeAndRecycleView(@NonNull View child, @NonNull Recycler recycler)方法可以将Item从RecyclerView界面面上剥离下来并存放到回收池中,具体存放在哪个回收池按照上面规则用又recycler对象自行处理。

Detach和Scrap

除了回收池,还有另一种场景,就是当列表数据发生变动时(例如调用Adapter的notifyDataSetChanged()方法),会回调LayoutManager的onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)方法,我们需要对列表进行重新布局,这种情况下需要对先对当前界面上的所有Item剥离下来再按照新的顺序布局到列表上。这个时候需要用到detachAndScrapAttachedViews(@NonNull Recycler recycler)方法,它的作用是将当前界面上的所有Item全部剥离下来,然后保存在一个叫mAttachedScrap的列表中,以供重新布局的时候使用,所以在自定义的LayoutManager的onLayoutChildren方法中,经常可以看到第一件事就是先调用detachAndScrapAttachedViews方法,原因就在这里。

到这里,我们总共有mAttachedScrap、mCachedViews、mRecyclerPool、mViewCacheExtension 4个可以存放Item的地方,其中mAttachedScrap比较特殊,需要调用detachAndScrapAttachedViews方法才能添加进去,其他3个调用removeAndRecycleView方法可以添加进去。

Item复用

上面讲到4个保存Item的地方,那么要怎么从这些缓存中取出Item进行复用呢?我们一般使用recycler.getViewForPosition(int position)方法获取,它会先在mAttachedScrap列表中查找,看看有没有刚刚被剥离的Item,如果有就直接返回,如果没有,那就去mCachedViews缓存中查找,如果有就直接返回,如果还是没有,那就去mRecyclerPool中查找,如果有就直接返回,如果没有,那就去mViewCacheExtension 中查找,如果都没有,最后才会去调用Adapter的onCreateViewHolder方法新建一个。需要注意的是mAttachedScrap和mCachedViews中的Item都是精准匹配,所以都是直接使用,不会调用Adapter的onBindViewHolder重新绑定数据,mRecyclerPool和mViewCacheExtension 都是根据ViewType来匹配的,所以会调用Adapter的onBindViewHolder重新绑定数据。

实现支持Item回收复用的LayoutManager

回到我们的CustomLayoutManager中来,上边的onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state)方法都需要修改。

先看onLayoutChildren方法,我们需要先将界面上所有的Item都剥离下来

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    detachAndScrapAttachedViews(recycler); //把Item从界面上剥离下来
    ...(之前的内容)
}

如图所示,根据当前偏移量,计算当前屏幕左右两边对应的X坐标值:

pageStartX = mXOffset + paddingLeft

pageEndX = pageStartX + canUseWidth


1697614336031.jpg

这里需要缓存屏幕左右两边各一个屏幕大小的缓冲区(后面实现ViewPager吸附效果需要用到),所以实际上需要添加Item的区域应该是:pageStartX - canUserWidth 到 pageEndX + canUserWidth 这个区域。

//计算当前界面上显示对应的区域
int pageStartX = mXOffset + getPaddingLeft();
int pageEndX = pageStartX + canUseWidth;
//缓存当前界面相邻一个page范围
pageStartX = pageStartX - canUseWidth;
pageEndX = pageEndX + canUseWidth;

遍历所有item,判断该item是否在区域内,如果在,则添加到界面上去。

 //将所有的Item添加到RecyclerView中并设置对应的位置
for (int i = 0; i < itemCount; i++) {
    Rect rect = rectFList.get(i);
    //判断Item是否处于pageStartX到pageEndX区间内,如果是就把对应的Item添加到RecyclerView中去
    if (rect.right > pageStartX && rect.left < pageEndX) {
        View view = recycler.getViewForPosition(i); //回收池中获取Item
        measureChildWithMargins(view, usedWidth, usedHeight);
        int viewWidth = getDecoratedMeasuredWidth(view);
        int viewHeight = getDecoratedMeasuredHeight(view);
        addView(view);//添加到RecyclerView中去
        //这里位置是相对于屏幕的位置,所以需要减去偏移量才是当前Item的位置
        int left = rect.left - mXOffset - getPaddingLeft(); 
        int top = rect.top;
        int right = left + viewWidth;
        int button = top + viewHeight;
        Log.d("ccm" + i,String.format("left %d, right %d", left,right));
        layoutDecorated(view, left, top, right, button);
    }
}

所以onLayoutChildren方法的最终代码应该是这样的:

int line = 2; //每一页line行
int col = 4; //每一行 col列
//每个item对应的位置信息
private List<Rect> rectFList = new ArrayList<>();
int mXOffset = 0; //当前滑动偏移量(scrollHorizontallyBy方法中记录的偏移量)
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    detachAndScrapAttachedViews(recycler); //把Item从界面上剥离下来
    int itemCount = getItemCount(); //获取item数量
    if (itemCount <= 0) {
        return;
    }
    View fistView = recycler.getViewForPosition(0);//获取第一个item
    int width = getWidth();//获取RecyclerView的宽度
    //这里考虑到RecyclerView可能有内边距,所以实际可用宽度应该是减去左右内边距
    int canUseWidth = width - getPaddingLeft() - getPaddingRight();
    /**
     * measureChildWithMargins方法的后两个参数是宽度和高度的使用量,
     * 这里每一行是有4列,所以宽度的使用量应该是3/4 canUseWidth
     */
    int usedWidth = Math.round(canUseWidth * ((col - 1f)/col));
    int usedHeight = 0;//高度不受限制
    //测量第一个Item,这里只测量第一个item,是默认所有的Item大小都是一样的
    measureChildWithMargins(fistView,usedWidth, usedHeight);
    //获取测量后Item的宽高
    int measureWidth = getDecoratedMeasuredWidth(fistView);
    int measureHeight = getDecoratedMeasuredHeight(fistView);
    int colWidth = Math.round((canUseWidth + 0f) / col); //计算出每一列的宽度

    //计算所有Item的位置情况
    rectFList.clear();
    for (int i = 0; i < itemCount; i++) {
        int tarPage = getTarPage(i); //获取第i个Item对应的页码(1、2、3)
        int tarLine = getTarLine(i); //获取对应的行码 (0、1、2、3)
        int tarCol = getTarCol(i); //获取对应的列码(0、1、2、3)
        //计算第tarPage页的偏移量 (每页宽度 * 页码) +  左边内边距
        int xOffset = canUseWidth * (tarPage - 1) + getPaddingLeft();
        //计算 第 i个Item的 上下左右位置
        int left = colWidth * tarCol + xOffset;
        int top = measureHeight * tarLine;
        int right = left + colWidth;
        int button = top + measureHeight;
        Rect rect = new Rect(left, top,right, button);
        rectFList.add(rect);
    }

    //计算当前界面上显示对应的区域
    int pageStartX = mXOffset + getPaddingLeft();
    int pageEndX = pageStartX + canUseWidth;
    //缓存当前界面相邻一个page范围
    pageStartX = pageStartX - canUseWidth;
    pageEndX = pageEndX + canUseWidth;


    //将所有的Item添加到RecyclerView中并设置对应的位置
    for (int i = 0; i < itemCount; i++) {
        Rect rect = rectFList.get(i);
        //判断Item是否处于pageStartX到pageEndX区间内,如果是就把对应的Item添加到RecyclerView中去
        if (rect.right > pageStartX && rect.left < pageEndX) {
            View view = recycler.getViewForPosition(i); //回收池中获取Item
            measureChildWithMargins(view, usedWidth, usedHeight);
            int viewWidth = getDecoratedMeasuredWidth(view);
            int viewHeight = getDecoratedMeasuredHeight(view);
            addView(view);//添加到RecyclerView中去
            //设置对应Item在RecyclerView中的位置
            int left = rect.left - mXOffset - getPaddingLeft(); //这里需要减去偏移量才是当前Item的位置
            int top = rect.top;
            int right = left + viewWidth;
            int button = top + viewHeight;
            Log.d("ccm" + i,String.format("left %d, right %d", left,right));
            layoutDecorated(view, left, top, right, button);
        }
    }


}

scrollHorizontallyBy方法也需要修改,同样根据当前偏移量计算出当前屏幕左右对应的x值,然后根据realDx计算出滑动后屏幕左右对应的x值:

//根据偏移量计算当前显示区域
int pageStartX = mXOffset + getPaddingLeft();
int pageEndX = pageStartX + canUseWidth;
//根据realDx计算移动后的显示区域
pageStartX += realDx;
pageEndX += realDx;
//缓存界面相邻一个page范围
pageStartX -= canUseWidth;
pageEndX += canUseWidth;

获取当前界面上所有Item,判断是否不在区域内,如果不在,需要剥离下来并添加到回收池中:

//遍历已添加到RecyclerView上的Item,判断是否处于pageStartX到pageEndX范围内,如果不是
//给他移除掉添加到回收池中去
int childCount = getChildCount();//添加到界面上的Item数量
List<View> needRemoveView = new ArrayList<>(2);
for (int i = 0; i < childCount; i++) {
    View item = getChildAt(i);
    int position = getPosition(item);
    Rect rect = rectFList.get(position);
    if (!(rect.right > pageStartX && rect.left < pageEndX)) {
        //不处于pageStartX到pageEndX范围内的Item需要移除
        needRemoveView.add(item);
    }
}
for (View item : needRemoveView) {
    removeAndRecycleView(item, recycler); //移除掉
}

遍历所有Item,判断是否在区域内,如果在区域内,判断是否已经添加到界面上,如果没有,添加到界面上

//添加Item
int usedWidth = Math.round(canUseWidth * ((col - 1f)/col));
int usedHeight = 0;//高度不受限制
for (int i = 0; i < itemCount; i++) {
    Rect rect = rectFList.get(i);
    //判断Item是否处于pageStartX到pageEndX区间内,如果是就把对应的Item添加到RecyclerView中去
    if (rect.right > pageStartX && rect.left < pageEndX) {
        View view = null;
        //查找该Item是否已经在界面上了
        view = findViewByPosition(i);
        if (view == null) { //界面上查找不到,说明需要添加
            view = recycler.getViewForPosition(i); //回收池中获取Item
            measureChildWithMargins(view, usedWidth, usedHeight);
            int viewWidth = getDecoratedMeasuredWidth(view);
            int viewHeight = getDecoratedMeasuredHeight(view);
            addView(view);//添加到RecyclerView中去
            //设置对应Item在RecyclerView中的位置
            int left = rect.left - mXOffset - getPaddingLeft(); //这里需要减去偏移量才是当前Item的位置
            int top = rect.top;
            int right = left + viewWidth;
            int button = top + viewHeight;
            layoutDecorated(view, left, top, right, button);
        }
    }
}

所有scrollHorizontallyBy方法的最终代码如下:

@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
    int itemCount = getItemCount(); //获取item数量
    if (itemCount == 0) return 0;
    //获取最后一个item对应的page值就等于获取到总的page数量
    int pageCount = getTarPage(itemCount - 1);
    int width = getWidth();//获取RecyclerView的宽度
    //这里考虑到RecyclerView可能有内边距,所以实际可用宽度应该是减去左右内边距
    int canUseWidth = width - getPaddingLeft() - getPaddingRight();
    int minOffset = 0;//最小滑动值是0
    //每页宽度 * (页面数量 - 1) 得到最大滑动距离 (只有1页的时候是不能滑动的)
    int maxOffset = canUseWidth * (pageCount - 1);
    int realDx = dx; //真实滑动距离
    if (mXOffset + dx < minOffset) { //如果滑动距离小于最小值,则修正真实滑动距离
        realDx = 0 - mXOffset;
    }
    if (mXOffset + dx > maxOffset) { //如果滑动距离小于最大值,则修正真实滑动距离
        realDx = maxOffset - mXOffset;
    }

    //根据偏移量计算当前显示区域
    int pageStartX = mXOffset + getPaddingLeft();
    int pageEndX = pageStartX + canUseWidth;
    //根据realDx计算移动后的显示区域
    pageStartX += realDx;
    pageEndX += realDx;
    //缓存界面相邻一个page范围
    pageStartX -= canUseWidth;
    pageEndX += canUseWidth;

    //遍历已添加到RecyclerView上的Item,判断是否处于pageStartX到pageEndX范围内,如果不是
    //给他移除掉添加到回收池中去
    int childCount = getChildCount();//添加到界面上的Item数量
    List<View> needRemoveView = new ArrayList<>(2);
    for (int i = 0; i < childCount; i++) {
        View item = getChildAt(i);
        int position = getPosition(item);
        Rect rect = rectFList.get(position);
        if (!(rect.right > pageStartX && rect.left < pageEndX)) {
            //不处于pageStartX到pageEndX范围内的Item需要移除
            needRemoveView.add(item);
        }
    }
    for (View item : needRemoveView) {
        removeAndRecycleView(item, recycler); //移除掉
    }

    //添加Item
    int usedWidth = Math.round(canUseWidth * ((col - 1f)/col));
    int usedHeight = 0;//高度不受限制
    for (int i = 0; i < itemCount; i++) {
        Rect rect = rectFList.get(i);
        //判断Item是否处于pageStartX到pageEndX区间内,如果是就把对应的Item添加到RecyclerView中去
        if (rect.right > pageStartX && rect.left < pageEndX) {
            View view = null;
            //查找该Item是否已经在界面上了
            view = findViewByPosition(i);
            if (view == null) { //界面上查找不到,说明需要添加
                view = recycler.getViewForPosition(i); //回收池中获取Item
                measureChildWithMargins(view, usedWidth, usedHeight);
                int viewWidth = getDecoratedMeasuredWidth(view);
                int viewHeight = getDecoratedMeasuredHeight(view);
                addView(view);//添加到RecyclerView中去
                //设置对应Item在RecyclerView中的位置
                int left = rect.left - mXOffset - getPaddingLeft(); //这里需要减去偏移量才是当前Item的位置
                int top = rect.top;
                int right = left + viewWidth;
                int button = top + viewHeight;
                layoutDecorated(view, left, top, right, button);
            }
        }
    }

    //计算新的mXOffset
    mXOffset = mXOffset + realDx;
    //执行滑动操作
    offsetChildrenHorizontal(-realDx);


    //返回值是本次滑动的真实距离,RecyclerView是支持嵌套滚动的,这里涉及到嵌套滚动的内容就不做赘述了
    return realDx;
}

到这里,我们的CustomLayoutManager基本上就写完了,效果如下:


device-2023-10-19-085944.gif

自定义SnapHelper实现ViewPage翻页效果

SnapHelper是RecyclerView的一个辅助类,用于辅助RecyclerView控制滚动结束时将Item进行对齐。继承SnapHelper类需要强制重写3个方法:

public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY)

这个方法会在触发Fling时被调用(Fling是指:手指离开屏幕后的惯性滚动),需要根据速度计算出需要滚动到具体的Item,并将该Item对应的position返回。

public View findSnapView(RecyclerView.LayoutManager layoutManager)

这个方法会在SnapHelper关联RecyclerView和Fling结束后被调用,需要根据LayoutManager当前的情况,计算出需要对齐的Item,并返回。

public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView)

当findSnapView方法返回值不为null时,这个方法会被调用,需要根据参数targetView计算出当前界面到对齐targetView需要滚动的距离,返回值是一个长度为2的数组,其中int[0]代表需要滚动的X轴距离,int[1]代表需要滚动的Y轴距离。

SnapHelper使用

SnapHelper使用很简单,只需要通过attachToRecyclerView方法关联RecyclerView即可

new CustomSnapHelper().attachToRecyclerView(recyclerView);

SnapHelper源码解析

先来看看关联方法attachToRecyclerView(recyclerView)

public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException {
    if (mRecyclerView == recyclerView) { 
        return; // nothing to do
    }
    if (mRecyclerView != null) {
        destroyCallbacks(); //移除之前的所有回调
    }
    mRecyclerView = recyclerView;
    if (mRecyclerView != null) {
        setupCallbacks();//设置新的回调
        //创建Scroller
        mGravityScroller = new Scroller(mRecyclerView.getContext(),
                new DecelerateInterpolator());
        //这个方法会进行一个对齐
        snapToTargetExistingView();
    }
}

可以看到attachToRecyclerView(@Nullable RecyclerView recyclerView) 方法会先移除掉所有的回调,然后添加新的回调,同时进行一次Item对齐。

先看一下snapToTargetExistingView()方法是怎么进行对齐的:

void snapToTargetExistingView() {
    if (mRecyclerView == null) {
        return;
    }
    RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
    if (layoutManager == null) {
        return;
    }
    View snapView = findSnapView(layoutManager); //通过findSnapView方法找到对齐的Item
    if (snapView == null) {
        return;
    }
    //通过calculateDistanceToFinalSnap方法计算对齐需要滚动距离
    int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView); 
    if (snapDistance[0] != 0 || snapDistance[1] != 0) {
        //调用recyclerView的smoothScrollBy方法实现滚动
        mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
    }
}

snapToTargetExistingView()方法非常简单,先是通过调用findSnapView(layoutManager)方法找到需要对齐的Item,如果找到,再调用calculateDistanceToFinalSnap(layoutManager, snapView)方法计算出需要滚动的距离,然后调用recyclerView的smoothScrollBy()方法实现对齐。这也就是上面说到findSnapView(layoutManager)方法会在关联RecyclerView的时候调用一次的原因,同时当findSnapView(layoutManager)方法返回不为null时calculateDistanceToFinalSnap(layoutManager, snapView)方法会被调用。

接下来看看setupCallbacks()destroyCallbacks()方法,设置和删除回调:

private void setupCallbacks() throws IllegalStateException {
    if (mRecyclerView.getOnFlingListener() != null) {
        throw new IllegalStateException("An instance of OnFlingListener already set.");
    }
    mRecyclerView.addOnScrollListener(mScrollListener);
    mRecyclerView.setOnFlingListener(this);
}


private void destroyCallbacks() {
    mRecyclerView.removeOnScrollListener(mScrollListener);
    mRecyclerView.setOnFlingListener(null);
}

可以看到这里给recyclerView添加了OnScrollListener和OnFlingListner两个回调。其中mScrollListener是一个匿名内部类,代码如下:

private final RecyclerView.OnScrollListener mScrollListener = new RecyclerView.OnScrollListener() {
    boolean mScrolled = false;

    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        super.onScrollStateChanged(recyclerView, newState);
        //newState == RecyclerView.SCROLL_STATE_IDLE表示RecyclerView已经停止滚动
        if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) {
            mScrolled = false;
            snapToTargetExistingView();
        }
    }

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        if (dx != 0 || dy != 0) {
            mScrolled = true;
        }
    }
};

mScrollListener通过mScrolled判断RecyclerView之前是否滚动过,并通过newState == RecyclerView.SCROLL_STATE_IDLE判断当前RecyclerView是否时停滚动状态,也就是说,当RecyclerView滚动到停下来的时候触发,调用snapToTargetExistingView()方法进行一次对齐,也就是说mScrollListener的作用就是在停止滚动的时候进行一次对齐,由此我们可以得到第二个结论:每次滚动结束,我们写的SnapHelper中findSnapView(RecyclerView.LayoutManager layoutManager)calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView)方法会被调用。

再来看看OnFlingListener回调,这个回调对象时SnapHelper自己mRecyclerView.setOnFlingListener(this);,直接看回调方法onFling(int velocityX, int velocityY):

@Override
public boolean onFling(int velocityX, int velocityY) {
    RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
    if (layoutManager == null) {
        return false;
    }
    RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
    if (adapter == null) {
        return false;
    }
    int minFlingVelocity = mRecyclerView.getMinFlingVelocity(); //获取触发Filing的最下速度
    return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
            && snapFromFling(layoutManager, velocityX, velocityY);//调用snapFromFling
}

调用了snapFromFling()方法:

private boolean snapFromFling(@NonNull RecyclerView.LayoutManager layoutManager, int velocityX,
        int velocityY) {
    //判断layoutManager是否实现了ScrollVectorProvider接口
    if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
        return false;
    }
    //创建SmoothScroller对象
    RecyclerView.SmoothScroller smoothScroller = createScroller(layoutManager);
    if (smoothScroller == null) {
        return false;
    }
    //调用findTargetSnapPosition查找需要滚动到的目标Posiont
    int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
    if (targetPosition == RecyclerView.NO_POSITION) {
        return false;
    }
    //将targetPosition设置到smoothScroller中
    smoothScroller.setTargetPosition(targetPosition);
    //开始滚动
    layoutManager.startSmoothScroll(smoothScroller);
    return true;
}

snapFromFling方法做了4件事,1:判断layoutManager是否实现了ScrollVectorProvider;2:创建SmoothScroller对象;3:调用findTargetSnapPosition返回查找到本次Fling需要滚动到第几个Item;4:调用layoutManager.startSmoothScroll()方法开始滚动。而layoutManager.startSmoothScroll()方法最终也是调用smoothScroller.start()方法把滚动交给SmoothScroller对象完成。

到这里,我们已经知道SnapHelper的大概原理了,总结一下就是:SnapHelper在关联RecyclerView的时候会进行一次对齐操作,同时通过添加OnScrollListener和OnFlingListener两个监听实现辅助对齐的,其中OnScrollListener用于在滚动结束后进行辅助对齐,OnFlingListner在触发FLing时辅助惯性滑动,具体的弹性滚动则由SmoothScroller类处理,同时要实现Fling辅助控制LayoutManager必须实现ScrollVectorProvider接口

讲到这里,我们只剩下最后几个问题:

1.LayoutManager为什么要实现ScrollVectorProvider接口,ScrollVectorProvider是干什么用的?

2.前边写CustomLayoutManager的时候提到,要缓存当前界面左右一个屏幕大小的Item,为什么要这么做?

3.翻页时如何控制翻页速度?

这几个问题都涉及到滚动的具体细节,由于篇幅问题,我这里只讲关键代码,如果想看具体源码分析,可以给我留言,我找个时间写一下。前边提到开始Fling滚动是通过调用layoutManager.startSmoothScroll(smoothScroller)实现的,这个方法会调用mSmoothScroller.start(mRecyclerView, this);在start方法中会获取需要滚动到的ItemmTargetView = findViewByPosition(getTargetPosition())

void start(RecyclerView recyclerView, LayoutManager layoutManager) {
    ...其他内容
    mRunning = true;
    mPendingInitialRun = true;
    //就是这句,getTargetPosition()就是前边findTargetSnapPosition()返回的那个值
    mTargetView = findViewByPosition(getTargetPosition());
    mRecyclerView.mViewFlinger.postOnAnimation();
    mStarted = true;
}

下面的mRecyclerView.mViewFlinger.postOnAnimation()是一个异步操作,最终会回调到SmoothScroller.onAnimation(int dx, int dy)方法:

void onAnimation(int dx, int dy) {
    ...其他内容
    //当mTargetView == null时会进入到这里
    if (mPendingInitialRun && mTargetView == null && mLayoutManager != null) {
        //这个方法中会判断layoutManager是否实现了ScrollVectorProvider接口,并调用computeScrollVectorForPosition方法
        PointF pointF = computeScrollVectorForPosition(mTargetPosition);
        if (pointF != null && (pointF.x != 0 || pointF.y != 0)) {
            recyclerView.scrollStep(
                    (int) Math.signum(pointF.x),
                    (int) Math.signum(pointF.y),
                    null);
        }
    }

    mPendingInitialRun = false;

    
    if (mTargetView != null) {
        // verify target position
        if (getChildPosition(mTargetView) == mTargetPosition) {
            //如果mTargetView不为null,调用onTargetFound方法
            onTargetFound(mTargetView, recyclerView.mState, mRecyclingAction);
            mRecyclingAction.runIfNecessary(recyclerView);
            stop();
        } else {
            Log.e(TAG, "Passed over target position while smooth scrolling.");
            mTargetView = null;
        }
    }
   ...其他内容
}

现在可以回答第一个问题了,ScrollVectorProvider接口是当mTargetView为null时用的,如果返回的pointF.x等于0则不需要滚动,如果大于0则向友滚动,小于0则向左滚动。并且这个滚动时匀速的。直到mTargetView不为null。所以我们的CustomLayoutManager如果要支持Filing那就必须实现ScrollVectorProvider接口,需要实现方法public PointF computeScrollVectorForPosition(int targetPosition)

@Nullable
@Override
public PointF computeScrollVectorForPosition(int targetPosition) {
    if (getChildCount() == 0) {
        return null;
    }
    Rect rect = rectFList.get(targetPosition);
    int offset = rect.left - mXOffset;
    PointF pointF = new PointF(offset, 0);
    return pointF;
}

第二个问题,LayoutManager中为啥要缓存当前界面左右各一个屏幕距离的Item?前面提到mTargetView是通过findViewByPosition()方法获取的,这个方法只会获取到已经添加到界面中的Item,如果没获取到,那么列表将已匀速滚动,直到对应Item被添加到界面中才调用onTargetFound()改为弹性滚动。我们要做的是ViewPager的翻页效果,显然不想匀速滚动,所以我这里选择了缓冲屏幕左右两边一个屏幕大小的Item。

第三各问题,翻页时如何控制翻页速度?前面代码中snapFromFling方法中是通过调用createScroller(layoutManager)方法获取RecyclerView.SmoothScroller对象的,这里我们只需要重写这方法即可:

@Nullable
@Override
protected RecyclerView.SmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
    return !(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider) ? null : new LinearSmoothScroller(mRecyclerView.getContext()) {
        //还记得这个方法吗,前边onAnimation方法中,如果mTargetView不为null,调用的就是这个方法
        protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
            //计算滚动到targetView需要滚动的距离
            int[] snapDistances =                        calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),targetView);
            int dx = snapDistances[0];
            int dy = snapDistances[1];
            int time = this.calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
            if (time > 0) {
                //开始弹性滚动
                action.update(dx, dy, time, this.mDecelerateInterpolator);
            }

        }
        //这个方法返回的是每Px距离需要滑动的时间
        protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
            return 100.0F / (float)displayMetrics.densityDpi;
        }
        //这个返回的是dx距离需要滚动的时间
        protected int calculateTimeForScrolling(int dx) {
            //我们是viewPager效果,翻页速度要比较块,这里加了个现在,最大时间是100毫秒
            return Math.min(100, super.calculateTimeForScrolling(dx));
        }
    };
}

接下来我们可以开始写我们的CustomSnapHelper类了。(这里需要为CustomLayoutManager添加几个辅助方法供CustomSnapHelper用),这里我直接把思路注释在代码里面了_

public class CustomSnapHelper extends SnapHelper {
    private RecyclerView mRecyclerView;
    @Override
    public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException {
        this.mRecyclerView = recyclerView;
        super.attachToRecyclerView(recyclerView);
    }

    /**
     * 计算滚动到targetView需要滚动的距离
     * @param layoutManager
     * @param targetView
     * @return
     */
    @Nullable
    @Override
    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
        Log.d("ccm", "calculateDistanceToFinalSnap");
        if (layoutManager instanceof CustomLayoutManager) {
            CustomLayoutManager customLayoutManager = (CustomLayoutManager) layoutManager;
            int position = customLayoutManager.getPosition(targetView); //得到targetView对应的position
            PointF pointF = customLayoutManager.computeScrollForPosition(position); //计算需要滚动距离
            int[] a = new int[2];
            a[0] = Math.round(pointF.x);
            a[1] = Math.round(pointF.y);
            return a;
        } else {
            int[] a = new int[2];
            a[0] = 0;
            a[1] = 0;
            Log.d("ccm", "calculateDistanceToFinalSnap");
            return a;
        }
    }

    /**
     * 计算出当前页面需要对齐的Item
     * @param layoutManager
     * @return
     */
    @Nullable
    @Override
    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager instanceof CustomLayoutManager) {
            int childCount = layoutManager.getChildCount();
            if (childCount <= 0) return null;
            //获取当前屏幕上最小的Item
            int minItem = 9999;
            for (int i = 0; i < childCount; i++) {
                View child = layoutManager.getChildAt(i);
                int decoratedRight = layoutManager.getDecoratedRight(child);
                int decoratedLeft = layoutManager.getDecoratedLeft(child);
                if (decoratedRight > layoutManager.getPaddingLeft() && decoratedLeft < layoutManager.getWidth() - layoutManager.getPaddingRight()) {
                    int position = layoutManager.getPosition(child);
                    minItem = Math.min(minItem, position);
                }
            }


            if (minItem == 9999) return null;
            CustomLayoutManager customLayoutManager = (CustomLayoutManager) layoutManager;
            int tarPage = customLayoutManager.getTarPage(minItem);//计算出minItem对应的page
            int maxPage = customLayoutManager.getMaxPage(); //获取总的page数量
            int pageFistPosition = customLayoutManager.getPageFistPosition(tarPage); //获取当前page的第一个Item
            if (tarPage == maxPage) { //如果当前pege已经是最后一页了,返回当前page的第一个Item
                return customLayoutManager.findViewByPosition(pageFistPosition);
            }
            int nextPageFistPosition = customLayoutManager.getPageFistPosition(tarPage + 1); //获取下一个page的第一个Item
            //得到当前page的第一个Item和下一个page的第一个Item 对应的位子数据
            Rect lastRect = customLayoutManager.getRectByPosition(pageFistPosition);
            if (lastRect == null) return null;
            Rect thisRect = customLayoutManager.getRectByPosition(nextPageFistPosition);
            if (lastRect == null) return null;

            int xOffset = customLayoutManager.getxOffset(); //拿到偏移量
            if ((xOffset - lastRect.left) > (thisRect.left - xOffset)) { //哪个page距离屏幕边缘近,滚动到该page
                return customLayoutManager.findViewByPosition(nextPageFistPosition);
            } else {
                return customLayoutManager.findViewByPosition(pageFistPosition);
            }
        }
        return null;
    }

    /**
     * 根据速度(velocityX)计算需要滚动到哪个Item
     * @param layoutManager
     * @param velocityX
     * @param velocityY
     * @return 目标Item对应的position
     */
    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
        Log.d("ccm", "findTargetSnapPosition");
        if (layoutManager instanceof CustomLayoutManager) {
//            Log.d("ccm","findTargetSnapPosition");
            int childCount = layoutManager.getChildCount();
            if (childCount <= 0) return RecyclerView.NO_POSITION;
            //获取当前屏幕上最小的Item
            int minItem = 9999;
            for (int i = 0 ; i < childCount; i++) {
                View child = layoutManager.getChildAt(i);
                int decoratedRight = layoutManager.getDecoratedRight(child);
                int decoratedLeft = layoutManager.getDecoratedLeft(child);
                if (decoratedRight > layoutManager.getPaddingLeft() && decoratedLeft < layoutManager.getWidth() - layoutManager.getPaddingRight()) {
                    int position = layoutManager.getPosition(child);
                    minItem = Math.min(minItem, position);
                }
            }
            if (minItem == 9999) return RecyclerView.NO_POSITION;

            CustomLayoutManager customLayoutManager = (CustomLayoutManager) layoutManager;
            int tarPage = customLayoutManager.getTarPage(minItem); //计算出minItem对应的page
            int maxPage = customLayoutManager.getMaxPage(); //获取总的page数量
            if (velocityX > 0) {//左滑
                if (tarPage < maxPage) {
                    //未到最后一页,滚动到下一页的第一个Item
                    return customLayoutManager.getPageFistPosition(tarPage + 1);
                } else {
                    //已经到最后一页了,滚动到当前页的第一个Item
                    return customLayoutManager.getPageFistPosition(maxPage);
                }
            } else {
                if (tarPage <= 1) {
                    //未到第一页,滚动到上一个页的第一个Item
                    return customLayoutManager.getPageFistPosition(1);
                } else {
                    //已经到第一页了,滚动到第一页的第一个Iitem
                    return customLayoutManager.getPageFistPosition(tarPage);
                }
            }
        } else {
            return RecyclerView.NO_POSITION;
        }
    }
    @Nullable
    @Override
    protected RecyclerView.SmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
        return !(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider) ? null : new LinearSmoothScroller(mRecyclerView.getContext()) {
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
                int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(), targetView);
                int dx = snapDistances[0];
                int dy = snapDistances[1];
                int time = this.calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
                if (time > 0) {
                    action.update(dx, dy, time, this.mDecelerateInterpolator);
                }

            }

            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return 25.0f / (float)displayMetrics.densityDpi;
            }

            protected int calculateTimeForScrolling(int dx) {
                return Math.min(100, super.calculateTimeForScrolling(dx));
            }

        };
    }
}

/** -------------------------CustomLayoutManager中需要添加几个辅助方法------------**/
public class CustomLayoutManager extends RecyclerView.LayoutManager implements RecyclerView.SmoothScroller.ScrollVectorProvider{

    /**
     * 计算滚动到targetPosition需要滚动的距离
     * @param targetPosition
     * @return
     */
    public PointF computeScrollForPosition(int targetPosition) {
        if (getChildCount() == 0) {
            return null;
        }

        Rect rect = rectFList.get(targetPosition);
        int offset = rect.left - mXOffset;
        PointF pointF = new PointF(offset, 0);
        return pointF;
    }

    /**
     * 获取最多页数
     * @return
     */
    public int getMaxPage() {
        int onePageCount = line * col;
        return (int) Math.ceil(getItemCount()/(onePageCount + 0f));
    }


    /**
     * 获取第page页第一个item的index
     * @param page
     * @return
     */
    public int getPageFistPosition(int page) {
        return line * col * (page - 1);
    }


    /**
     * 根据position获取对应的Rect
     * @param position
     * @return
     */
    public Rect getRectByPosition(int position) {
        if (rectFList == null || rectFList.size() <= position) return null;
        return rectFList.get(position);
    }
}

使用CustomLayoutManager和customSnapHelper,实现ViewPager效果

recyclerView.setLayoutManager(new CustomLayoutManager(2,4));
new CustomSnapHelper().attachToRecyclerView(recyclerView);
recyclerView.setAdapter(new Adapter2());

最后,CustomLayoutManager和CustomSnapHelper的完整代码如下:

/** ---------------CustomLayoutManager--------------------**/

public class CustomLayoutManager extends RecyclerView.LayoutManager implements RecyclerView.SmoothScroller.ScrollVectorProvider{

    public CustomLayoutManager(int line, int col) {
        this.line = line;
        this.col = col;
    }

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {

        return new RecyclerView.LayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.WRAP_CONTENT);
    }

    /**
     * 这个返回true可以自动根据所有Item的宽高,重新测试RecyclerView的宽高
     * @return
     */
    @Override
    public boolean isAutoMeasureEnabled() {
        return true;
    }


    int line = 2; //每一页line行
    int col = 4; //每一行 col列
    //每个item对应的位置信息
    private List<Rect> rectFList = new ArrayList<>();
    int mXOffset = 0; //当前滑动偏移量(scrollHorizontallyBy方法中记录的偏移量)
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        detachAndScrapAttachedViews(recycler); //把Item从界面上剥离下来
        int itemCount = getItemCount(); //获取item数量
        if (itemCount <= 0) {
            return;
        }
        View fistView = recycler.getViewForPosition(0);//获取第一个item
        int width = getWidth();//获取RecyclerView的宽度
        //这里考虑到RecyclerView可能有内边距,所以实际可用宽度应该是减去左右内边距
        int canUseWidth = width - getPaddingLeft() - getPaddingRight();
        /**
         * measureChildWithMargins方法的后两个参数是宽度和高度的使用量,
         * 这里每一行是有4列,所以宽度的使用量应该是3/4 canUseWidth
         */
        int usedWidth = Math.round(canUseWidth * ((col - 1f)/col));
        int usedHeight = 0;//高度不受限制
        //测量第一个Item,这里只测量第一个item,是默认所有的Item大小都是一样的
        measureChildWithMargins(fistView,usedWidth, usedHeight);
        //获取测量后Item的宽高
        int measureWidth = getDecoratedMeasuredWidth(fistView);
        int measureHeight = getDecoratedMeasuredHeight(fistView);
        int colWidth = Math.round((canUseWidth + 0f) / col); //计算出每一列的宽度

        //计算所有Item的位置情况
        rectFList.clear();
        for (int i = 0; i < itemCount; i++) {
            int tarPage = getTarPage(i); //获取第i个Item对应的页码(1、2、3)
            int tarLine = getTarLine(i); //获取对应的行码 (0、1、2、3)
            int tarCol = getTarCol(i); //获取对应的列码(0、1、2、3)
            //计算第tarPage页的偏移量 (每页宽度 * 页码) +  左边内边距
            int xOffset = canUseWidth * (tarPage - 1) + getPaddingLeft();
            //计算 第 i个Item的 上下左右位置
            int left = colWidth * tarCol + xOffset;
            int top = measureHeight * tarLine;
            int right = left + colWidth;
            int button = top + measureHeight;
            Rect rect = new Rect(left, top,right, button);
            rectFList.add(rect);
        }

        //计算当前界面上显示对应的区域
        int pageStartX = mXOffset + getPaddingLeft();
        int pageEndX = pageStartX + canUseWidth;
        //缓存当前界面相邻一个page范围
        pageStartX = pageStartX - canUseWidth;
        pageEndX = pageEndX + canUseWidth;


        //将所有的Item添加到RecyclerView中并设置对应的位置
        for (int i = 0; i < itemCount; i++) {
            Rect rect = rectFList.get(i);
            //判断Item是否处于pageStartX到pageEndX区间内,如果是就把对应的Item添加到RecyclerView中去
            if (rect.right > pageStartX && rect.left < pageEndX) {
                View view = recycler.getViewForPosition(i); //回收池中获取Item
                measureChildWithMargins(view, usedWidth, usedHeight);
                int viewWidth = getDecoratedMeasuredWidth(view);
                int viewHeight = getDecoratedMeasuredHeight(view);
                addView(view);//添加到RecyclerView中去
                //设置对应Item在RecyclerView中的位置
                int left = rect.left - mXOffset - getPaddingLeft(); //这里需要减去偏移量才是当前Item的位置
                int top = rect.top;
                int right = left + viewWidth;
                int button = top + viewHeight;
                Log.d("ccm" + i,String.format("left %d, right %d", left,right));
                layoutDecorated(view, left, top, right, button);
            }
        }


    }

    /**
     * 获取第position个Item在其所在Page对应的行数(0、1、... (line -1))
     * @param position
     * @return
     */
    private int getTarLine(int position) {
        int line = (int) Math.ceil((position + 1f) / this.col);
        line = (line - 1)%this.line;
        return (int) line;
    }

    /**
     * 获取第position个Item在其所在Page对应的列数(0、1、... (col -1))
     * @param position
     * @return
     */
    private int getTarCol(int position) {
        int col = (int) Math.ceil((position) % this.col);
        return col;
    }

    /**
     * 获取第position个Item在所在的page(1、2、3、4.。。)
     * @param position
     * @return
     */
    public int getTarPage(int position) {
        int onePageCount = line * col;
        return (int) Math.ceil((position + 1f) / onePageCount);
    }


    /**
     * 需要支持左右滑动,所以这里需要返回true
     * @return
     */
    @Override
    public boolean canScrollHorizontally() {
        return true;
    }



    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        int itemCount = getItemCount(); //获取item数量
        if (itemCount == 0) return 0;
        //获取最后一个item对应的page值就等于获取到总的page数量
        int pageCount = getTarPage(itemCount - 1);
        int width = getWidth();//获取RecyclerView的宽度
        //这里考虑到RecyclerView可能有内边距,所以实际可用宽度应该是减去左右内边距
        int canUseWidth = width - getPaddingLeft() - getPaddingRight();
        int minOffset = 0;//最小滑动值是0
        //每页宽度 * (页面数量 - 1) 得到最大滑动距离 (只有1页的时候是不能滑动的)
        int maxOffset = canUseWidth * (pageCount - 1);
        int realDx = dx; //真实滑动距离
        if (mXOffset + dx < minOffset) { //如果滑动距离小于最小值,则修正真实滑动距离
            realDx = 0 - mXOffset;
        }
        if (mXOffset + dx > maxOffset) { //如果滑动距离小于最大值,则修正真实滑动距离
            realDx = maxOffset - mXOffset;
        }



        //根据偏移量计算当前显示区域
        int pageStartX = mXOffset + getPaddingLeft();
        int pageEndX = pageStartX + canUseWidth;
        //根据realDx计算移动后的显示区域
        pageStartX += realDx;
        pageEndX += realDx;
        //缓存界面相邻一个page范围
        pageStartX -= canUseWidth;
        pageEndX += canUseWidth;

        //遍历已添加到RecyclerView上的Item,判断是否处于pageStartX到pageEndX范围内,如果不是
        //给他移除掉添加到回收池中去
        int childCount = getChildCount();//添加到界面上的Item数量
        List<View> needRemoveView = new ArrayList<>(2);
        for (int i = 0; i < childCount; i++) {
            View item = getChildAt(i);
            int position = getPosition(item);
            Rect rect = rectFList.get(position);
            if (!(rect.right > pageStartX && rect.left < pageEndX)) {
                //不处于pageStartX到pageEndX范围内的Item需要移除
                needRemoveView.add(item);
            }
        }
        for (View item : needRemoveView) {
            removeAndRecycleView(item, recycler); //移除掉
        }

        //添加Item
        int usedWidth = Math.round(canUseWidth * ((col - 1f)/col));
        int usedHeight = 0;//高度不受限制
        for (int i = 0; i < itemCount; i++) {
            Rect rect = rectFList.get(i);
            //判断Item是否处于pageStartX到pageEndX区间内,如果是就把对应的Item添加到RecyclerView中去
            if (rect.right > pageStartX && rect.left < pageEndX) {
                View view = null;
                //查找该Item是否已经在界面上了
                view = findViewByPosition(i);
                if (view == null) { //界面上查找不到,说明需要添加
                    view = recycler.getViewForPosition(i); //回收池中获取Item
                    measureChildWithMargins(view, usedWidth, usedHeight);
                    int viewWidth = getDecoratedMeasuredWidth(view);
                    int viewHeight = getDecoratedMeasuredHeight(view);
                    addView(view);//添加到RecyclerView中去
                    //设置对应Item在RecyclerView中的位置
                    int left = rect.left - mXOffset - getPaddingLeft(); //这里需要减去偏移量才是当前Item的位置
                    int top = rect.top;
                    int right = left + viewWidth;
                    int button = top + viewHeight;
                    layoutDecorated(view, left, top, right, button);
                }
            }
        }

        //计算新的mXOffset
        mXOffset = mXOffset + realDx;
        //执行滑动操作
        offsetChildrenHorizontal(-realDx);


        //返回值是本次滑动的真实距离,RecyclerView是支持嵌套滚动的,这里涉及到嵌套滚动的内容就不做赘述了
        return realDx;
    }


    @Nullable
    @Override
    public PointF computeScrollVectorForPosition(int targetPosition) {
        if (getChildCount() == 0) {
            return null;
        }
        Rect rect = rectFList.get(targetPosition);
        int offset = rect.left - mXOffset;
        PointF pointF = new PointF(offset, 0);
        return pointF;
    }

    /**
     * 计算滚动到targetPosition需要滚动的距离
     * @param targetPosition
     * @return
     */
    public PointF computeScrollForPosition(int targetPosition) {
        if (getChildCount() == 0) {
            return null;
        }

        Rect rect = rectFList.get(targetPosition);
        int offset = rect.left - mXOffset;
        PointF pointF = new PointF(offset, 0);
        return pointF;
    }

    /**
     * 获取最多页数
     * @return
     */
    public int getMaxPage() {
        int onePageCount = line * col;
        return (int) Math.ceil(getItemCount()/(onePageCount + 0f));
    }


    /**
     * 获取第page页第一个item的index
     * @param page
     * @return
     */
    public int getPageFistPosition(int page) {
        return line * col * (page - 1);
    }


    /**
     * 根据position获取对应的Rect
     * @param position
     * @return
     */
    public Rect getRectByPosition(int position) {
        if (rectFList == null || rectFList.size() <= position) return null;
        return rectFList.get(position);
    }




    public int getxOffset() {
        return mXOffset;
    }
}


/** --------------------------------------CustomSnapHelper--------------**/


public class CustomSnapHelper extends SnapHelper {
    private RecyclerView mRecyclerView;
    @Override
    public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException {
        this.mRecyclerView = recyclerView;
        super.attachToRecyclerView(recyclerView);
    }

    /**
     * 计算滚动到targetView需要滚动的距离
     * @param layoutManager
     * @param targetView
     * @return
     */
    @Nullable
    @Override
    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
        Log.d("ccm", "calculateDistanceToFinalSnap");
        if (layoutManager instanceof CustomLayoutManager) {
            CustomLayoutManager customLayoutManager = (CustomLayoutManager) layoutManager;
            int position = customLayoutManager.getPosition(targetView); //得到targetView对应的position
            PointF pointF = customLayoutManager.computeScrollForPosition(position); //计算需要滚动距离
            int[] a = new int[2];
            a[0] = Math.round(pointF.x);
            a[1] = Math.round(pointF.y);
            return a;
        } else {
            int[] a = new int[2];
            a[0] = 0;
            a[1] = 0;
            Log.d("ccm", "calculateDistanceToFinalSnap");
            return a;
        }
    }

    /**
     * 计算出当前页面需要对齐的Item
     * @param layoutManager
     * @return
     */
    @Nullable
    @Override
    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager instanceof CustomLayoutManager) {
            int childCount = layoutManager.getChildCount();
            if (childCount <= 0) return null;
            //获取当前屏幕上最小的Item
            int minItem = 9999;
            for (int i = 0; i < childCount; i++) {
                View child = layoutManager.getChildAt(i);
                int decoratedRight = layoutManager.getDecoratedRight(child);
                int decoratedLeft = layoutManager.getDecoratedLeft(child);
                if (decoratedRight > layoutManager.getPaddingLeft() && decoratedLeft < layoutManager.getWidth() - layoutManager.getPaddingRight()) {
                    int position = layoutManager.getPosition(child);
                    minItem = Math.min(minItem, position);
                }
            }


            if (minItem == 9999) return null;
            CustomLayoutManager customLayoutManager = (CustomLayoutManager) layoutManager;
            int tarPage = customLayoutManager.getTarPage(minItem);//计算出minItem对应的page
            int maxPage = customLayoutManager.getMaxPage(); //获取总的page数量
            int pageFistPosition = customLayoutManager.getPageFistPosition(tarPage); //获取当前page的第一个Item
            if (tarPage == maxPage) { //如果当前pege已经是最后一页了,返回当前page的第一个Item
                return customLayoutManager.findViewByPosition(pageFistPosition);
            }
            int nextPageFistPosition = customLayoutManager.getPageFistPosition(tarPage + 1); //获取下一个page的第一个Item
            //得到当前page的第一个Item和下一个page的第一个Item 对应的位子数据
            Rect lastRect = customLayoutManager.getRectByPosition(pageFistPosition);
            if (lastRect == null) return null;
            Rect thisRect = customLayoutManager.getRectByPosition(nextPageFistPosition);
            if (lastRect == null) return null;

            int xOffset = customLayoutManager.getxOffset(); //拿到偏移量
            if ((xOffset - lastRect.left) > (thisRect.left - xOffset)) { //哪个page距离屏幕边缘近,滚动到该page
                return customLayoutManager.findViewByPosition(nextPageFistPosition);
            } else {
                return customLayoutManager.findViewByPosition(pageFistPosition);
            }
        }
        return null;
    }

    /**
     * 根据速度(velocityX)计算需要滚动到哪个Item
     * @param layoutManager
     * @param velocityX
     * @param velocityY
     * @return 目标Item对应的position
     */
    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
        Log.d("ccm", "findTargetSnapPosition");
        if (layoutManager instanceof CustomLayoutManager) {
//            Log.d("ccm","findTargetSnapPosition");
            int childCount = layoutManager.getChildCount();
            if (childCount <= 0) return RecyclerView.NO_POSITION;
            //获取当前屏幕上最小的Item
            int minItem = 9999;
            for (int i = 0 ; i < childCount; i++) {
                View child = layoutManager.getChildAt(i);
                int decoratedRight = layoutManager.getDecoratedRight(child);
                int decoratedLeft = layoutManager.getDecoratedLeft(child);
                if (decoratedRight > layoutManager.getPaddingLeft() && decoratedLeft < layoutManager.getWidth() - layoutManager.getPaddingRight()) {
                    int position = layoutManager.getPosition(child);
                    minItem = Math.min(minItem, position);
                }
            }
            if (minItem == 9999) return RecyclerView.NO_POSITION;

            CustomLayoutManager customLayoutManager = (CustomLayoutManager) layoutManager;
            int tarPage = customLayoutManager.getTarPage(minItem); //计算出minItem对应的page
            int maxPage = customLayoutManager.getMaxPage(); //获取总的page数量
            if (velocityX > 0) {//左滑
                if (tarPage < maxPage) {
                    //未到最后一页,滚动到下一页的第一个Item
                    return customLayoutManager.getPageFistPosition(tarPage + 1);
                } else {
                    //已经到最后一页了,滚动到当前页的第一个Item
                    return customLayoutManager.getPageFistPosition(maxPage);
                }
            } else {
                if (tarPage <= 1) {
                    //未到第一页,滚动到上一个页的第一个Item
                    return customLayoutManager.getPageFistPosition(1);
                } else {
                    //已经到第一页了,滚动到第一页的第一个Iitem
                    return customLayoutManager.getPageFistPosition(tarPage);
                }
            }
        } else {
            return RecyclerView.NO_POSITION;
        }
    }
    @Nullable
    @Override
    protected RecyclerView.SmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
        return !(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider) ? null : new LinearSmoothScroller(mRecyclerView.getContext()) {
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
                int[] snapDistances = calculateDistanceToFinalSnap(layoutManager, targetView);
                int dx = snapDistances[0];
                int dy = snapDistances[1];
                int time = this.calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
                if (time > 0) {
                    action.update(dx, dy, time, this.mDecelerateInterpolator);
                }

            }

            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return 25.0f / (float)displayMetrics.densityDpi;
            }

            protected int calculateTimeForScrolling(int dx) {
                return Math.min(100, super.calculateTimeForScrolling(dx));
            }

        };
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,657评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,889评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,057评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,509评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,562评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,443评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,251评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,129评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,561评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,779评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,902评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,621评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,220评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,838评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,971评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,025评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,843评论 2 354

推荐阅读更多精彩内容