由旋转画廊,看自定义RecyclerView.LayoutManager

一、简介

前段时间需要一个旋转木马效果用于展示图片,于是第一时间在github上找了一圈,找了一个还不错的控件,但是使用起来有点麻烦,始终觉得很不爽,所以寻思着自己做一个轮子。想起旋转画廊的效果不是和横向滚动列表非常相似吗?那么是否可以利用RecycleView实现呢?

RecyclerView是google官方在support.v7中提供的一个控件,是ListView和GridView的升级版。该控件具有高度灵活、高度解耦的特性,并且还提供了添加、删除、移动的动画支持,分分钟让你作出漂亮的列表、九宫格、瀑布流。相信使用过该控件的人必定爱不释手。

先来看下如何简单的使用RecyclerView


RecyclerView listView = (RecyclerView)findViewById(R.id.lsit);
listView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
listView.setAdapter(new Adapter());

就是这么简单:

  1. 设置LayoutManager
  2. 设置Adapter(继承RecyclerView.Adapter)

其中,LayoutManager用于指定布局管理器,官方已经提供了几个布局管理器,可以满足大部分需求:

  • LinearLayoutManger:提供了竖向和横向线性布局(可实现ListView功能)
  • GridLayoutManager:表格布局(可实现GridView功能)
  • StaggeredGridLayoutManager:瀑布流布局

Adapter的定义与ListView的Adapter用法类似。

重点来看LayoutManage

LinearLayoutManager与其他几个布局管理器都是继承了该类,从而实现了对每个Item的布局。那么我们也可以通过自定义LayoutManager来实现旋转画廊的效果。

看下要实现的效果:

旋转画廊.gif

二、自定义LayoutManager

首先,我们来看看,自定义LayoutManager是什么样的流程:

  1. 计算每个Item的位置,并对Item布局。重写onLayoutChildren()方法

  2. 处理滑动事件(包括横向和竖向滚动、滑动结束、滑动到指定位置等)

    i.横向滚动:重写scrollHorizontallyBy()方法

    ii.竖向滚动:重写scrollVerticallyBy()方法

    iii.滑动结束:重写onScrollStateChanged()方法

    iiii.指定滚动位置:重写scrollToPosition()和smoothScrollToPosition()方法

  3. 重用和回收Item

  4. 重设Adapter 重写onAdapterChanged()方法

接下来,就来实现这个流程

第一步,定义CoverFlowLayoutManager继承RecyclerView.LayoutManager


public class CoverFlowLayoutManger extends RecyclerView.LayoutManager {
    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    }
}

继承LayoutManager后,会强制要求必须实现generateDefaultLayoutParams()方法,提供默认的Item布局参数,设置为Wrap_Content,由Item自己决定。

第二步,计算Item的位置和布局,并根据显示区域回收出界的Item

i.计算Item位置
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //如果没有item,直接返回
    //跳过preLayout,preLayout主要用于支持动画
    if (getItemCount() <= 0 || state.isPreLayout()) {
        mOffsetAll = 0;
        return;
    }
    mAllItemFrames.clear(); //mAllItemFrame存储了所有Item的位置信息
    mHasAttachedItems.clear(); //mHasAttachedItems存储了Item是否已经被添加到控件中

    //得到子view的宽和高,这里的item的宽高都是一样的,所以只需要进行一次测量
    View scrap = recycler.getViewForPosition(0);
    addView(scrap);
    measureChildWithMargins(scrap, 0, 0);
    //计算测量布局的宽高
    mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
    mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
    //计算第一个Item X轴的起始位置坐标,这里第一个Item居中显示
    mStartX = Math.round((getHorizontalSpace() - mDecoratedChildWidth) * 1.0f / 2);
    //计算第一个Item Y轴的启始位置坐标,这里为控件竖直方向居中
    mStartY = Math.round((getVerticalSpace() - mDecoratedChildHeight) *1.0f / 2);

    float offset = mStartX; //item X轴方向的位置坐标
    for (int i = 0; i < getItemCount(); i++) { //存储所有item具体位置
        Rect frame = mAllItemFrames.get(i);
        if (frame == null) {
            frame = new Rect();
        }
        frame.set(Math.round(offset), mStartY, Math.round(offset + mDecoratedChildWidth), mStartY + mDecoratedChildHeight);
        mAllItemFrames.put(i, frame); //保存位置信息
        mHasAttachedItems.put(i, false);
        //计算Item X方向的位置,即上一个Item的X位置+Item的间距
        offset = offset + getIntervalDistance();
    }

    detachAndScrapAttachedViews(recycler);

    layoutItems(recycler, state, SCROLL_RIGHT); //布局Item

    mRecycle = recycler; //保存回收器
    mState = state; //保存状态
}

以上,我们为Item的布局做了准备,计算了Item的宽高,以及首个Item的起始位置,并根据设置的Item间,计算每个Item的位置,并保存了下来。

接下来,来看看layoutItems()方法做了什么。

ii.布局和回收Item
private void layoutItems(RecyclerView.Recycler recycler,
                             RecyclerView.State state, int scrollDirection) {
        if (state.isPreLayout()) return;

    Rect displayFrame = new Rect(mOffsetAll, 0, mOffsetAll + getHorizontalSpace(), getVerticalSpace()); //获取当前显示的区域

    //回收或者更新已经显示的Item
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        int position = getPosition(child);

        if (!Rect.intersects(displayFrame, mAllItemFrames.get(position))) {
            //Item没有在显示区域,就说明需要回收
            removeAndRecycleView(child, recycler); //回收滑出屏幕的View
            mHasAttachedItems.put(position, false);
        } else { //Item还在显示区域内,更新滑动后Item的位置
            layoutItem(child, mAllItemFrames.get(position)); //更新Item位置
            mHasAttachedItems.put(position, true);
        }
    }

    for (int i = 0; i < getItemCount(); i++) {
        if (Rect.intersects(displayFrame, mAllItemFrames.get(i)) &&
                !mHasAttachedItems.get(i)) { //加载可见范围内,并且还没有显示的Item
            View scrap = recycler.getViewForPosition(i);
            measureChildWithMargins(scrap, 0, 0);
            if (scrollDirection == SCROLL_LEFT || mIsFlatFlow) {
                //向左滚动,新增的Item需要添加在最前面
                addView(scrap, 0);
            } else { //向右滚动,新增的item要添加在最后面
                addView(scrap);
            }
            layoutItem(scrap, mAllItemFrames.get(i)); //将这个Item布局出来
            mHasAttachedItems.put(i, true);
        }
    }
}

private void layoutItem(View child, Rect frame) {
    layoutDecorated(child,
            frame.left - mOffsetAll,
            frame.top,
            frame.right - mOffsetAll,
            frame.bottom);
        child.setScaleX(computeScale(frame.left - mOffsetAll)); //缩放
        child.setScaleY(computeScale(frame.left - mOffsetAll)); //缩放
}

第一个方法:在layoutItems()中
mOffsetAll记录了当前控件滑动的总偏移量,一开始mOffsetAll为0。

在第一个for循环中,先判断已经显示的Item是否已经超出了显示范围,如果是,则回收改Item,否则更新Item的位置。

在第二个for循环中,遍历了所有的Item,然后判断Item是否在当前显示的范围内,如果是,将Item添加到控件中,并根据Item的位置信息进行布局。

第二个方法:在layoutItem()中
调用了父类方法layoutDecorated对Item进行布局,其中mOffsetAll为整个旋转控件的滑动偏移量。

布局好后,对根据Item的位置对Item进行缩放,中间最大,距离中间越远,Item越小。

第三步,处理滑动事件

i. 处理横向滚动事件
由于旋转画廊只需横向滚动,所以这里只处理横向滚动事件
@Override
public boolean canScrollHorizontally() {
    return true;
}

@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
                                RecyclerView.State state) {
    if (mAnimation != null && mAnimation.isRunning()) mAnimation.cancel();
    int travel = dx;
    if (dx + mOffsetAll < 0) {
        travel = -mOffsetAll;
    } else if (dx + mOffsetAll > getMaxOffset()){
        travel = (int) (getMaxOffset() - mOffsetAll);
    }
    mOffsetAll += travel; //累计偏移量
    layoutItems(recycler, state, dx > 0 ? SCROLL_RIGHT : SCROLL_LEFT);
    return travel;
}

首先,需要告诉RecyclerView,我们需要接收横向滚动事件。
当用户滑动控件时,会回调scrollHorizontallyBy()方法对Item进行重新布局。

我们先忽略第一句代码,mAnimation用于处理滑动停止后Item的居中显示。

然后,我们判断了滑动距离dx,加上之前已经滚动的总偏移量mOffsetAll,是否超出所有Item可以滑动的总距离(总距离= Item个数 * Item间隔),对滑动距离进行边界处理,并将实际滚动的距离累加到mOffsetAll中。

当dx>0时,控件向右滚动,即<--;当dx<0时,控件向左滚动,即-->

接着,调用先前已经写好的布局方法layoutItems(),对Item进行重新布局。

最后,返回实际滑动的距离。

ii.处理滑动结束事件,将Item居中显示
@Override
public void onScrollStateChanged(int state) {
    super.onScrollStateChanged(state);
    switch (state){
        case RecyclerView.SCROLL_STATE_IDLE:
            //滚动停止时
            fixOffsetWhenFinishScroll();
            break;
        case RecyclerView.SCROLL_STATE_DRAGGING:
            //拖拽滚动时
            break;
        case RecyclerView.SCROLL_STATE_SETTLING:
            //动画滚动时
            break;
    }
}

private void fixOffsetWhenFinishScroll() {
    //计算滚动了多少个Item
    int scrollN = (int) (mOffsetAll * 1.0f / getIntervalDistance()); 
    //计算scrollN位置的Item超出控件中间位置的距离
    float moreDx = (mOffsetAll % getIntervalDistance());
    if (moreDx > (getIntervalDistance() * 0.5)) { //如果大于半个Item间距,则下一个Item居中
        scrollN ++;
    }
    //计算最终的滚动距离
    int finalOffset = (int) (scrollN * getIntervalDistance());
    //启动居中显示动画
    startScroll(mOffsetAll, finalOffset);
    //计算当前居中的Item的位置
    mSelectPosition = Math.round (finalOffset * 1.0f / getIntervalDistance());
}

通过onScrollStateChanged()方法,可以监听到控件的滚动状态,这里我们只需处理滑动停止事件。

在fixOffsetWhenFinishScroll()中,getIntervalDistance()方法用于获取Item的间距。
根据滚动的总距离除以Item的间距计算出总共滚动了多少个Item,然后启动居中显示动画。

private void startScroll(int from, int to) {
    if (mAnimation != null && mAnimation.isRunning()) {
        mAnimation.cancel();
    }
    final int direction = from < to ? SCROLL_RIGHT : SCROLL_LEFT;
    mAnimation = ValueAnimator.ofFloat(from, to);
    mAnimation.setDuration(500);
    mAnimation.setInterpolator(new DecelerateInterpolator());
    mAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            mOffsetAll = Math.round((float) animation.getAnimatedValue());
            layoutItems(mRecycle, mState, direction);
        }
    });
}

动画很简单,从滑动停止的位置,不断刷新Item布局,直到滚动到最终位置。

iii.处理指定位置滚动事件
@Override
public void scrollToPosition(int position) {
    if (position < 0 || position > getItemCount() - 1) return;
    mOffsetAll = calculateOffsetForPosition(position);
    if (mRecycle == null || mState == null) {
        //如果RecyclerView还没初始化完,先记录下要滚动的位置
        mSelectPosition = position;
    } else {
        layoutItems(mRecycle, mState, 
                    position > mSelectPosition ? SCROLL_RIGHT : SCROLL_LEFT);
    }
}

@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
    if (position < 0 || position > getItemCount() - 1) return;
    int finalOffset = calculateOffsetForPosition(position);
    if (mRecycle == null || mState == null) {
        //如果RecyclerView还没初始化完,先记录下要滚动的位置
        mSelectPosition = position;
    } else {
        startScroll(mOffsetAll, finalOffset);
    }
}

scrollToPosition()用于不带动画的Item直接跳转
smoothScrollToPosition()用于带动画Item滑动

也很简单,计算要跳转Item的所在位置需要滚动的距离,如果不需要动画,则直接对Item进行布局,否则启动滑动动画。

第四,处理重新设置Adapter

当重新调用RecyclerView的setAdapter时,需要对LayoutManager的所有状态进行重置

@Override
public void onAdapterChanged(RecyclerView.Adapter oldAdapter,  
                             RecyclerView.Adapter newAdapter) {
    removeAllViews();
    mRecycle = null;
    mState = null;
    mOffsetAll = 0;
    mSelectPosition = 0;
    mLastSelectPosition = 0;
    mHasAttachedItems.clear();
    mAllItemFrames.clear();
}

清空所有的Item,已经所有存放的位置信息和状态。

最后RecyclerView会重新调用onLayoutChildren()进行布局。

以上,就是自定义LayoutManager的流程,但是,为了实现旋转画廊的功能,只自定义了LayoutManager是不够的。旋转画廊中,每个Item是有重叠部分的,因此会有Item绘制顺序的问题,如果不对Item的绘制顺序进行调整,将出现中间Item被旁边Item遮挡的问题。

为了解决这个问题,需要重写RecyclerView的getChildDrawingOrder()方法,对Item的绘制顺序进行调整。

三、重写RecyclerView

这里简单看下如何如何改变Item的绘制顺序,具体可以查看源码

public class RecyclerCoverFlow extends RecyclerView {
    public RecyclerCoverFlow(Context context) {
        super(context);
        init();
    }

    public RecyclerCoverFlow(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RecyclerCoverFlow(Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
    }
    
    private void init() {
        ......
        
        setChildrenDrawingOrderEnabled(true); //开启重新排序
        
        ......
    }

    @Override
    protected int getChildDrawingOrder(int childCount, int i) {
        //计算正在显示的所有Item的中间位置
        int center = getCoverFlowLayout().getCenterPosition()
                - getCoverFlowLayout().getFirstVisiblePosition();
        if (center < 0) center = 0;
        else if (center > childCount) center = childCount;
        int order;
        if (i == center) {
            order = childCount - 1;
        } else if (i > center) {
            order = center + childCount - 1 - i;
        } else {
            order = i;
        }
        return order;
    }
}

首先,需要调用setChildrenDrawingOrderEnabled(true); 开启重新排序功能。

接着,在getChildDrawingOrder()中,childCount为当前已经显示的Item数量,i为item的位置。
旋转画廊中,中间位置的优先级是最高的,两边item随着递减。因此,在这里,我们通过以上定义的LayoutManager计算了当前显示的Item的中间位置,然后对Item的绘制进行了重新排序。

最后将计算出来的顺序优先级返回给RecyclerView进行绘制。

总结

以上,通过旋转画廊控件,我们过了一遍自定义LayoutManager的流程。当然RecyclerView的强大远远不至于此,结合LayoutManager的横竖滚动事件还可以做出更多有趣的效果。

最后,奉上源码

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

推荐阅读更多精彩内容