前言
上周五写了篇仿夸克浏览器底部工具栏,相信看过的同学还有印象吧。在文末我抛出了一个问题,夸克浏览器底部工具栏只是单层层叠的ViewGroup,如何实现类似Android系统通知栏的多级层叠列表呢?
不过当时仅仅有了初步的思路:recyclerView
+自定义layoutManager
,所以周末又把自定义layoutManager
狠补了一遍。终于大致实现了这个效果(当然细节有待优化( ̄. ̄))。老样子,先来看看效果吧:
实际使用时可能不需要顶部层叠,所以还有单边效果,看起来更自然些:
怎么样,乍一看是不是非常形(神)似呢?以上的效果都是自定义layoutManager
实现的,所以只要一行代码就能把普通的RecyclerView替换成这种层叠列表:
mRecyclerView.setLayoutManager(new OverFlyingLayoutManager());
好了废话不多说,直接来分析下怎么实现吧。以下的主要内容就是帮你从学会到熟悉自定义layoutManager
。
概述
先简单说下自定义layoutManager
的步骤吧,其实很多文章都讲过,适合没接触的同学:
- 实现
generateDefaultLayoutParams()
方法,生成自己所定义扩展的LayoutParams
。 - 在
onLayoutChildren()
中实现初始列表中各个itemView
的位置 - 在
scrollVerticallyBy()
和scrollHorizontallyBy()
中处理横向和纵向滚动,还有view的回收复用。
个人理解就是:layoutManager
就相当于自定义ViewGroup
中把onMeasure()
、onlayout()
,scrollTo()
等方法独立出来,单独交给它来做。实际表现也是类似:onLayoutChildren()
作用就是测量放置itemView
。
初始化列表
我们先实现自己的布局参数:
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
也就是不实现,自带的RecyclerView.LayoutParams
继承自ViewGroup.MarginLayoutParams
,已经够用了。通过查看源码,最终这个方法返回的布局参数对象会设置给:
holder.itemView.setLayoutParams(rvLayoutParams);
然后实现onLayoutChildren()
,在里面要把所有itemView
没滑动前自身应该在的位置都记录并放置一遍:
定义两个集合:
// 用于保存item的位置信息
private SparseArray allItemRects = new SparseArray<>();
// 用于保存item是否处于可见状态的信息
private SparseBooleanArray itemStates = new SparseBooleanArray();
把所有View虚拟地放置一遍,记录下每个view的位置信息,因为此时并没有把View真正到recyclerview中,也是不可见的:
private void calculateChildrenSiteVertical(RecyclerView.Recycler recycler, RecyclerView.State state) {
// 先把所有的View先从RecyclerView中detach掉,然后标记为"Scrap"状态,表示这些View处于可被重用状态(非显示中)。
detachAndScrapAttachedViews(recycler);
for (int i = 0; i < getItemCount(); i++) {
View view = recycler.getViewForPosition(i);
// 测量View的尺寸。
measureChildWithMargins(view, 0, 0);
//去除ItemDecoration部分
calculateItemDecorationsForChild(view, new Rect());
int width = getDecoratedMeasuredWidth(view);
int height = getDecoratedMeasuredHeight(view);
Rect mTmpRect = allItemRects.get(i);
if (mTmpRect == null) {
mTmpRect = new Rect();
}
mTmpRect.set(0, totalHeight, width, totalHeight + height);
totalHeight += height;
// 保存ItemView的位置信息
allItemRects.put(i, mTmpRect);
// 由于之前调用过detachAndScrapAttachedViews(recycler),所以此时item都是不可见的
itemStates.put(i, false);
}
addAndLayoutViewVertical(recycler, state, 0);
}
然后我们开始真正地添加View到RecyclerView中。为什么不在记录位置的时候添加呢?因为后添加的view如果和前面添加的view重叠,那么后添加的view会覆盖前者,和我们想要实现的层叠的效果是相反的,所以需要正向记录位置信息,然后根据位置信息反向添加View:
private void addAndLayoutViewVertical(RecyclerView.Recycler recycler, RecyclerView.State state) {
int displayHeight = getWidth() - getPaddingLeft() - getPaddingRight();//计算recyclerView可以放置view的高度
//反向添加
for (int i = getItemCount() - 1; i >= 0; i--) {
// 遍历Recycler中保存的View取出来
View view = recycler.getViewForPosition(i);
//因为刚刚进行了detach操作,所以现在可以重新添加
addView(view);
//测量view的尺寸
measureChildWithMargins(view, 0, 0);
int width = getDecoratedMeasuredWidth(view); // 计算view实际大小,包括了ItemDecorator中设置的偏移量。
int height = getDecoratedMeasuredHeight(view);
//调用这个方法能够调整ItemView的大小,以除去ItemDecorator距离。
calculateItemDecorationsForChild(view, new Rect());
Rect mTmpRect = allItemRects.get(i);//取出我们之前记录的位置信息
if (mTmpRect.bottom > displayHeight) {
//排到底了,后面统一置底
layoutDecoratedWithMargins(view, 0, displayHeight - height, width, displayHeight);
} else {
//按原位置放置
layoutDecoratedWithMargins(view, 0, mTmpRect.top, width, mTmpRect.bottom);
}
Log.e(TAG, "itemCount = " + getChildCount());
}
这样一来,编译运行,界面上已经能看到列表了,就是它还不能滚动,只能停留在顶部。
处理滚动
先设置允许纵向滚动:
@Override
public boolean canScrollVertically() {
// 返回true表示可以纵向滑动
return orientation == OrientationHelper.VERTICAL;
}
处理滚动原理其实很简单:
- 手指在屏幕上滑动,系统告诉我们一个滑动的距离
- 我们根据这个距离判断我们列表内部各个view的实际变化,然后和
onLayoutChildren()
一样重新布局就行 - 返回告诉系统我们滑动了多少,如果返回0,就说明滑到边界了,就会有一个边缘的波纹效果。
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//列表向下滚动dy为正,列表向上滚动dy为负,这点与Android坐标系保持一致。
//dy是系统告诉我们手指滑动的距离,我们根据这个距离来处理列表实际要滑动的距离
int tempDy = dy;
//最多滑到总距离减去列表距离的位置,即可滑动的总距离是列表内容多余的距离
if (verticalScrollOffset <= totalHeight - getVerticalSpace()) {
//将竖直方向的偏移量+dy
verticalScrollOffset += dy;
}
if (verticalScrollOffset > totalHeight - getVerticalSpace()) {
verticalScrollOffset = totalHeight - getVerticalSpace();
tempDy = 0;//滑到底部了,就返回0,说明到边界了
} else if (verticalScrollOffset < 0) {
verticalScrollOffset = 0;
tempDy = 0;//滑到顶部了,就返回0,说明到边界了
}
//重新布局位置、显示View
addAndLayoutViewVertical(recycler, state, verticalScrollOffset);
return tempDy;
}
上面说了,滚动其实就是根据滑动距离重新布局的过程,和onLayoutChildren()
中的初始化布局没什么两样。我们扩展布局方法,传入偏移量,这样onLayoutChildren()
调用时只要传0就行了:
private void addAndLayoutViewVertical(RecyclerView.Recycler recycler, RecyclerView.State state, int offset) {
int displayHeight = getVerticalSpace();
for (int i = getItemCount() - 1; i >= 0; i--) {
// 遍历Recycler中保存的View取出来
View view = recycler.getViewForPosition(i);
addView(view); // 因为刚刚进行了detach操作,所以现在可以重新添加
measureChildWithMargins(view, 0, 0); // 通知测量view的margin值
int width = getDecoratedMeasuredWidth(view); // 计算view实际大小,包括了ItemDecorator中设置的偏移量。
int height = getDecoratedMeasuredHeight(view);
Rect mTmpRect = allItemRects.get(i);
//调用这个方法能够调整ItemView的大小,以除去ItemDecorator。
calculateItemDecorationsForChild(view, new Rect());
int bottomOffset = mTmpRect.bottom - offset;
int topOffset = mTmpRect.top - offset;
if (bottomOffset > displayHeight) {//滑到底了
layoutDecoratedWithMargins(view, 0, displayHeight - height, width, displayHeight);
} else {
if (topOffset <= 0 ) {//滑到顶了
layoutDecoratedWithMargins(view, 0, 0, width, height);
} else {//中间位置
layoutDecoratedWithMargins(view, 0, topOffset, width, bottomOffset);
}
}
Log.e(TAG, "itemCount = " + getChildCount());
}
好了,这样就能滚动了。
小结
因为自定义layoutManager
内容比较多,所以我分成了上下篇来讲。到这里基础效果实现了,但是这个RecyclerView还没有实现回收复用(参看addAndLayoutViewVertical
末尾打印),还有边缘的层叠嵌套动画和视觉处理也都留到下篇说了。看了上面的内容,实现横向滚动也是很简单的,感兴趣的自己去github上看下实现吧!
Github地址