GcsSloop

Just do IT later.

嗨,我是 GcsSloop,一名来自2.5次元的魔法师,Android自定义View系列文章作者,非著名程序员。


欢迎来到我的魔法世界!

雕虫晓技(六) 网格分页布局源码解析(下)

0. 前言

pager-layoutmanager: https://github.com/GcsSloop/pager-layoutmanager

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

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

1. 分页对齐

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

pic_01 pic_02

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

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

1.1 SnapHelper

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

pic_03

有关 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 中再添加两个方法,就是做一些简单的计算,另外防止越界就可以了。

/**
 * 找到下一页第一个条目的位置
 *
 * @return 第一个搞条目的位置
 */
int findNextPageFirstPos() {
    int page = mLastPageIndex;
    page++;
    if (page >= getTotalPageCount()) {
        page = getTotalPageCount() - 1;
    }
    Loge("computeScrollVectorForPosition next = " + page);
    return page * mOnePageSize;
}

/**
 * 找到上一页的第一个条目的位置
 *
 * @return 第一个条目的位置
 */
int findPrePageFirstPos() {
    // 在获取时由于前一页的View预加载出来了,所以获取到的直接就是前一页
    int page = mLastPageIndex;
    page--;
    Loge("computeScrollVectorForPosition pre = " + page);
    if (page < 0) {
        page = 0;
    }
    Loge("computeScrollVectorForPosition pre = " + page);
    return page * mOnePageSize;
}

1.4 参数控制

实际上经过上面的步骤,一个简单的分页对齐辅助工具有完成了,但是有时手感可能会不太好,例如说 Fling 的触发速度,是需要用很大力气才能触发翻页操作呢,还是只需轻轻一划就会翻页呢,这个我们需要控制一下。

除此之外,还有就是自动滚动时的滚动速度控制,是很快的就滚动过去呢,还是慢慢的滚动到目标位置,官方提供的一些参数在实际应用时可能并不符合我们的需求,因此我们可以自定义一些参数来控制。

1.4.1 控制 Fling 触发速度

官方使用的是 RecyclerView 的最小触发速度,但是这个速度我们无法设置,因此我们对这段代码重载一下,替换成我们自己定义的速度。

/**
 * 一扔(快速滚动)
 *
 * @param velocityX X 轴滚动速率
 * @param velocityY Y 轴滚动速率
 * @return 是否消费该事件
 */
@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();
    
    // 替换成自定义触发速度
    int minFlingVelocity = PagerConfig.getFlingThreshold();
    Loge("minFlingVelocity = " + minFlingVelocity);
    return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
            && snapFromFling(layoutManager, velocityX, velocityY);
}

由于 snapFromFling 方法不是公开的,不可重载,我们按照官方实现复制过来一份就行了,此处没有贴出。

1.4.2 控制滚动速度

自动滚动速度主要是有 SmoothScroller 来控制,此处我们自己进行创建 SmoothScroller 来控制平滑滚动的速度。

/**
 * 通过自定义 LinearSmoothScroller 来控制速度
 * @param layoutManager 布局故哪里去
 * @return 自定义 LinearSmoothScroller
 */
protected LinearSmoothScroller createSnapScroller(RecyclerView.LayoutManager layoutManager) {
    if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
        return null;
    }
    return new PagerGridSmoothScroller(mRecyclerView);
}

此处的 PagerGridSmoothScroller 是我们自己实现的,继承自 LinearSmoothScroller,很简单。

public class PagerGridSmoothScroller extends LinearSmoothScroller {
    private RecyclerView mRecyclerView;

    public PagerGridSmoothScroller(@NonNull RecyclerView recyclerView) {
        super(recyclerView.getContext());
        mRecyclerView = recyclerView;
    }

    @Override
    protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
        RecyclerView.LayoutManager manager = mRecyclerView.getLayoutManager();
        if (null == manager) return;
        if (manager instanceof PagerGridLayoutManager) {
            PagerGridLayoutManager layoutManager = (PagerGridLayoutManager) manager;
            int pos = mRecyclerView.getChildAdapterPosition(targetView);
            int[] snapDistances = layoutManager.getSnapOffset(pos);
            final int dx = snapDistances[0];
            final int dy = snapDistances[1];
            Logi("dx = " + dx);
            Logi("dy = " + dy);
            final int time = calculateTimeForScrolling(Math.max(Math.abs(dx), Math.abs(dy)));
            if (time > 0) {
                action.update(dx, dy, time, mDecelerateInterpolator);
            }
        }
    }

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

经过这些步骤之后,一个可调控,简单方便的分页对齐工具就开发完成了,可以像这样使用了。

// 设置滚动辅助工具
PagerGridSnapHelper pageSnapHelper = new PagerGridSnapHelper();
pageSnapHelper.attachToRecyclerView(mRecyclerView);

2. 处理滚动到指定条目(页面)

在上面一篇文章中,只是简单的开发了一个分页网格布局,该网格布局实现了布局和基本的滚动处理,经过本篇的上半篇文章,实现了分页对齐功能。

但是,该分页布局管理器依旧存在问题,例如,你会发现它只能手动的进行滚动,你无法用代码控制它滚动到距离的条目和页面,例如,你调用 recyclerView.scrollToPosition(0); 它是没有任何响应的。这是因为我们没有处理滚动到指定条目的方案。

2.1 直接滚动

由于我们是页面对齐,所以滚动到指定条目,也就是滚动到指定条目所在到页面,因此我们可以这样写。

public void scrollToPosition(int position) {
    int pageIndex = getPageIndexByPos(position);
    scrollToPage(pageIndex);
}

先计算出条目所在页面的下标,然后滚动到该页面。但是 scrollToPage 方法需要我们自己实现一下,逻辑也很简单,如下:

public void scrollToPage(int pageIndex) {
    if (pageIndex < 0 || pageIndex >= mLastPageCount) {
        Log.e(TAG, "pageIndex = " + pageIndex + " is out of bounds, mast in [0, " + mLastPageCount + ")");
        return;
    }

    if (null == mRecyclerView) {
        Log.e(TAG, "RecyclerView Not Found!");
        return;
    }

    int mTargetOffsetXBy = 0;
    int mTargetOffsetYBy = 0;
    if (canScrollVertically()) {
        mTargetOffsetXBy = 0;
        mTargetOffsetYBy = pageIndex * getUsableHeight() - mOffsetY;
    } else {
        mTargetOffsetXBy = pageIndex * getUsableWidth() - mOffsetX;
        mTargetOffsetYBy = 0;
    }
    Loge("mTargetOffsetXBy = " + mTargetOffsetXBy);
    Loge("mTargetOffsetYBy = " + mTargetOffsetYBy);
    mRecyclerView.scrollBy(mTargetOffsetXBy, mTargetOffsetYBy);
    setPageIndex(pageIndex, false);
}

其实就是先计算出目标页面的坐标,然后计算滚动到该位置需要的距离,最后调用 RecyclerView 的滚动就可以了。

但是,在 LayoutManager 中是无法直接取到 RecyclerView 的,因此我们在 onAttachedToWindow 方法中获得当前 LayoutManager 的 RecyclerView,并记录下来。

private RecyclerView mRecyclerView;

@Override
public void onAttachedToWindow(RecyclerView view) {
    super.onAttachedToWindow(view);
    mRecyclerView = view;
}

在有了 scrollToPage 方法后,我们还可以定义两个常用的方法,上一页和下一页,来供外部使用。

/**
 * 上一页
 */
public void prePage() {
    scrollToPage(getPageIndexByOffset() - 1);
}

/**
 * 下一页
 */
public void nextPage() {
    scrollToPage(getPageIndexByOffset() + 1);
}

就这样,直接滚动就处理好了。

2.2 平滑滚动

和直接滚动一样,我们同样实现平滑滚动到指定条目,平滑滚动到指定页面,上一页,下一页等功能,实现方式页和直接滚动类似,将所有的操作都统一转化为滚动到指定页面,最终有滚动到指定页面来实现具体功能。

// 平滑滚动到指定条目
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
    int targetPageIndex = getPageIndexByPos(position);
    smoothScrollToPage(targetPageIndex);
}

// 平滑滚动到上一页
public void smoothPrePage() {
    smoothScrollToPage(getPageIndexByOffset() - 1);
}

// 平滑滚动到下一页
public void smoothNextPage() {
    smoothScrollToPage(getPageIndexByOffset() + 1);
}

// 平滑滚动到指定页面
public void smoothScrollToPage(int pageIndex) {
    if (pageIndex < 0 || pageIndex >= mLastPageCount) {
        Log.e(TAG, "pageIndex is outOfIndex, must in [0, " + mLastPageCount + ").");
        return;
    }
    if (null == mRecyclerView) {
        Log.e(TAG, "RecyclerView Not Found!");
        return;
    }

    // 如果滚动到页面之间距离过大,先直接滚动到目标页面到临近页面,在使用 smoothScroll 最终滚动到目标
    // 否则在滚动距离很大时,会导致滚动耗费的时间非常长
    int currentPageIndex = getPageIndexByOffset();
    if (Math.abs(pageIndex - currentPageIndex) > 3) {
        if (pageIndex > currentPageIndex) {
            scrollToPage(pageIndex - 3);
        } else if (pageIndex < currentPageIndex) {
            scrollToPage(pageIndex + 3);
        }
    }

    // 具体执行滚动
    LinearSmoothScroller smoothScroller = new PagerGridSmoothScroller(mRecyclerView);
    int position = pageIndex * mOnePageSize;
    smoothScroller.setTargetPosition(position);
    startSmoothScroll(smoothScroller);
}

和直接滚动不同的是,平滑滚动会有一个简单的滚动动画效果,这个动画效果借助 SmoothScroller 来实现,为了保证滑动效果一致,我们使用和 SnapHelper 相同的 PagerGridSmoothScroller。

需要注意的是,在进行超长距离的平滑滚动时,如果不做特殊处理,可能要滚动很长的时间(会花费超过 10s 甚至更长的时间在滚动上),为了限制平滑滚动花费的时间,这里对滚动距离做了一个简单的限制,即最大可以平滑滚动 3 个页面的长度,如果超过 3 个页面的长度后,则先直接跳转到临近页面,再执行平滑滚动,这样就可以保证很快执行完超长距离的平滑滚动,具体代码逻辑参考上面。

3. 结语

到此为止,一个简单的网格分页布局管理器(PagerGridLayoutManager)和分页辅助工具(PagerGridSnapHelper)就开发完了,当然,这个库还有很多可以完善的地方,事实上,在写本文的同时,我又对它很多的细节又重新打磨了一遍。因此你如果查看就版本的话,代码内容可能会稍有不同,但基本逻辑是相同的。

如果喜欢本文的话,欢迎点赞、分享或者打赏支持。

关于作者

GcsSloop,一名 2.5 次元魔法师。


如果你觉得我的文章对你有帮助的话,欢迎赞助一些服务器费用!

¥ 点击赞助

欢迎关注我的微信公众号

最近的文章

雕虫晓技(七) 用旧Android手机做远程摄像头

关于作者GcsSloop,一名 2.5 次元魔法师。微博 | GitHub | 博客0. 前言科技发展越来越快,手机也换的越来越频繁,有很多旧手机都被淘汰了,这些旧手机一般没什么用,也不能卖,毕...…

GeBug

继续阅读
更早的文章

雕虫晓技(五) 网格分页布局源码解析(上)

关于作者GcsSloop,一名 2.5 次元魔法师。微博 | GitHub | 博客0.前言pager-layoutmanager: https://github.com/GcsSloop/pa...…

GeBug

继续阅读