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

96
Dajavu
0.8 2016.08.22 13:35* 字数 775

我们都知道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/)
日记本
Web note ad 1