美文网首页Android TechAndroid UI资料 | 汇总
自定义LayoutManager 实现弧形以及滑动放大效果Rec

自定义LayoutManager 实现弧形以及滑动放大效果Rec

作者: Dajavu | 来源:发表于2016-08-22 13:35 被阅读9432次

我们都知道RecyclerView可以通过将LayoutManager设置为StaggeredGridLayoutManager来实现瀑布流的效果。默认的还有LinearLayoutManager用于实现线性布局,GridLayoutManager用于实现网格布局。

然而RecyclerView可以做的不仅限于此,通过重写LayoutManager我们可以按自己的意愿实现更为复杂的效果。而且将控件与其显示效果解耦之后我们就可以动态的改变其显示效果。

设想有这么一个界面,以列表形式展示了一系列的数据,点击一个按钮后以网格形势显示另一组数据。传统的做法可能是在同一布局下设置了一个listview和一个gridview然后通过按钮点击事件切换他们的visiblity属性。而如果使用recyclerview的话你只需通过setAdapter方法改变数据,setLayoutManager方法改变样式即可,这样不仅简化了布局也实现了逻辑上的简洁。

下面我们就来介绍怎么通过重写一个LayoutManager来实现一个弧形的recycylerview以及另一个会随着滚动在指定位置缩放的recyclerview。并实现类似viewpager的回弹效果。

弧形 缩放

项目地址 Github

通常重写一个LayoutManager可以分为以下几个步骤

  • 指定默认的LayoutParams
  • 测量并记录每个item的信息
  • 回收以及放置各个item
  • 处理滚动

指定默认的 LayoutParams

当你继承LayoutManager之后,有一个必须重写的方法

generateDefaultLayoutParams()

这个方法指定了每一个子view默认的LayoutParams,并且这个LayoutParams会在你调用getViewForPosition()返回子view前应用到这个子view。

@Override
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()方法,绑定数据之后将其返回。

@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是否已经添加到当前界面。

####回收以及放置各个 item
```Java
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坐标后添加到当前布局就行了。
```Java
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已经能按我们的意愿显示一个弧形的列表了,只是少了点生气。接下来我们就让他滚起来!
```Java
@Override
    public boolean canScrollHorizontally() {
        return true;
    }```
看名字就知道这个方法是用于设定能否横向滚动的,对应的还有canScrollVertically()这个方法。

```Java
@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<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即可。

```Java
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添加滚动监听器就可以了。
```Java
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;
        }
    }
}```
```Java
recyclerView.addOnScrollListener(new CenterScrollListener());```
还需要在自定义的LayoutManager添加一个获取滚动偏移量的方法
```Java
public int getCurrentPosition(){
        return Math.round(offsetRotate / intervalAngle);
    }

public int getOffsetCenterView(){
        return (int) ((getCurrentPosition()*intervalAngle-offsetRotate)*DISTANCE_RATIO);
    }```
完整代码已上传 [Github](https://github.com/leochuan/CustomLayoutManager) 
### 参考资料
[Building a RecyclerView LayoutManager](http://wiresareobsolete.com/2014/09/building-a-recyclerview-layoutmanager-part-1/)

相关文章

网友评论

  • 554cb745d1a4:能讲一下无限滚动的原理吗?
  • 波澜步惊:先顶再看好习惯!
  • 2db008de8543:有个很严重的BUG,就是用了
    rv_wine.setLayoutManager(new ScaleLayoutManager());
    再用 new CenterSnapHelper().attachToRecyclerView(mRecyclerView)之后,RecyclerView的每个item的点击事件经常会失效
  • 海晨忆:博主,你好,我现在只想要一个逆时针旋转90度的layoutManager,我再用的时候,manager.setRadius(400);
    manager.setMaxRemoveAngle(0);
    manager.setMinRemoveAngle(-90);
    另一半不显示,但是还是占位置,我不想让他占位置,要怎么弄?我后面设置margin值,想把占位置的部分挤到屏幕外面去,设置之后,左边显示的部分也显示不完全,要怎么解决啊?
    海晨忆:@Dajavu 在线等
    海晨忆:@Dajavu 大佬,这个问题我找出来了,还有两个问题,一个是Recycler的高度和我设置的半径的问题,如果recycler的高度设置低了,我设置的半径高了,回显示不全。能不能我的recycler的高度就是我的半径。另一个问题,就是我一共设置10条数据,当我的第一条数据到了屏幕最左边的时候,右滑就不让他滑动了,第一条数据始终都在最左边,然后,向左可以正常滑动。这里回复不方便的话,可以加我qq371965177。大佬
    Dajavu:@海灬琼 现在默认是居中的,我还没有直接提供设置位置的 api,但是如果你真的想改也是可以的,你自定义一个 layoutmanager 继承自 CircleLayoutManager。然后重写setUp方法,
    @Override
    protected void setUp() {
    super.setUp();
    mSpaceMain = mOrientationHelper.getTotalSpace() - mDecoratedMeasurement;
    }
  • f3dc7e7d32a7:我是新手,想问下,为什么ViewPagerLayoutManager中有好多方法是原封不动的重写父类,有何特殊作用?
    f3dc7e7d32a7:@Dajavu :smile: 我看的是github上的代码,我很喜欢这个设计思路,赞一个!
    Dajavu:@静听流水 没啥特殊作用,原来ViewPagerLayoutManager是继承自LayoutManager的,但是后面为了支持wrap_content和match_parent需要重写LayoutManager的一个方法,但是这个方法是package作用域的,我并不能重写,注释说给package作用域的原因是会在之后的版本去掉这个方法。所以为了支持wrap_content和match_parent我选择了继承LinearLayoutManager。因为有些行为和LinearLayoutManager是一致的,之前从LinearLayoutManager那边复制过来的代码我没删,看起来就是我原封不动的重写了父类。没删的原因是因为等官方删了那个方法后我可能会把父类改回LayoutManager所以就留着了。然后这篇文章其实也没啥参考价值了,因为之后改动很大,代码已经对不上了。最近没什么时间,之后有时间可能会更新一下这篇文章。
  • ccbeb2ce36e7:在DialogFragment里 RecyclerView垂直排列 使用ScaleLayoutManager item宽度设置match_parent 但是在显示时并没有占满父View
  • 2742889678f5:博主,您好,RecycleView如将高度设置值后就显示不出来,这是为啥啊?
    Dajavu:布局文件 贴上来看看 recyclerview 和 item的都要
  • 1842561893ad:大神,我放在RecyclerView嵌套RecyclerView,然后现出来的时候,然后,加载出来时,里边嵌套的RecyclerView 只显示下半截,不显示上半截,并且,嵌套的RecyclerView的下边有空白多出来
    Dajavu:之前没试过wrap_content, 具体原因是wrap_content下测量模式为at_most,在`onLayoutChildren`里拿到的高度为屏幕高度,计算位置直接放在了中间,加上我设了`setAutoMeasureEnabled`为true,`recyclerView的onMeasure()`方法里的` mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);` 会计算加进去的子view的大小作为`setMeasuredDimension`的参数,导致recyclerView测量的结果为view的大小,所以只显示了一半,你先给recyclerView一个固定大小,这个问题就解决了,之后的更新我可能也会支持一下wrap_content
    Dajavu:因为我放置的时候会取一下recyclerView的高度然后放中间, 你可以先给你里面嵌套的那个recyclerView设置一个固定的高度
  • 13850909338e:大神有没有做过把这两个效果结合,就是弧形旋转带3g效果的,类似js
    弧形轮播图效果?请求大神指点
    Dajavu:不太清楚。。你说的是什么效果:joy: 有具体例子可以给我看看嘛
  • 地球是猿的:文章中的代码和github代码基本对不上,改动比较大
    Dajavu:确实后来改了很多次,之后有空会把这篇文章更新下,不过这个库写的其实也不是很好,一直想等有时间重构下,但最近都比较忙
  • 479ae72f70bc:addview 会调用 onlayoutchildren吗
  • boboyuwu://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);
    }
    这个if (getChildCount() == 0)这么判断是什么意思呢?
    Dajavu:这个是取recyclerview的子view数量 等于0的话说明还没有view添加过,或者全部移走了。可以看到我在onAdapterChanged里面调用了removeAllViews();因为换adapter意味着子view的样式可能也发生了改变需要重新测量。
  • boboyuwu: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); }为啥要add这个view呢
    Dajavu:因为我假设并限制了所有的view的大小必须相同,这里取了第一个view加进来测量了一下宽高,用于设置startLeft和startTop来居中。measureChildWithMargins(View child, int widthUsed, int heightUsed)这个方法,你点进去可以看到注释说是用于测量子view,计算时会算上recyclerview的padding子view的margin以及添加item decorations。所以就先添加了一下这个view
  • boboyuwu:看了很多遍,关于detach 和recycle使用还是很迷糊求大神给我解解惑
  • f12bc32d63e1:怎么获取到中间的view进行效果的改变,有没有这个监听啊?
    Dajavu:效果的改变是在继承ViewPagerLayoutManager的子类中实现的,几个默认的LayoutManager并没有给出这个监听,你可以自己写一下,在setItemViewProperty这个方法下把属性回调出去就好了
  • 1f0bac4098d9:大神,最近项目中用到了你写的横向滑动缩放的效果,本来已经集成好了,但是老板还需要是默认显示第二张图片,已经琢磨了3天了。。还是想不出来,怎么让他默认显示到第二张呢
  • 胆子哥:大神,我想获取中间那个item里面的数据,这个怎么做啊?
    胆子哥: @Dajavu 比如,我滚动停止后,我要得到中间那个条目的信息,然后显示到下面的控件上,我这点弄不出来了,
    胆子哥:比如,我滚动停止后,我要得到中间那个条目的信息,然后显示到下面的控件上,我这点弄不出来了,
    Dajavu:不好意思啊,简书上的比较少,什么数据???
  • 苏易川:大赞!!!!
  • 57994451de99:大牛真是及时雨,不过遇到一个问题,RecyclerView一屏显示多个item的时候,zoom缩放的item就不是中间位置,一直是最右边位置,怎么解决,急求
    Dajavu:@57994451de99 好像是之前改的一个版本的bug,我昨天修复了 我把整个逻辑抽离了 你pull一下我的更新 应该好了
  • Tenny1225:请问item view叠加排列是怎么解决滑动时item view闪动的问题
    Dajavu:@tenny1109 在重用的过程中,层级是按addView的顺序来的,我这边默认应该都是按一个方向添加的,你可以看下customeLayoutManager里的layoutItems方法,按你自己的需求修改一下
    Tenny1225: @Dajavu 就是item view在重用的过程中层级不同,所以滑动时一直闪动,尝试使用setZ可以解决,但是这个方法在4.4版本以后才可以使用
    Dajavu:@tenny1109 可以描述的再详细一点吗?
  • 恋猫月亮:支持支持
  • 201b7930efb9:搂住,可以实现类似3D画廊效果吗
    Dajavu:@201b7930efb9 可以啊,你可以试着分析一下3D画廊几个关键item的运动模式(看了下你说的效果应该是根据偏移量改变view的ratationY属性),然后参照我这篇文章写一个layoutmanager,我写它的目的也是为了跟大家分享一下怎么按需求定义一个自己的layoutmanager。
  • 渡过:有个问题,如果我的数据量很大,即getChildCount很大,你在scrollHorizontallyBy里面遍历,那么滑动的时候会非常卡
    Dajavu:@渡过 不好意思,简书上的比较少,不能换,不过确实没必要更新所有数据的状态,我空了再优化一下好了
    渡过:@Dajavu 嗯,不过在大数据滑动的时候确实是卡了,layoutItems这个方法中,有getItemCount的方法,是否是可以换成getChildCount?因为我在做循环轮播时,Adapter返回数据大一点,滑动就会很卡
    Dajavu:@渡过 我文章里有提到啊, getChildCount返回的是recyclerview中已经添加了的用于复用的view的数量,你数据量再大对getChildCount的数量是不影响的。getItemCount返回的才是数据的数量,你看源码会发现他会取Adapter.getItemCount()
  • 024a6bba9544:问个问题?
    顺时针圆弧咋实现了
    024a6bba9544:@Dajavu 我要实现一个 在椭圆上滚动的列表,一屏只显示五个,五个里面中间的那个放大
    Dajavu:@魂魄 anyway,等我空了我更新一下加个flip参数好了
    Dajavu:@魂魄 你指初始就可以顺时针滑动是嘛,所以你得让数据逆时针排布。你需要在onLayoutChildren修改itemsRotate记录所有数据的初始角度,修改可滑动的最大偏移角度,以及在layoutItems中改变他们的放置位置。比较简便的方法是你可以初始化后调用scrollToPosition方法跳转到最后一项。
  • 捡淑:66666
  • L_Xian:和viewpage有滑动冲突啊
    Dajavu:@L_Xian 我测试了一下 没有冲突啊
    L_Xian: @Dajavu 是滴
    Dajavu:@L_Xian 你是在viewpager里嵌套了recyclerview?
  • 3ee6e9ef0293:略厉害 :+1:

本文标题:自定义LayoutManager 实现弧形以及滑动放大效果Rec

本文链接:https://www.haomeiwen.com/subject/ptxwsttx.html