网格分页布局源码解析(下)

1,125 阅读6分钟

0. 前言

pager-layoutmanager: github.com/GcsSloop/pa…

网格分页布局源码解析(上)中,主要分享了如何定义一个网格布局,正常运行的效果看起来其实和 GridLayoutManager 有些类似。

这是它的下篇,主要讲解如何让它滑动时一页一页的滚动,而不是随意的滚动,除此之外,还包括一些其他相关内容,例如滚动、平滑滚动和超长距离滚动需要注意的一些细节。

1. 分页对齐

在开始讲解前,先看一下启用了分页对齐和未启用分页对齐的效果有何不同:

在左侧未启用分页对齐时,滚动到哪里就会停在哪里。在右侧启用了分页对齐后,滚动距离较小时,会回弹到当前页,滚动距离超过阀值时,会自动滚动到下一页。

让其页面对齐的方法有很多种,其核心就是控制滚动距离,在本文中,我们使用 RecyclerView 官方提供的条目对齐方式,借助 SnapHelper 来进行页面对齐。

1.1 SnapHelper

SnapHelper 是官方提供的一个辅助类,主要用于拓展 RecyclerView,让 RecyclerView 在滚动结束时不会停留在任意位置,而是根据一定的规则来约束停留的位置,例如:卡片布局在停止滚动时始终保证一张卡片居中显示,而不是出现两张卡片都显示一半这种情况。

有关 SnapHelper 的更多内容可以参考:让你明明白白的使用RecyclerView——SnapHelper详解

官方提供了两个 SnapHelper 的实例,分别是 LinearSnapHelper 和 PagerSnapHelper,不过这两个都不太符合我们的需求,因此我们要自定义一个 SnapHelper 来协助我们完成分页对齐。

1.2 让 LayoutManager 支持 SnapHelper

SnapHelper 会尝试处理 Fling,但为了正常工作,LayoutManager 必须实现RecyclerView.SmoothScroller.ScrollVectorProvider 接口,或者重写 onFling(int,int) 并手动处理 Fling。

Fling: 手指从屏幕上快速划过,手指离开屏幕后,界面由于“惯性”依旧会滚动一段时间,这个过程称为 Fling。Fling 从手指离开屏幕时触发,滚动停止时结束。

我们先让 LayoutManager 实现该接口。

public class PagerGridLayoutManager extends RecyclerView.LayoutManager
        implements RecyclerView.SmoothScroller.ScrollVectorProvider {
    /**
     * 计算到目标位置需要滚动的距离{@link RecyclerView.SmoothScroller.ScrollVectorProvider}
     * @param targetPosition 目标控件
     * @return 需要滚动的距离
     */
    @Override
    public PointF computeScrollVectorForPosition(int targetPosition) {
        PointF vector = new PointF();
        int[] pos = getSnapOffset(targetPosition);
        vector.x = pos[0];
        vector.y = pos[1];
        return vector;
    }

    //--- 下面两个方法是自定义的辅助方法 ------------------------------------------------------

    /**
     * 获取偏移量(为PagerGridSnapHelper准备)
     * 用于分页滚动,确定需要滚动的距离。
     * {@link PagerGridSnapHelper}
     *
     * @param targetPosition 条目下标
     */
    int[] getSnapOffset(int targetPosition) {
        int[] offset = new int[2];
        int[] pos = getPageLeftTopByPosition(targetPosition);
        offset[0] = pos[0] - mOffsetX;
        offset[1] = pos[1] - mOffsetY;
        return offset;
    }

    /**
     * 根据条目下标获取该条目所在页面的左上角位置
     *
     * @param pos 条目下标
     * @return 左上角位置
     */
    private int[] getPageLeftTopByPosition(int pos) {
        int[] leftTop = new int[2];
        int page = getPageIndexByPos(pos);
        if (canScrollHorizontally()) {
            leftTop[0] = page * getUsableWidth();
            leftTop[1] = 0;
        } else {
            leftTop[0] = 0;
            leftTop[1] = page * getUsableHeight();
        }
        return leftTop;
    }

    // 省略其它部分代码...
}

注意:由于我们是分页对齐,所以,最终滚动停留的位置始终应该以页面为基准,而不是以具体条目为基准,所以,我们要计算出目标条目所在页面的坐标,并以此为基准计算出所需滚动的距离。

当然,除了 Fling 操作,我们在用户普通滑动结束时也要进行一次页面对齐,为了支持这一功能,我们在 PagerGridLayoutManager 再定义一个方法,用于寻找当前应该对齐的 View。

/** 获取需要对齐的View
 * @return 需要对齐的View
 */
public View findSnapView() {
    // 适配 TV
    if (null != getFocusedChild()) {
        return getFocusedChild();
    }
    if (getChildCount() <= 0) {
        return null;
    }
    // 以当前页面第一个View为基准
    int targetPos = getPageIndexByOffset() * mOnePageSize;   // 目标Pos
    for (int i = 0; i < getChildCount(); i++) {
        int childPos = getPosition(getChildAt(i));
        if (childPos == targetPos) {
            return getChildAt(i);
        }
    }
    // 如果没有找到就返回当前的第一个 View
    return getChildAt(0);
}

/** 根据 offset 获取页面 Index
 *  计算规则是,在当前状态下,哪个页面显示区域最大,就认为该页面是主要的页面,
 *。最终对齐时也会以该页面为基准。
 * @return 页面 Index
 */
private int getPageIndexByOffset() {
    int pageIndex;
    if (canScrollVertically()) {
        int pageHeight = getUsableHeight();
        if (mOffsetY <= 0 || pageHeight <= 0) {
            pageIndex = 0;
        } else {
            pageIndex = mOffsetY / pageHeight;
            if (mOffsetY % pageHeight > pageHeight / 2) {
                pageIndex++;
            }
        }
    } else {
        int pageWidth = getUsableWidth();
        if (mOffsetX <= 0 || pageWidth <= 0) {
            pageIndex = 0;
        } else {
            pageIndex = mOffsetX / pageWidth;
            if (mOffsetX % pageWidth > pageWidth / 2) {
                pageIndex++;
            }
        }
    }
    Logi("getPageIndexByOffset pageIndex = " + pageIndex);
    return pageIndex;
}

主要方法时 findSnapView,寻找当前应该对齐的 View,需要注意的是临界点的处理方案,例如在横向滚动的状态下,向左翻页未超过左侧页面中心位置,则松手后应该继续回到当前页面,若是超过了左侧页面中心位置,则松手后应该自动滚动到左侧页面。向右翻页同理,应该以当前状态下,显示区域最大的页面作为基准。

1.3 自定义 PagerGridSnapHelper

由于官方已经有了一个 PagerSnapHelper,为了避免混淆,我起名叫做 PagerGridSnapHelper。

由于官方已经实现了一些基础逻辑,所以实现一个 SnapHelper 还是比较简单的,主要是实现一些方法就行了,不过由于 SnapHelper 某些内容没有提供设置途径,因此我们会重载部分方法,所以下面的代码可能会看起来稍长,其实很简单。

public class PagerGridSnapHelper 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;
    }
}

继承 SnapHelper 后,它会让我们实现 3 个方法。

1.3.1 计算到目标控件需要的距离

这里直接使用我们之前在 LayoutManager 中定义好的 getSnapOffset 就可以了。

/**
 * 计算需要滚动的向量,用于页面自动回滚对齐
 *
 * @param layoutManager 布局管理器
 * @param targetView    目标控件
 * @return 需要滚动的距离
 */
@Nullable
@Override
public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager,
                                          @NonNull View targetView) {
    int pos = layoutManager.getPosition(targetView);
    Loge("findTargetSnapPosition, pos = " + pos);
    int[] offset = new int[2];
    if (layoutManager instanceof PagerGridLayoutManager) {
        PagerGridLayoutManager manager = (PagerGridLayoutManager) layoutManager;
        offset = manager.getSnapOffset(pos);
    }
    return offset;
}

1.3.2 获得需要对齐的 View

这个主要用于用户普通滚动停止时的对齐,直接使用之前 LayoutManager 中定义好的 findSnapView 就可以了。

/**
 * 获得需要对齐的View,对于分页布局来说,就是页面第一个
 *
 * @param layoutManager 布局管理器
 * @return 目标控件
 */
@Nullable
@Override
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
    if (layoutManager instanceof PagerGridLayoutManager) {
        PagerGridLayoutManager manager = (PagerGridLayoutManager) layoutManager;
        return manager.findSnapView();
    }
    return null;
}

1.3.3 获取目标控件的位置下标

这个主要用于处理 Fling 事件,因此我们需要判断一下用户的 Fling 的方向,进而来获取需要对齐的条目,对于此处来说,就是上一页或者下一页的第一个条目。

/**
 * 获取目标控件的位置下标
 * (获取滚动后第一个View的下标)
 *
 * @param layoutManager 布局管理器
 * @param velocityX     X 轴滚动速率
 * @param velocityY     Y 轴滚动速率
 * @return 目标控件的下标
 */
@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager,
                                  int velocityX, int velocityY) {
    int target = RecyclerView.NO_POSITION;
    Loge("findTargetSnapPosition, velocityX = " + velocityX + ", velocityY" + velocityY);
    if (null != layoutManager && layoutManager instanceof PagerGridLayoutManager) {
        PagerGridLayoutManager manager = (PagerGridLayoutManager) layoutManager;
        if (manager.canScrollHorizontally()) {
            if (velocityX > PagerConfig.getFlingThreshold()) {
                target = manager.findNextPageFirstPos();
            } else if (velocityX < -PagerConfig.getFlingThreshold()) {
                target = manager.findPrePageFirstPos();
            }
        } else if (manager.canScrollVertically()) {
            if (velocityY > PagerConfig.getFlingThreshold()) {
                target = manager.findNextPageFirstPos();
            } else if (velocityY < -PagerConfig.getFlingThreshold()) {
                target = manager.findPrePageFirstPos();
            }
        }
    }
    Loge("findTargetSnapPosition, target = " + target);
    return target;
}

为此我们需要在 LayoutManager 中再添加两个方法,就是做一些简单的计算,另外防止越界就可以了。

```Java
/**

  • 找到下一页第一个条目的位置
    *
  • @return 第一个搞条目的位置
    */
    int findNextPageFirstPos() {