这效果炸了系列 豆瓣影音炫酷堆叠列表效果
本文作者
作者:勇朝陈
链接:
https://blog.csdn.net/ccy0122/article/details/90515386
本文由作者授权发布。
看到这篇文章指出的很多博文中自定义 LayoutManager 误区部分,一定是一篇好文。另外随着 RecyclerView 流行程度,自定义 LayoutManager 逐渐成为一项基本技能了,可以抽空设置到自己的 TODO LIST 中去。
效果预览
GIF:
可自己监听滚动编写效果,如修改成仿MacOS文件浏览:
使用
focusLayoutManager =
new FocusLayoutManager.Builder()
.layerPadding(dp2px(this, 14))
.normalViewGap(dp2px(this, 14))
.focusOrientation(FocusLayoutManager.FOCUS_LEFT)
.isAutoSelect(true)
.maxLayerCount(3)
.setOnFocusChangeListener(new FocusLayoutManager.OnFocusChangeListener() {
public void onFocusChanged(int focusdPosition, int lastFocusdPosition) {
}
})
.build();
recyclerView.setLayoutManager(focusLayoutManager);
各属性意义见图:
注意:因为item在不同区域随着滑动会有不同的缩放,所以实际layerPadding、normalViewGap会被缩放计算。
自备。
这个项目就我学习LayoutManager的实战项目。(断断续续学习过很多次,还是得实际编码才能掌握)
推荐几篇我觉得好的自定义LayoutManager文章:
1、 张旭童的掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API
https://blog.csdn.net/zxt0601/article/details/52948009
2、张旭童的掌握自定义LayoutManager(二) 实现流式布局
https://blog.csdn.net/zxt0601/article/details/52956504
3、陈小缘的自定义LayoutManager第十一式之飞龙在天
https://blog.csdn.net/u011387817/article/details/81875021
上面张旭童的文章里有指出很多自定义LayoutManager的误区、注意事项,我补充几点:
1、不要遍历ItemCount
这个真的,是我认为最关键的一个注意事项。
getItemCount获取到的是什么?
是列表的总item数量,它可能有几千条几万条,甚至某些情况使用者会特意重写getItemCount将其返回为Integer.MAX_VALUE(比如为了实现无限循环轮播)。
你之所以自定义LayoutManager而不自定义ViewGroup,就是为了不管itemCount多少你都能hold住。所以你不应该在布局相关代码中遍历ItemCount!!
诚然,遍历它往往可以获取很多有用的数据,对后续的布局的计算、子View是否在屏幕内等判断非常有用,但请尽量不要遍历它(除非你的LM够特殊)。
张旭童说的没错,很多文章都存在误导,我还看到过有篇”喜欢“数很多的文章里有类似这么一段代码:
for (int i = 0; i < getItemCount(); i++) {
View view = recycler.getViewForPosition(i);
addView(view);
......
???
对于初次布局,这不就是有多少item就onCreateViewHolder多少次了么。
缓存池总数 = item总数?之后的回收复用操作也没意义了。
2、注意调用getChildCount时机
在列表滚动时,一般都要判断子View是否还在屏幕内,若不在了则回收。那么获取子View的逻辑应该在detachAndScrapAttachedViews(or detachAndScrapView等)之前。
见下面代码的打印:
//分离全部的view,放入临时缓存
log("before。child count = " + getChildCount() + ";scrap count = " + recycler.getScrapList().size());
detachAndScrapAttachedViews(recycler);
log("after。child count = " + getChildCount() + ";scrap count = " + recycler.getScrapList().size());
//打印结果:
//before。child count = 5;scrap count = 0
//after。child count = 0;scrap count = 5
另外,不用多说,recycler.getViewForPosition应在detachAndScrapAttachedViews之后
3、回收子View小技巧
这是在陈小缘那篇文章里学到的:
可以直接把Recycler里面的mAttachedScrap全部放进mRecyclerPool中,因为我们在一开始就已经调用了detachAndScrapAttachedViews方法将当前屏幕中有效的ViewHolder全部放进了mAttachedScrap,而在重新布局的时候,有用的Holder已经被重用了,也就是拿出去了,这个mAttachedScrap中剩下的Holder,都是不需要layout的,所以可以把它们都回收进mRecyclerPool中。
实用哦。
(不知道对预布局是否有影响,但我代码中并没有判断过isPreLayout,也测试过notifyItemRemoved,动画正常)
先把上面的细节图重新贴一下
首先无视掉view的缩放、透明度变化。那么布局其实就这样:
我们称一个view从”普通view“滚动到”焦点view“为一次完整的聚焦滑动所需要移动的距离,定义其为onceCompleteScrollLength。
在普通view移动了一个onceCompleteScrollLength,堆叠View只移动了一个layerPadding。核心逻辑就这一句。
我们在scrollHorizontallyBy中记录偏移量dx,保存一个累计偏移量mHorizontalOffset,然后用该偏移量除以onceCompleteScrollLength,就知道当前已经滚动了多少个item了,换句话说就是屏幕内第一个可见view的position知道了。
同时能计算出一个onceCompleteScrollLength已经滚动了的百分比fraction,再用这个百分比换算出堆叠区域和普通区域布局起始位置的偏移量,然后可以开始布局了,对于堆叠区域的view,彼此之间距离一个layerPadding,对于普通区域view,彼此之间距离一个onceCompleteScrollLength。
见代码:
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
RecyclerView.State state) {
//手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
//位移0、没有子View 当然不移动
if (dx == 0 || getChildCount() == 0) {
return 0;
}
mHorizontalOffset += dx;//累加实际滑动距离
dx = fill(recycler, state, dx);
return dx;
}
/**
* @param recycler
* @param state
* @param delta
*/
private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int delta) {
int resultDelta = delta;
//省略
resultDelta = fillHorizontalLeft(recycler, state, delta);
//省略
return resultDelta;
}
/**
* 水平滚动、向左堆叠布局
*
* @param recycler
* @param state
* @param dx 偏移量。手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
*/
private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state,
int dx) {
//----------------1、边界检测-----------------
if (dx < 0) {
//已达左边界
if (mHorizontalOffset < 0) {
mHorizontalOffset = dx = 0;
}
}
if (dx > 0) {
//滑动到只剩堆叠view,没有普通view了,说明已经到达右边界了
if (mLastVisiPos - mFirstVisiPos <= maxLayerCount - 1) {
//因为scrollHorizontallyBy里加了一次dx,现在减回去
mHorizontalOffset -= dx;
dx = 0;
}
}
//分离全部的view,放入临时缓存
detachAndScrapAttachedViews(recycler);
//----------------2、初始化布局数据-----------------
float startX = getPaddingLeft() - layerPadding;
View tempView = null;
int tempPosition = -1;
if (onceCompleteScrollLength == -1) {
//因为mFirstVisiPos在下面可能会被改变,所以用tempPosition暂存一下。
tempPosition = mFirstVisiPos;
tempView = recycler.getViewForPosition(tempPosition);
measureChildWithMargins(tempView, 0, 0);
onceCompleteScrollLength = getDecoratedMeasurementHorizontal(tempView) + normalViewGap;
}
//当前"一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT,从右向左移动fraction将从0%到100%)
float fraction =
(Math.abs(mHorizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f);
//堆叠区域view偏移量。在一次完整的聚焦滑动期间,其总偏移量是一个layerPadding的距离
float layerViewOffset = layerPadding * fraction;
//普通区域view偏移量。在一次完整的聚焦滑动期间,其总位移量是一个onceCompleteScrollLength
float normalViewOffset = onceCompleteScrollLength * fraction;
boolean isLayerViewOffsetSetted = false;
boolean isNormalViewOffsetSetted = false;
//修正第一个可见的view:mFirstVisiPos。已经滑动了多少个完整的onceCompleteScrollLength就代表滑动了多少个item
mFirstVisiPos = (int) Math.floor(Math.abs(mHorizontalOffset) / onceCompleteScrollLength); //向下取整
//临时将mLastVisiPos赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局
mLastVisiPos = getItemCount() - 1;
//...省略监听回调
//----------------3、开始布局-----------------
for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
//属于堆叠区域
if (i - mFirstVisiPos < maxLayerCount) {
View item;
if (i == tempPosition && tempView != null) {
//如果初始化数据时已经取了一个临时view,可别浪费了!
item = tempView;
} else {
item = recycler.getViewForPosition(i);
}
addView(item);
measureChildWithMargins(item, 0, 0);
startX += layerPadding;
if (!isLayerViewOffsetSetted) {
startX -= layerViewOffset;
isLayerViewOffsetSetted = true;
}
//...省略监听回调
int l, t, r, b;
l = (int) startX;
t = getPaddingTop();
r = (int) (startX + getDecoratedMeasurementHorizontal(item));
b = getPaddingTop() + getDecoratedMeasurementVertical(item);
layoutDecoratedWithMargins(item, l, t, r, b);
} else {//属于普通区域
View item = recycler.getViewForPosition(i);
addView(item);
measureChildWithMargins(item, 0, 0);
startX += onceCompleteScrollLength;
if (!isNormalViewOffsetSetted) {
startX += layerViewOffset;
startX -= normalViewOffset;
isNormalViewOffsetSetted = true;
}
//...省略监听回调
int l, t, r, b;
l = (int) startX;
t = getPaddingTop();
r = (int) (startX + getDecoratedMeasurementHorizontal(item));
b = getPaddingTop() + getDecoratedMeasurementVertical(item);
layoutDecoratedWithMargins(item, l, t, r, b);
//判断下一个view的布局位置是不是已经超出屏幕了,若超出,修正mLastVisiPos并跳出遍历
if (startX + onceCompleteScrollLength > getWidth() - getPaddingRight()) {
mLastVisiPos = i;
break;
}
}
}
return dx;
}
因为measure、layout调用的都是考虑了margin的api,所以布局时也要考虑到margin:
/**
* 获取某个childView在水平方向所占的空间,将margin考虑进去
*
* @param view
* @return
*/
public int getDecoratedMeasurementHorizontal(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return getDecoratedMeasuredWidth(view) + params.leftMargin
+ params.rightMargin;
}
/**
* 获取某个childView在竖直方向所占的空间,将margin考虑进去
*
* @param view
* @return
*/
public int getDecoratedMeasurementVertical(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return getDecoratedMeasuredHeight(view) + params.topMargin
+ params.bottomMargin;
}
用上面讲的回收技巧:
/**
* @param recycler
* @param state
* @param delta
*/
private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int delta) {
int resultDelta = delta;
//。。。省略
recycleChildren(recycler);
log("childCount= [" + getChildCount() + "]" + ",[recycler.getScrapList().size():" + recycler.getScrapList().size());
return resultDelta;
}
/**
* 回收需回收的Item。
*/
private void recycleChildren(RecyclerView.Recycler recycler) {
List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList();
for (int i = 0; i < scrapList.size(); i++) {
RecyclerView.ViewHolder holder = scrapList.get(i);
removeAndRecycleView(holder.itemView, recycler);
}
}
接下来验证下。
验证1
张旭童:通过getChildCount()和recycler.getScrapList().size() 查看当前屏幕上的Item数量 和 scrapCache缓存区域的Item数量,合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0.
编写log并打印:
childCount= [5],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [5],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
合格。
验证2
用最直接的方法,打印onCreateViewHolder、onBindViewHolder看看到底复用了没:
public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.item_card,
viewGroup, false);
view.setTag(++index);
Log.d("ccy", "onCreateViewHolder = " + index);
return new ViewHolder(view);
}
public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
Log.d("ccy", "onBindViewHolder,index = " + (int) (viewHolder.itemView.getTag()));
}
在onCreateViewHolder创建view时,给他一个tag,然后onBindViewHolder中打印这个tag,以此查看是不用复用了view。打印如下
onCreateViewHolder = 1
onBindViewHolder,index = 1
onCreateViewHolder = 2
onBindViewHolder,index = 2
onCreateViewHolder = 3
onBindViewHolder,index = 3
onCreateViewHolder = 4
onBindViewHolder,index = 4
onCreateViewHolder = 5
onBindViewHolder,index = 5
onCreateViewHolder = 6
onBindViewHolder,index = 6
onCreateViewHolder = 7
onBindViewHolder,index = 7
onCreateViewHolder = 8
onBindViewHolder,index = 8
onBindViewHolder,index = 1
onBindViewHolder,index = 2
onBindViewHolder,index = 3
onBindViewHolder,index = 4
onBindViewHolder,index = 5
onBindViewHolder,index = 6
onBindViewHolder,index = 7
onBindViewHolder,index = 8
onCreateViewHolder = 9
onBindViewHolder,index = 9
onBindViewHolder,index = 2
onBindViewHolder,index = 3
onBindViewHolder,index = 1
onBindViewHolder,index = 4
onBindViewHolder,index = 5
onBindViewHolder,index = 6
我测试时手机一屏内最多可见约6个,从打印中可见它最多调用了9次onCreateViewHolder,这个次数完全可以接受。并且onBindViewHolder也在复用view。完全ojbk没得问题
我做的动画,就是在滑动期间渐变view的缩放比例、透明度,使得view看上去像一层一层堆叠上去的样子。其实就是各种y = kx + b之类的计算,因为fill系列方法中已经计算出很多有用的数据了。
我的做法是,暴露出这么个接口:
/**
* 滚动过程中view的变换监听接口。属于高级定制,暴露了很多关键布局数据。若定制要求不高,考虑使用{@link SimpleTrasitionListener}
*/
public interface TrasitionListener {
/**
* 处理在堆叠里的view。
*
* @param focusLayoutManager
* @param view view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
* @param viewLayer 当前层级,0表示底层,maxLayerCount-1表示顶层
* @param maxLayerCount 最大层级
* @param position item所在的position
* @param fraction "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
* ,从右向左移动fraction将从0%到100%)
* @param offset 当次滑动偏移量
*/
void handleLayerView(FocusLayoutManager focusLayoutManager, View view, int viewLayer,
int maxLayerCount, int position, float fraction, float offset);
/**
* 处理正聚焦的那个View(即正处在从普通位置滚向聚焦位置时的那个view,即堆叠顶层view)
*
* @param focusLayoutManager
* @param view view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
* @param position item所在的position
* @param fraction "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
* ,从右向左移动fraction将从0%到100%)
* @param offset 当次滑动偏移量
*/
void handleFocusingView(FocusLayoutManager focusLayoutManager, View view, int position,
float fraction, float offset);
/**
* 处理不在堆叠里的普通view(正在聚焦的那个view除外)
*
* @param focusLayoutManager
* @param view view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
* @param position item所在的position
* @param fraction "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
* ,从右向左移动fraction将从0%到100%)
* @param offset 当次滑动偏移量
*/
void handleNormalView(FocusLayoutManager focusLayoutManager, View view, int position,
float fraction, float offset);
}
然后在fill系列方法的对应位置回调该接口即可:
/**
* 变换监听接口。
*/
private List<TrasitionListener> trasitionListeners;
/**
* 水平滚动、向左堆叠布局
*
* @param recycler
* @param state
* @param dx 偏移量。手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
*/
private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state,
int dx) {
//省略。。。。。
//----------------3、开始布局-----------------
for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
//属于堆叠区域
if (i - mFirstVisiPos < maxLayerCount) {
//省略。。。。。
if (trasitionListeners != null && !trasitionListeners.isEmpty()) {
for (TrasitionListener trasitionListener : trasitionListeners) {
trasitionListener.handleLayerView(this, item, i - mFirstVisiPos,
maxLayerCount, i, fraction, dx);
}
}
} else {//属于普通区域
//省略。。。。。
if (trasitionListeners != null && !trasitionListeners.isEmpty()) {
for (TrasitionListener trasitionListener : trasitionListeners) {
if (i - mFirstVisiPos == maxLayerCount) {
trasitionListener.handleFocusingView(this, item, i, fraction, dx);
} else {
trasitionListener.handleNormalView(this, item, i, fraction, dx);
}
}
}
}
}
return dx;
}
然后使用者可以自己注册该接口,天马行空。
那么我这个项目默认的动画具体实现是怎么样的呢?
先这样,再那样,效果就出来啦:
public void handleLayerView(FocusLayoutManager focusLayoutManager, View view,
int viewLayer, int maxLayerCount, int position,
float fraction, float offset) {
/**
* 期望效果:从0%开始到{@link SimpleTrasitionListener#getLayerChangeRangePercent()} 期间
* view均匀完成渐变,之后一直保持不变
*/
//转换为真实的渐变变化百分比
float realFraction;
if (fraction <= stl.getLayerChangeRangePercent()) {
realFraction = fraction / stl.getLayerChangeRangePercent();
} else {
realFraction = 1.0f;
}
float minScale = stl.getLayerViewMinScale(maxLayerCount);
float maxScale = stl.getLayerViewMaxScale(maxLayerCount);
float scaleDelta = maxScale - minScale; //总缩放差
float currentLayerMaxScale =
minScale + scaleDelta * (viewLayer + 1) / (maxLayerCount * 1.0f);
float currentLayerMinScale = minScale + scaleDelta * viewLayer / (maxLayerCount * 1.0f);
float realScale =
currentLayerMaxScale - (currentLayerMaxScale - currentLayerMinScale) * realFraction;
float minAlpha = stl.getLayerViewMinAlpha(maxLayerCount);
float maxAlpha = stl.getLayerViewMaxAlpha(maxLayerCount);
float alphaDelta = maxAlpha - minAlpha; //总透明度差
float currentLayerMaxAlpha =
minAlpha + alphaDelta * (viewLayer + 1) / (maxLayerCount * 1.0f);
float currentLayerMinAlpha = minAlpha + alphaDelta * viewLayer / (maxLayerCount * 1.0f);
float realAlpha =
currentLayerMaxAlpha - (currentLayerMaxAlpha - currentLayerMinAlpha) * realFraction;
// log("layer =" + viewLayer + ";alpha = " + realAlpha + ";fraction = " + fraction);
view.setScaleX(realScale);
view.setScaleY(realScale);
view.setAlpha(realAlpha);
}
哈哈哈。代码中stl 存储着堆叠区域view、焦点view、普通view的最大和最小缩放比、透明度,然后利用fraction计算出当前位置真实的缩放比、透明度设置之。
上面只贴了堆叠区域view的实现,完整实现见源码中的TrasitionListenerConvert
1、滚动停止后自动选中
我的实现方式是这样的:监听onScrollStateChanged,在滚动停止时计算出应当停留的position,再计算出停留时的mHorizontalOffset值,播放属性动画将当前mHorizontalOffset不断更新至最终值即可。
具体代码参考源码中的onScrollStateChanged和smoothScrollToPosition。
(思考:能通过自定义SnapHelper实现么?)
2、点击非焦点view自动将其选中为焦点view
已经实现了setFocusdPosition方法。内部逻辑就是计算出实际position并调用smoothScrollToPosition或scrollToPosition 。
示例代码:
public ViewHolder(@NonNull final View itemView) {
super(itemView);
itemView.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
int pos = getAdapterPosition();
if (pos == focusLayoutManager.getFocusdPosition()) {
//是焦点view
} else {
focusLayoutManager.setFocusdPosition(pos, true);
}
}
});
}
因为FocusLayoutManager内部没有遍历itemCount这种bad操作,你可以自己通过重写getItemCount返回Integer.MAX_VALUE实现伪无限循环。
示例代码:
public void initView(){
recyclerView.post(new Runnable() {
public void run() {
focusLayoutManager.scrollToPosition(1000); //差不多大行了,毕竟mHorizontalOffset是会一直累加的
}
});
}
public class Adapter extends RecyclerView.Adapter<Adapter.ViewHolder> {
public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
int realPosition = position % datas.size();
Bean bean = datas.get(realPosition);
//...
}
public int getItemCount() {
return Integer.MAX_VALUE;
}
}
按目前布局逻辑,开头的position = 0 到position = maxLayerCount - 1个view永远只能在堆叠区域,没法拉出来到焦点view。解决方式也简单,给你的源数据开头插入maxLayerCount - 1个假数据,然后当adapter中识别到假数据时让其布局不可见即可
结束
剩下的三个堆叠方向的实现就是加加减减的变化,不用贴出来了。
给个赞呗~
给个star呗~
项目地址:
https://github.com/CCY0122/FocusLayoutManager
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
推荐阅读:
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!