自定义LayoutManager:实现弧形以及滑动放大效果RecyclerView
我们都知道RecyclerView
可以通过将LayoutManager
设置为StaggeredGridLayoutManager
来实现瀑布流的效果。默认的还有LinearLayoutManager
用于实现线性布局,GridLayoutManager
用于实现网格布局。
然而RecyclerView可以做的不仅限于此,通过重写LayoutManager
我们可以按自己的意愿实现更为复杂的效果。而且将控件与其显示效果解耦之后我们就可以动态的改变其显示效果。
设想有这么一个界面,以列表形式展示了一系列的数据,点击一个按钮后以网格形势显示另一组数据。传统的做法可能是在同一布局下设置了一个listview
和一个gridview
然后通过按钮点击事件切换他们的visiblity
属性。而如果使用recyclerview
的话你只需通过setAdapter
方法改变数据,setLayoutManager
方法改变样式即可,这样不仅简化了布局也实现了逻辑上的简洁。
下面我们就来介绍怎么通过重写一个LayoutManager
来实现一个弧形的recycylerview
以及另一个会随着滚动在指定位置缩放的recyclerview
。并实现类似Viewpager
的回弹效果。
通常重写一个LayoutManager
可以分为以下几个步骤
指定默认的
LayoutParams
测量并记录每个
item
的信息回收以及放置各个
item
处理滚动
指定默认的 LayoutParams
当你继承LayoutManager
之后,有一个必须重写的方法,generateDefaultLayoutParams()
。这个方法指定了每一个vie
w默认的LayoutParams
,并且这个LayoutParams
会在你调用getViewForPosition()
返回子view前应用到这个子view
。
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
测量并记录每个 item 的信息
接下来我们需要重写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()
方法,绑定数据之后将其返回。
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
是否已经添加到当前界面。
回收以及放置各个 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<getChildCount();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<end;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
坐标后添加到当前布局就行了。
private int calLeftPosition(float rotate){
return (int) (mRadius * Math.cos(Math.toRadians(90 - rotate)));
}
private int calTopPosition(float rotate){
return (int) (mRadius - mRadius * Math.sin(Math.toRadians(90 - rotate)));
}
处理滚动
现在我们的LayoutManager
已经能按我们的意愿显示一个弧形的列表了,只是少了点生气。接下来我们就让他滚起来!
public boolean canScrollHorizontally() {
return true;
}
看名字就知道这个方法是用于设定能否横向滚动的,对应的还有canScrollVertically()
这个方法。
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<getChildCount();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
即可。
private float calculateScale(int x){
int deltaX = Math.abs(x-(getHorizontalSpace() - mDecoratedChildWidth) / 2);
float diff = 0f;
if((mDecoratedChildWidth-deltaX)>0) diff = mDecoratedChildWidth-deltaX;
return (maxScale-1f)/mDecoratedChildWidth * diff + 1;
}
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;
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
添加一个获取滚动偏移量的方法
public int getCurrentPosition(){
return Math.round(offsetRotate / intervalAngle);
}
public int getOffsetCenterView(){
return (int) ((getCurrentPosition()*intervalAngle-offsetRotate)*DISTANCE_RATIO);
}
最后,查看完整代码,请戳:https://github.com/leochuan/ViewPagerLayoutManager
---END---
推荐阅读:
来了,Android 10 正式发布,新增黑暗模式、手势导航等功能