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

我们都知道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/)
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容

  • 简介: 提供一个让有限的窗口变成一个大数据集的灵活视图。 术语表: Adapter:RecyclerView的子类...
    酷泡泡阅读 5,136评论 0 16
  • 这篇文章分三个部分,简单跟大家讲一下 RecyclerView 的常用方法与奇葩用法;工作原理与ListView比...
    LucasAdam阅读 4,374评论 0 27
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,393评论 25 707
  • 从5月29号的第一篇文章,更到今天,历时大概1个半月,一共写了26篇文章、52650个字、获得343个喜欢、187...
    Arthurwu24阅读 531评论 5 5
  • 感恩这几天张欣怡的乖巧听话,每天早上六点半准时起床,晚上在晚托班又早早的把作业写完,回来等我检查签字。她现在...
    一丢一丢阅读 160评论 0 0