【Android】掌握自定义LayoutManager(二) 实现流式布局

private int mVerticalOffset;//竖直偏移量 每次换行时,要根据这个offset判断

private int mFirstVisiPos;//屏幕可见的第一个View的Position

private int mLastVisiPos;//屏幕可见的最后一个View的Position

@Override

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {

if (getItemCount() == 0) {//没有Item,界面空着吧

detachAndScrapAttachedViews(recycler);

return;

}

if (getChildCount() == 0 && state.isPreLayout()) {//state.isPreLayout()是支持动画的

return;

}

//onLayoutChildren方法在RecyclerView 初始化时 会执行两遍

detachAndScrapAttachedViews(recycler);

//初始化

mVerticalOffset = 0;

mFirstVisiPos = 0;

mLastVisiPos = getItemCount();

//初始化时调用 填充childView

fill(recycler, state);

}

这个fill(recycler, state);方法将是你自定义LayoutManager之旅一生的敌人,简单的说它承担了以下任务:

在考虑滑动位移的情况下:

1 回收所有屏幕不可见的子View

2 layout所有可见的子View

在这一节,我们先看一下它的简单版本,不考虑滑动位移,不考虑滑动方向等,只考虑初始化时,从头至尾,layout所有可见的子View,在下一节我会配合滑动事件放出它的完整版.

int topOffset = getPaddingTop();//布局时的上偏移

int leftOffset = getPaddingLeft();//布局时的左偏移

int lineMaxHeight = 0;//每一行最大的高度

int minPos = mFirstVisiPos;//初始化时,我们不清楚究竟要layout多少个子View,所以就假设从0~itemcount-1

mLastVisiPos = getItemCount() - 1;

//顺序addChildView

for (int i = minPos; i <= mLastVisiPos; i++) {

//找recycler要一个childItemView,我们不管它是从scrap里取,还是从RecyclerViewPool里取,亦或是onCreateViewHolder里拿。

View child = recycler.getViewForPosition(i);

addView(child);

measureChildWithMargins(child, 0, 0);

//计算宽度 包括margin

if (leftOffset + getDecoratedMeasurementHorizontal(child) <= getHorizontalSpace()) {//当前行还排列的下

layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

//改变 left lineHeight

leftOffset += getDecoratedMeasurementHorizontal(child);

lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));

} else {//当前行排列不下

//改变top left lineHeight

leftOffset = getPaddingLeft();

topOffset += lineMaxHeight;

lineMaxHeight = 0;

//新起一行的时候要判断一下边界

if (topOffset - dy > getHeight() - getPaddingBottom()) {

//越界了 就回收

removeAndRecycleView(child, recycler);

mLastVisiPos = i - 1;

} else {

layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

//改变 left lineHeight

leftOffset += getDecoratedMeasurementHorizontal(child);

lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));

}

}

}

用到的一些工具函数(在系列开篇已介绍过):

//模仿LLM Horizontal 源码

/**

  • 获取某个childView在水平方向所占的空间

  • @param view

  • @return

*/

public int getDecoratedMeasurementHorizontal(View view) {

final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)

view.getLayoutParams();

return getDecoratedMeasuredWidth(view) + params.leftMargin

  • params.rightMargin;

}

/**

  • 获取某个childView在竖直方向所占的空间

  • @param view

  • @return

*/

public int getDecoratedMeasurementVertical(View view) {

final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)

view.getLayoutParams();

return getDecoratedMeasuredHeight(view) + params.topMargin

  • params.bottomMargin;

}

public int getVerticalSpace() {

return getHeight() - getPaddingTop() - getPaddingBottom();

}

public int getHorizontalSpace() {

return getWidth() - getPaddingLeft() - getPaddingRight();

}

如上编写一个超级简单的fill()方法,运行,你的程序应该就能看到流式布局的效果出现了。

可是千万别开心,因为痛苦的计算远没到来。

如果这些都看不懂,那么我建议:

一,直接下载完整代码,配合后面的章节看,看到后面也许前面的就好理解了= =。

二,去学习一下自定义ViewGroup的知识。

此时虽然界面上已经展示了流式布局的效果,可是它并不能滑动,下一节我们让它动起来。

四,动起来

=====

想让我们自定义的LayoutManager动起来,最简单的写法如下:

@Override

public boolean canScrollVertically() {

return true;

}

@Override

public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {

int realOffset = dy;//实际滑动的距离, 可能会在边界处被修复

offsetChildrenVertical(-realOffset);

return realOffset;

}

offsetChildrenVertical(-realOffset);这句话移动所有的childView.

返回值会被RecyclerView用来判断是否达到边界, 如果返回值!=传入的dy,则会有一个边缘的发光效果,表示到达了边界。而且返回值还会被RecyclerView用于计算fling效果。

写完编译,哇塞,真的跟随手指滑动了,只不过能动的总共就我们在上一节layout的那些Item,Item并没有回收,也没有新的Item出现。

好了,下面开始正经的写它吧,

@Override

public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {

//位移0、没有子View 当然不移动

if (dy == 0 || getChildCount() == 0) {

return 0;

}

int realOffset = dy;//实际滑动的距离, 可能会在边界处被修复

//边界修复代码

if (mVerticalOffset + realOffset < 0) {//上边界

realOffset = -mVerticalOffset;

} else if (realOffset > 0) {//下边界

//利用最后一个子View比较修正

View lastChild = getChildAt(getChildCount() - 1);

if (getPosition(lastChild) == getItemCount() - 1) {

int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);

if (gap > 0) {

realOffset = -gap;

} else if (gap == 0) {

realOffset = 0;

} else {

realOffset = Math.min(realOffset, -gap);

}

}

}

realOffset = fill(recycler, state, realOffset);//先填充,再位移。

mVerticalOffset += realOffset;//累加实际滑动距离

offsetChildrenVertical(-realOffset);//滑动

return realOffset;

}

这里用realOffset变量保存实际的位移,也是return 回去的值。大部分情况下它=dy。

在边界处,为了防止越界,做了一些处理,realOffset 可能不等于dy。

别的文章不同的是,我参考了LinearLayoutManager的源码,先考虑滑动位移进行View的回收、填充(fill()函数),然后再真正的位移这些子Item。


fill()的过程中

流程:

一 会先考虑到dy回收界面上不可见的Item。

填充布局子View

三 判断是否将dy都消费掉了,如果消费不掉:例如滑动距离太多,屏幕上的View已经填充完了,仍有空白,那么就要修正dy给realOffset。

注意事项一:考虑滑动的方向

在填充布局子View的时候,还要考虑滑动的方向,即填充的顺序,是从头至尾填充,还是从尾至头部填充。

如果是向底部滑动,那么是顺序填充,显示底端position更大的Item。( dy>0)

如果是向顶部滑动,那么是逆序填充,显示顶端positon更小的Item。(dy<0)

注意事项二:流式布局 逆序布局子View的问题

再啰嗦最后一点,我们想象一下这个逆序填充的过程:

正序过程可以自上而下,自左向右layout 子View,每次layout之前判断当前这一行宽度+子View宽度,是否超过父控件宽度,如果超过了就另起一行。

逆序时,有两种方案:

1 利用Rect保存子View边界

正序排列时,保存每个子View的Rect

逆序时,直接拿出来,layout

2 逆序化

自右向左layout子View,每次layout之前判断当前这一行宽度+子View宽度,是否超过父控件宽度,

如果超过了就另起一行。并且判断最后一个子View距离父控件左边的offset,平移这一行的所有子View,较复杂,采用方案1.

(我个人认为这两个方案都不太好,希望有朋友能提出更好的方案。)

下面上码:

private SparseArray mItemRects;//key 是View的position,保存View的bounds ,

/**

  • 填充childView的核心方法,应该先填充,再移动。

  • 在填充时,预先计算dy的在内,如果View越界,回收掉。

  • 一般情况是返回dy,如果出现View数量不足,则返回修正后的dy.

  • @param recycler

  • @param state

  • @param dy RecyclerView给我们的位移量,+,显示底端, -,显示头部

  • @return 修正以后真正的dy(可能剩余空间不够移动那么多了 所以return <|dy|)

*/

private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int dy) {

int topOffset = getPaddingTop();

//回收越界子View

if (getChildCount() > 0) {//滑动时进来的

for (int i = getChildCount() - 1; i >= 0; i–) {

View child = getChildAt(i);

if (dy > 0) {//需要回收当前屏幕,上越界的View

if (getDecoratedBottom(child) - dy < topOffset) {

removeAndRecycleView(child, recycler);

mFirstVisiPos++;

continue;

}

} else if (dy < 0) {//回收当前屏幕,下越界的View

if (getDecoratedTop(child) - dy > getHeight() - getPaddingBottom()) {

removeAndRecycleView(child, recycler);

mLastVisiPos–;

continue;

}

}

}

//detachAndScrapAttachedViews(recycler);

}

int leftOffset = getPaddingLeft();

int lineMaxHeight = 0;

//布局子View阶段

if (dy >= 0) {

int minPos = mFirstVisiPos;

mLastVisiPos = getItemCount() - 1;

if (getChildCount() > 0) {

View lastView = getChildAt(getChildCount() - 1);

minPos = getPosition(lastView) + 1;//从最后一个View+1开始吧

topOffset = getDecoratedTop(lastView);

leftOffset = getDecoratedRight(lastView);

lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(lastView));

}

//顺序addChildView

for (int i = minPos; i <= mLastVisiPos; i++) {

//找recycler要一个childItemView,我们不管它是从scrap里取,还是从RecyclerViewPool里取,亦或是onCreateViewHolder里拿。

View child = recycler.getViewForPosition(i);

addView(child);

measureChildWithMargins(child, 0, 0);

//计算宽度 包括margin

if (leftOffset + getDecoratedMeasurementHorizontal(child) <= getHorizontalSpace()) {//当前行还排列的下

layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

//保存Rect供逆序layout用

Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child) + mVerticalOffset);

mItemRects.put(i, rect);

//改变 left lineHeight

leftOffset += getDecoratedMeasurementHorizontal(child);

lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));

} else {//当前行排列不下

//改变top left lineHeight

leftOffset = getPaddingLeft();

topOffset += lineMaxHeight;

lineMaxHeight = 0;

//新起一行的时候要判断一下边界

if (topOffset - dy > getHeight() - getPaddingBottom()) {

//越界了 就回收

removeAndRecycleView(child, recycler);

mLastVisiPos = i - 1;

} else {

layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

//保存Rect供逆序layout用

Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child) + mVerticalOffset);

mItemRects.put(i, rect);

//改变 left lineHeight

leftOffset += getDecoratedMeasurementHorizontal(child);

lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));

}

}

}

//添加完后,判断是否已经没有更多的ItemView,并且此时屏幕仍有空白,则需要修正dy

View lastChild = getChildAt(getChildCount() - 1);

if (getPosition(lastChild) == getItemCount() - 1) {

int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);

if (gap > 0) {

dy -= gap;

}

}

} else {

/**

  • 利用Rect保存子View边界

正序排列时,保存每个子View的Rect,逆序时,直接拿出来layout。

*/

int maxPos = getItemCount() - 1;

mFirstVisiPos = 0;

if (getChildCount() > 0) {

View firstView = getChildAt(0);

maxPos = getPosition(firstView) - 1;

}

for (int i = maxPos; i >= mFirstVisiPos; i–) {

Rect rect = mItemRects.get(i);

if (rect.bottom - mVerticalOffset - dy < getPaddingTop()) {

mFirstVisiPos = i + 1;

break;

} else {

View child = recycler.getViewForPosition(i);

addView(child, 0);//将View添加至RecyclerView中,childIndex为1,但是View的位置还是由layout的位置决定

measureChildWithMargins(child, 0, 0);

layoutDecoratedWithMargins(child, rect.left, rect.top - mVerticalOffset, rect.right, rect.bottom - mVerticalOffset);

}

}

}

Log.d(“TAG”, “count= [” + getChildCount() + “]” + “,[recycler.getScrapList().size():” + recycler.getScrapList().size() + “, dy:” + dy + “, mVerticalOffset” + mVerticalOffset+", ");

return dy;

}

思路已经在前面讲解过,代码里也配上了注释,计算坐标等都是数学问题,略饶人,需要用笔在纸上写一写,或者运行调试调试。没啥好办法。

值得一提的是,可以通过getChildCount()recycler.getScrapList().size() 查看当前屏幕上的Item数量 和 scrapCache缓存区域的Item数量,合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0.

总结

【Android 详细知识点思维脑图(技能树)】

【Android】掌握自定义LayoutManager(二) 实现流式布局

我个人是做Android开发,已经有十来年了,目前在某创业公司任职CTO兼系统架构师。虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,现在高级工程师还是比较缺少的,很多高级职位给的薪资真的特别高(钱多也不一定能找到合适的),所以努力让自己成为高级工程师才是最重要的。

这里附上上述的面试题相关的几十套字节跳动,京东,小米,腾讯、头条、阿里、美团等公司19年的面试题。把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

由于篇幅有限,这里以图片的形式给大家展示一小部分。

【Android】掌握自定义LayoutManager(二) 实现流式布局

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

最后,赠与大家一句话,共勉!

值得一提的是,可以通过getChildCount()recycler.getScrapList().size() 查看当前屏幕上的Item数量 和 scrapCache缓存区域的Item数量,合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0.

总结

【Android 详细知识点思维脑图(技能树)】

[外链图片转存中…(img-dv2XZA3S-1643536620133)]

我个人是做Android开发,已经有十来年了,目前在某创业公司任职CTO兼系统架构师。虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,现在高级工程师还是比较缺少的,很多高级职位给的薪资真的特别高(钱多也不一定能找到合适的),所以努力让自己成为高级工程师才是最重要的。

这里附上上述的面试题相关的几十套字节跳动,京东,小米,腾讯、头条、阿里、美团等公司19年的面试题。把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

由于篇幅有限,这里以图片的形式给大家展示一小部分。

[外链图片转存中…(img-L3BZxb9t-1643536620134)]

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

最后,赠与大家一句话,共勉!

本文已被CODING开源项目:《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》收录

上一篇:Android 嵌套滑动总结,kotlin框架


下一篇:运营小技能:推荐的DY内容变现方式