作者 | Dajavu
地址 | http://www.jianshu.com/p/7bb7556bbe10
声明 | 本文是 Dajavu 原创,已获授权发布,未经原作者允许请勿转载
前言
我们都知道 RecyclerView 可以通过将 LayoutManager 设置为StaggeredGridLayoutManager 来实现瀑布流的效果。默认的还有LinearLayoutManager 用于实现线性布局,GridLayoutManager 用于实现网格布局。
然而RecyclerView可以做的不仅限于此,通过重写LayoutManager我们可以按自己的意愿实现更为复杂的效果。而且将控件与其显示效果解耦之后我们就可以动态的改变其显示效果。
设想有这么一个界面,以列表形式展示了一系列的数据,点击一个按钮后以网格形势显示另一组数据。传统的做法可能是在同一布局下设置了一个listview和一个gridview然后通过按钮点击事件切换他们的visiblity属性。而如果使用recyclerview的话你只需通过setAdapter方法改变数据,setLayoutManager方法改变样式即可,这样不仅简化了布局也实现了逻辑上的简洁。
效果图
下面我们就来介绍怎么通过重写一个 LayoutManager 来实现一个弧形的recycylerview 以及另一个会随着滚动在指定位置缩放的 recyclerview。并实现类似 viewpager 的回弹效果。
项目地址 Github
https://github.com/leochuan/CustomLayoutManager
通常重写一个 LayoutManager 可以分为以下几个步骤
指定默认的LayoutParams
测量并记录每个item的信息
回收以及放置各个item
处理滚动
当你继承LayoutManager之后,有一个必须重写的方法
generateDefaultLayoutParams()
这个方法指定了每一个子view默认的LayoutParams,并且这个LayoutParams会在你调用getViewForPosition()返回子view前应用到这个子view。
接下来我们需要重写onLayoutChildren()这个方法。这是LayoutManager的主要入口,他会在初始化布局以及adapter数据发生改变(或更换adapter)的时候调用。所以我们在这个方法中对我们的item进行测量以及初始化。
在贴代码前有必要先提一下,recycler有两种缓存的机制,scrap heap 以及recycle pool。相比之下scrap heap更轻量一点,他会直接将当前的view缓存而不通过adapter,当一个view被detach之后就会暂存进scrap heap。而recycle pool所存储的view,我们一般认为里面存的是错误的数据(这个view之后需要拿出来重用显示别的位置的数据),所以这里面的view会被传给adapter进行数据的重新绑定,一般,我们将子view从其parent view中remove之后会将其存入recycler pool中。
当界面上我们需要显示一个新的 view 时,recycler 会先检查 scrap heap中 position 相匹配的 view,如果有,则直接返回,如果没有 recycler会从recycler pool 中取一个合适的view,将其传递给 adapter,然后调用adapter的bindViewHolder()方法,绑定数据之后将其返回。
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {
detachAndScrapAttachedViews(recycler);
offsetRotate = 0;
return;
}
//calculate the size of child
if (getChildCount() == 0) {
View scrap = recycler.getViewForPosition(0);
addView(scrap);
measureChildWithMargins(scrap, 0, 0);
mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
startLeft = contentOffsetX == -1?(getHorizontalSpace() - mDecoratedChildWidth)/2: contentOffsetX;
startTop = contentOffsetY ==-1?0: contentOffsetY;
mRadius = mDecoratedChildHeight;
detachAndScrapView(scrap, recycler);
}
//record the state of each items
float rotate = firstChildRotate;
for (int i = 0; i < getItemCount(); i++) {
itemsRotate.put(i,rotate);
itemAttached.put(i,false);
rotate+= intervalAngle;
}
detachAndScrapAttachedViews(recycler);
fixRotateOffset();
layoutItems(recycler,state);
}
getItemCount()方法会调用adapter的getItemCount()方法,所以他获取到的是数据的总数,而getChildCount()方法则是获取当前已添加了的子View的数量。
因为在这个项目中所有view的大小都是一样的,所以就只测量了position为0的view的大小。itemsRotate用于记录初始状态下,每一个item的旋转角度,offsetRotate是旋转的偏移角度,每个item的旋转角加上这个偏移角度便是最后显示在界面上的角度,滑动过程中我们只需对应改变offsetRotate即可,itemAttached则用于记录这个item是否已经添加到当前界面。
private void layoutItems(RecyclerView.Recycler recycler,
RecyclerView.State state){
if(state.isPreLayout()) return;
//remove the views which out of range
for(int i = 0;i View view = getChildAt(i);
int position = getPosition(view);
if(itemsRotate.get(position) - offsetRotate>maxRemoveDegree
|| itemsRotate.get(position) - offsetRotate< minRemoveDegree){
itemAttached.put(position,false);
removeAndRecycleView(view,recycler);
}
}
//add the views which do not attached and in the range
int begin = getCurrentPosition() - MAX_DISPLAY_ITEM_COUNT / 2;
int end = getCurrentPosition() + MAX_DISPLAY_ITEM_COUNT / 2;
if(begin<0) begin = 0;
if(end > getItemCount()) end = getItemCount();
for(int i=begin;i if(itemsRotate.get(i) - offsetRotate<= maxRemoveDegree
&& itemsRotate.get(i) - offsetRotate>= minRemoveDegree){
if(!itemAttached.get(i)){
View scrap = recycler.getViewForPosition(i);
measureChildWithMargins(scrap, 0, 0);
addView(scrap);
float rotate = itemsRotate.get(i) - offsetRotate;
int left = calLeftPosition(rotate);
int top = calTopPosition(rotate);
scrap.setRotation(rotate);
layoutDecorated(scrap, startLeft + left, startTop + top,
startLeft + left + mDecoratedChildWidth, startTop + top + mDecoratedChildHeight);
itemAttached.put(i,true);
}
}
}
}
prelayout 是 recyclerview 绘制动画的阶段,因为这个项目不需要处理动画所以直接return。这里先是将当前已添加的子view中超出范围的那些remove掉并添加进recycle pool,(是的,只要调用removeAndRecycleView就行了),然后将所有item中还没有attach的view进行测量后,根据当前角度运用一下初中数学知识算出x,y坐标后添加到当前布局就行了。
现在我们的LayoutManager已经能按我们的意愿显示一个弧形的列表了,只是少了点生气。接下来我们就让他滚起来!
@Override
public boolean canScrollHorizontally() {
return true;
}
看名字就知道这个方法是用于设定能否横向滚动的,对应的还有canScrollVertically()这个方法。
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
int willScroll = dx;
float theta = dx/DISTANCE_RATIO; // the angle every item will rotate for each dx
float targetRotate = offsetRotate + theta;
//handle the boundary
if (targetRotate < 0) {
willScroll = (int) (-offsetRotate*DISTANCE_RATIO);
}
else if (targetRotate > getMaxOffsetDegree()) {
willScroll = (int) ((getMaxOffsetDegree() - offsetRotate)*DISTANCE_RATIO);
}
theta = willScroll/DISTANCE_RATIO;
offsetRotate+=theta; //increase the offset rotate so when re-layout it can recycle the right views
//re-calculate the rotate x,y of each items
for(int i=0;i View view = getChildAt(i);
float newRotate = view.getRotation() - theta;
int offsetX = calLeftPosition(newRotate);
int offsetY = calTopPosition(newRotate);
layoutDecorated(view, startLeft + offsetX, startTop + offsetY,
startLeft + offsetX + mDecoratedChildWidth, startTop + offsetY + mDecoratedChildHeight);
view.setRotation(newRotate);
}
//different direction child will overlap different way
layoutItems(recycler, state);
return willScroll;
}
如果是处理纵向滚动请重写scrollVerticallyBy这个方法。
在这里将滑动的距离按一定比例转换成滑动对应的角度,按滑动的角度重新绘制当前的子view,最后再调用一下layoutItems处理一下各个item的回收。
到这里一个弧形(圆形)的LayoutManager就写好了。滑动放大的layoutManager的实现与之类似,在中心点scale时最大,距离中心x坐标做差后取绝对值再转换为对应scale即可。
Bonuses
如果想实现类似于viewpager可以锁定到某一页的效果要怎么做?一开始想到对scrollHorizontallyBy()中的dx做手脚,但最后实现的效果很不理想。又想到重写并实现smoothScrollToPosition方法,然后给recyclerview设置滚动监听器在IDLE状态下调用smoothScrollToPosition。但最后滚动到的位置总会有偏移。
最后查阅API后发现recyclerView有一个smoothScrollBy方法,他会根据你给定的偏移量调用scrollHorizontallyBy以及scrollVerticallyBy。
所以我们可以重写一个OnScrollListener,然后给我们的recyclerView添加滚动监听器就可以了。
public class CenterScrollListener extends RecyclerView.OnScrollListener{
private boolean mAutoSet = true;
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
if(!(layoutManager instanceof CircleLayoutManager) && !(layoutManager instanceof ScrollZoomLayoutManager)){
mAutoSet = true;
return;
}
if(!mAutoSet){
if(newState == RecyclerView.SCROLL_STATE_IDLE){
if(layoutManager instanceof ScrollZoomLayoutManager){
final int scrollNeeded = ((ScrollZoomLayoutManager) layoutManager).getOffsetCenterView();
recyclerView.smoothScrollBy(scrollNeeded,0);
}else{
final int scrollNeeded = ((CircleLayoutManager)layoutManager).getOffsetCenterView();
recyclerView.smoothScrollBy(scrollNeeded,0);
}
}
mAutoSet = true;
}
if(newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING){
mAutoSet = false;
}
}
}
recyclerView.addOnScrollListener(new CenterScrollListener());
还需要在自定义的LayoutManager添加一个获取滚动偏移量的方法
完整代码已上传 Github
https://github.com/leochuan/CustomLayoutManager
参考资料
Building a RecyclerView LayoutManager
与之相关
1 四步准备 Android 面试
2 自定义 View:用贝塞尔曲线绘制酷炫轮廓背景
日
更
精
彩
微信号:code-xiaosheng
公众号
「code小生」