这效果炸了系列 豆瓣影音炫酷堆叠列表效果

来自:勇朝陈

看到这篇文章指出的很多博文中自定义 LayoutManager 误区部分,一定是一篇好文。另外随着 RecyclerView 流行程度,自定义 LayoutManager 逐渐成为一项基本技能了,可以抽空设置到自己的 TODO LIST 中去。


1
效果以及使用


效果预览



GIF: 





可自己监听滚动编写效果,如修改成仿MacOS文件浏览: 



使用


 focusLayoutManager =
                new FocusLayoutManager.Builder()
                        .layerPadding(dp2px(this14))
                        .normalViewGap(dp2px(this14))
                        .focusOrientation(FocusLayoutManager.FOCUS_LEFT)
                        .isAutoSelect(true)
                        .maxLayerCount(3)
                        .setOnFocusChangeListener(new FocusLayoutManager.OnFocusChangeListener() {
                            @Override
                            public void onFocusChanged(int focusdPosition, int lastFocusdPosition) {

                            }
                        })
                        .build();
recyclerView.setLayoutManager(focusLayoutManager);


各属性意义见图:




注意:因为item在不同区域随着滑动会有不同的缩放,所以实际layerPadding、normalViewGap会被缩放计算。


2
自定义LayoutManager基础知识


自备。


这个项目就我学习LayoutManager的实战项目。(断断续续学习过很多次,还是得实际编码才能掌握)

推荐几篇我觉得好的自定义LayoutManager文章:


1、 张旭童的掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API

https://blog.csdn.net/zxt0601/article/details/52948009

2、张旭童的掌握自定义LayoutManager(二) 实现流式布局

https://blog.csdn.net/zxt0601/article/details/52956504

3、陈小缘的自定义LayoutManager第十一式之飞龙在天

https://blog.csdn.net/u011387817/article/details/81875021


3
自定义LayoutManager注意事项


上面张旭童的文章里有指出很多自定义LayoutManager的误区、注意事项,我补充几点:


1、不要遍历ItemCount


这个真的,是我认为最关键的一个注意事项。


getItemCount获取到的是什么?


是列表的总item数量,它可能有几千条几万条,甚至某些情况使用者会特意重写getItemCount将其返回为Integer.MAX_VALUE(比如为了实现无限循环轮播)。


你之所以自定义LayoutManager而不自定义ViewGroup,就是为了不管itemCount多少你都能hold住。所以你不应该在布局相关代码中遍历ItemCount!!


诚然,遍历它往往可以获取很多有用的数据,对后续的布局的计算、子View是否在屏幕内等判断非常有用,但请尽量不要遍历它(除非你的LM够特殊)。


张旭童说的没错,很多文章都存在误导,我还看到过有篇”喜欢“数很多的文章里有类似这么一段代码:


  for (int i = 0; i < getItemCount(); i++) {
            View view = recycler.getViewForPosition(i);
            addView(view);
            ......


???


对于初次布局,这不就是有多少item就onCreateViewHolder多少次了么。


缓存池总数 = item总数?之后的回收复用操作也没意义了。


2、注意调用getChildCount时机


在列表滚动时,一般都要判断子View是否还在屏幕内,若不在了则回收。那么获取子View的逻辑应该在detachAndScrapAttachedViews(or detachAndScrapView等)之前。


见下面代码的打印:


//分离全部的view,放入临时缓存
log("before。child count = " + getChildCount() + ";scrap count = " + recycler.getScrapList().size());
detachAndScrapAttachedViews(recycler);
log("after。child count = " + getChildCount() + ";scrap count = " + recycler.getScrapList().size());


//打印结果:
//before。child count = 5;scrap count = 0
//after。child count = 0;scrap count = 5


另外,不用多说,recycler.getViewForPosition应在detachAndScrapAttachedViews之后


3、回收子View小技巧


这是在陈小缘那篇文章里学到的:


可以直接把Recycler里面的mAttachedScrap全部放进mRecyclerPool中,因为我们在一开始就已经调用了detachAndScrapAttachedViews方法将当前屏幕中有效的ViewHolder全部放进了mAttachedScrap,而在重新布局的时候,有用的Holder已经被重用了,也就是拿出去了,这个mAttachedScrap中剩下的Holder,都是不需要layout的,所以可以把它们都回收进mRecyclerPool中。


实用哦。

(不知道对预布局是否有影响,但我代码中并没有判断过isPreLayout,也测试过notifyItemRemoved,动画正常)


4
布局实现


先把上面的细节图重新贴一下


首先无视掉view的缩放、透明度变化。那么布局其实就这样:



我们称一个view从”普通view“滚动到”焦点view“为一次完整的聚焦滑动所需要移动的距离,定义其为onceCompleteScrollLength。


在普通view移动了一个onceCompleteScrollLength,堆叠View只移动了一个layerPadding。核心逻辑就这一句。


我们在scrollHorizontallyBy中记录偏移量dx,保存一个累计偏移量mHorizontalOffset,然后用该偏移量除以onceCompleteScrollLength,就知道当前已经滚动了多少个item了,换句话说就是屏幕内第一个可见view的position知道了。


同时能计算出一个onceCompleteScrollLength已经滚动了的百分比fraction,再用这个百分比换算出堆叠区域和普通区域布局起始位置的偏移量,然后可以开始布局了,对于堆叠区域的view,彼此之间距离一个layerPadding,对于普通区域view,彼此之间距离一个onceCompleteScrollLength。


见代码:


     @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
                                    RecyclerView.State state)
 
{
        //手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;

        //位移0、没有子View 当然不移动
        if (dx == 0 || getChildCount() == 0) {
            return 0;
        }

        mHorizontalOffset += dx;//累加实际滑动距离


        dx = fill(recycler, state, dx);

        return dx;
    }
    /**
     * @param recycler
     * @param state
     * @param delta
     */

    private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int delta) {
        int resultDelta = delta;
        //省略
        resultDelta = fillHorizontalLeft(recycler, state, delta);
        //省略
         return resultDelta;

    }
/**
     * 水平滚动、向左堆叠布局
     *
     * @param recycler
     * @param state
     * @param dx       偏移量。手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
     */

    private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state,
                                   int dx)
 
{

        //----------------1、边界检测-----------------
        if (dx < 0) {
            //已达左边界
            if (mHorizontalOffset < 0) {
                mHorizontalOffset = dx = 0;
            }
        }

        if (dx > 0) {
            //滑动到只剩堆叠view,没有普通view了,说明已经到达右边界了
            if (mLastVisiPos - mFirstVisiPos <= maxLayerCount - 1) {
                //因为scrollHorizontallyBy里加了一次dx,现在减回去
                mHorizontalOffset -= dx;
                dx = 0;
            }
        }

        //分离全部的view,放入临时缓存
        detachAndScrapAttachedViews(recycler);

        //----------------2、初始化布局数据-----------------

        float startX = getPaddingLeft() - layerPadding;

        View tempView = null;
        int tempPosition = -1;
        if (onceCompleteScrollLength == -1) {
            //因为mFirstVisiPos在下面可能会被改变,所以用tempPosition暂存一下。
            tempPosition = mFirstVisiPos;
            tempView = recycler.getViewForPosition(tempPosition);
            measureChildWithMargins(tempView, 00);
            onceCompleteScrollLength = getDecoratedMeasurementHorizontal(tempView) + normalViewGap;
        }
        //当前"一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT,从右向左移动fraction将从0%到100%)
        float fraction =
                (Math.abs(mHorizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f);

        //堆叠区域view偏移量。在一次完整的聚焦滑动期间,其总偏移量是一个layerPadding的距离
        float layerViewOffset = layerPadding * fraction;
        //普通区域view偏移量。在一次完整的聚焦滑动期间,其总位移量是一个onceCompleteScrollLength
        float normalViewOffset = onceCompleteScrollLength * fraction;
        boolean isLayerViewOffsetSetted = false;
        boolean isNormalViewOffsetSetted = false;

        //修正第一个可见的view:mFirstVisiPos。已经滑动了多少个完整的onceCompleteScrollLength就代表滑动了多少个item
        mFirstVisiPos = (int) Math.floor(Math.abs(mHorizontalOffset) / onceCompleteScrollLength); //向下取整
        //临时将mLastVisiPos赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局
        mLastVisiPos = getItemCount() - 1;

        //...省略监听回调

        //----------------3、开始布局-----------------

        for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
            //属于堆叠区域
            if (i - mFirstVisiPos < maxLayerCount) {
                View item;

                if (i == tempPosition && tempView != null) {
                    //如果初始化数据时已经取了一个临时view,可别浪费了!
                    item = tempView;
                } else {
                    item = recycler.getViewForPosition(i);
                }
                addView(item);
                measureChildWithMargins(item, 00);

                startX += layerPadding;
                if (!isLayerViewOffsetSetted) {
                    startX -= layerViewOffset;
                    isLayerViewOffsetSetted = true;
                }

               //...省略监听回调

                int l, t, r, b;
                l = (int) startX;
                t = getPaddingTop();
                r = (int) (startX + getDecoratedMeasurementHorizontal(item));
                b = getPaddingTop() + getDecoratedMeasurementVertical(item);
                layoutDecoratedWithMargins(item, l, t, r, b);


            } else {//属于普通区域

                View item = recycler.getViewForPosition(i);
                addView(item);
                measureChildWithMargins(item, 00);

                startX += onceCompleteScrollLength;
                if (!isNormalViewOffsetSetted) {
                    startX += layerViewOffset;
                    startX -= normalViewOffset;
                    isNormalViewOffsetSetted = true;
                }

                //...省略监听回调

                int l, t, r, b;
                l = (int) startX;
                t = getPaddingTop();
                r = (int) (startX + getDecoratedMeasurementHorizontal(item));
                b = getPaddingTop() + getDecoratedMeasurementVertical(item);
                layoutDecoratedWithMargins(item, l, t, r, b);

                //判断下一个view的布局位置是不是已经超出屏幕了,若超出,修正mLastVisiPos并跳出遍历
                if (startX + onceCompleteScrollLength > getWidth() - getPaddingRight()) {
                    mLastVisiPos = i;
                    break;
                }
            }
        }

        return dx;
    }


因为measure、layout调用的都是考虑了margin的api,所以布局时也要考虑到margin:


 /**
     * 获取某个childView在水平方向所占的空间,将margin考虑进去
     *
     * @param view
     * @return
     */

    public int getDecoratedMeasurementHorizontal(View view{
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        return getDecoratedMeasuredWidth(view) + params.leftMargin
                + params.rightMargin;
    }

    /**
     * 获取某个childView在竖直方向所占的空间,将margin考虑进去
     *
     * @param view
     * @return
     */

    public int getDecoratedMeasurementVertical(View view{
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        return getDecoratedMeasuredHeight(view) + params.topMargin
                + params.bottomMargin;
    }


5
回收复用



用上面讲的回收技巧:


 /**
     * @param recycler
     * @param state
     * @param delta
     */

    private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int delta) {
        int resultDelta = delta;
        //。。。省略

        recycleChildren(recycler);
       log("childCount= [" + getChildCount() + "]" + ",[recycler.getScrapList().size():" + recycler.getScrapList().size());
        return resultDelta;
    }

    /**
     * 回收需回收的Item。
     */

    private void recycleChildren(RecyclerView.Recycler recycler) {
        List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList();
        for (int i = 0; i < scrapList.size(); i++) {
            RecyclerView.ViewHolder holder = scrapList.get(i);
            removeAndRecycleView(holder.itemView, recycler);
        }
    }


接下来验证下。


验证1


张旭童:通过getChildCount()和recycler.getScrapList().size() 查看当前屏幕上的Item数量 和 scrapCache缓存区域的Item数量,合格的LayoutManager,childCount数量不应大于屏幕上显示的Item数量,而scrapCache缓存区域的Item数量应该是0.


编写log并打印:


 childCount= [5],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [5],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0
childCount= [6],[recycler.getScrapList().size():0


合格。


验证2


用最直接的方法,打印onCreateViewHolder、onBindViewHolder看看到底复用了没:


  @NonNull
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
            View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.item_card,
                    viewGroup, false);
            view.setTag(++index);
            Log.d("ccy""onCreateViewHolder = " + index);
            return new ViewHolder(view);
        }

        @Override
        public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
            Log.d("ccy""onBindViewHolder,index = " + (int) (viewHolder.itemView.getTag()));
        }


在onCreateViewHolder创建view时,给他一个tag,然后onBindViewHolder中打印这个tag,以此查看是不用复用了view。打印如下


onCreateViewHolder = 1
onBindViewHolder,index = 1
onCreateViewHolder = 2
onBindViewHolder,index = 2
onCreateViewHolder = 3
onBindViewHolder,index = 3
onCreateViewHolder = 4
onBindViewHolder,index = 4
onCreateViewHolder = 5
onBindViewHolder,index = 5
onCreateViewHolder = 6
onBindViewHolder,index = 6
onCreateViewHolder = 7
onBindViewHolder,index = 7
onCreateViewHolder = 8
onBindViewHolder,index = 8
onBindViewHolder,index = 1
onBindViewHolder,index = 2
onBindViewHolder,index = 3
onBindViewHolder,index = 4
onBindViewHolder,index = 5
onBindViewHolder,index = 6
onBindViewHolder,index = 7
onBindViewHolder,index = 8
onCreateViewHolder = 9
onBindViewHolder,index = 9
onBindViewHolder,index = 2
onBindViewHolder,index = 3
onBindViewHolder,index = 1
onBindViewHolder,index = 4
onBindViewHolder,index = 5
onBindViewHolder,index = 6


我测试时手机一屏内最多可见约6个,从打印中可见它最多调用了9次onCreateViewHolder,这个次数完全可以接受。并且onBindViewHolder也在复用view。完全ojbk没得问题


5
动画效果



我做的动画,就是在滑动期间渐变view的缩放比例、透明度,使得view看上去像一层一层堆叠上去的样子。其实就是各种y = kx + b之类的计算,因为fill系列方法中已经计算出很多有用的数据了。


我的做法是,暴露出这么个接口:


/**
     * 滚动过程中view的变换监听接口。属于高级定制,暴露了很多关键布局数据。若定制要求不高,考虑使用{@link SimpleTrasitionListener}
     */

    public interface TrasitionListener {

        /**
         * 处理在堆叠里的view。
         *
         * @param focusLayoutManager
         * @param view               view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
         * @param viewLayer          当前层级,0表示底层,maxLayerCount-1表示顶层
         * @param maxLayerCount      最大层级
         * @param position           item所在的position
         * @param fraction           "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
         *                           ,从右向左移动fraction将从0%到100%)
         * @param offset             当次滑动偏移量
         */

        void handleLayerView(FocusLayoutManager focusLayoutManager, View view, int viewLayer,
                             int maxLayerCount, int position, float fraction, float offset)
;

        /**
         * 处理正聚焦的那个View(即正处在从普通位置滚向聚焦位置时的那个view,即堆叠顶层view)
         *
         * @param focusLayoutManager
         * @param view               view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
         * @param position           item所在的position
         * @param fraction           "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
         *                           ,从右向左移动fraction将从0%到100%)
         * @param offset             当次滑动偏移量
         */

        void handleFocusingView(FocusLayoutManager focusLayoutManager, View view, int position,
                                float fraction, float offset)
;

        /**
         * 处理不在堆叠里的普通view(正在聚焦的那个view除外)
         *
         * @param focusLayoutManager
         * @param view               view对象。请仅在方法体范围内对view做操作,不要外部强引用它,view是要被回收复用的
         * @param position           item所在的position
         * @param fraction           "一次完整的聚焦滑动"所在的进度百分比.百分比增加方向为向着堆叠移动的方向(即如果为FOCUS_LEFT
         *                           ,从右向左移动fraction将从0%到100%)
         * @param offset             当次滑动偏移量
         */

        void handleNormalView(FocusLayoutManager focusLayoutManager, View view, int position,
                              float fraction, float offset)
;

    }


然后在fill系列方法的对应位置回调该接口即可:


/**
     * 变换监听接口。
     */

    private List<TrasitionListener> trasitionListeners;


         /**
     * 水平滚动、向左堆叠布局
     *
     * @param recycler
     * @param state
     * @param dx       偏移量。手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0;
     */

    private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state,
                                   int dx)
 
{
         //省略。。。。。                         

        //----------------3、开始布局-----------------

        for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
            //属于堆叠区域
            if (i - mFirstVisiPos < maxLayerCount) {

             //省略。。。。。   
                if (trasitionListeners != null && !trasitionListeners.isEmpty()) {
                    for (TrasitionListener trasitionListener : trasitionListeners) {
                        trasitionListener.handleLayerView(this, item, i - mFirstVisiPos,
                                maxLayerCount, i, fraction, dx);
                    }
                }

            } else {//属于普通区域
            //省略。。。。。  
                if (trasitionListeners != null && !trasitionListeners.isEmpty()) {
                    for (TrasitionListener trasitionListener : trasitionListeners) {
                        if (i - mFirstVisiPos == maxLayerCount) {
                            trasitionListener.handleFocusingView(this, item, i, fraction, dx);
                        } else {
                            trasitionListener.handleNormalView(this, item, i, fraction, dx);
                        }
                    }
                }
            }
        }

        return dx;
    }


然后使用者可以自己注册该接口,天马行空。


那么我这个项目默认的动画具体实现是怎么样的呢?


先这样,再那样,效果就出来啦:


@Override
public void handleLayerView(FocusLayoutManager focusLayoutManager, View view,
                            int viewLayer, int maxLayerCount, int position,
                            float fraction, float offset)
 
{
    /**
     * 期望效果:从0%开始到{@link SimpleTrasitionListener#getLayerChangeRangePercent()} 期间
     * view均匀完成渐变,之后一直保持不变
     */

    //转换为真实的渐变变化百分比
    float realFraction;
    if (fraction <= stl.getLayerChangeRangePercent()) {
        realFraction = fraction / stl.getLayerChangeRangePercent();
    } else {
        realFraction = 1.0f;
    }

    float minScale = stl.getLayerViewMinScale(maxLayerCount);
    float maxScale = stl.getLayerViewMaxScale(maxLayerCount);
    float scaleDelta = maxScale - minScale; //总缩放差
    float currentLayerMaxScale =
            minScale + scaleDelta * (viewLayer + 1) / (maxLayerCount * 1.0f);
    float currentLayerMinScale = minScale + scaleDelta * viewLayer / (maxLayerCount * 1.0f);
    float realScale =
            currentLayerMaxScale - (currentLayerMaxScale - currentLayerMinScale) * realFraction;

    float minAlpha = stl.getLayerViewMinAlpha(maxLayerCount);
    float maxAlpha = stl.getLayerViewMaxAlpha(maxLayerCount);
    float alphaDelta = maxAlpha - minAlpha; //总透明度差
    float currentLayerMaxAlpha =
            minAlpha + alphaDelta * (viewLayer + 1) / (maxLayerCount * 1.0f);
    float currentLayerMinAlpha = minAlpha + alphaDelta * viewLayer / (maxLayerCount * 1.0f);
    float realAlpha =
            currentLayerMaxAlpha - (currentLayerMaxAlpha - currentLayerMinAlpha) * realFraction;

//            log("layer =" + viewLayer + ";alpha = " + realAlpha + ";fraction = " + fraction);
    view.setScaleX(realScale);
    view.setScaleY(realScale);
    view.setAlpha(realAlpha);

}


哈哈哈。代码中stl 存储着堆叠区域view、焦点view、普通view的最大和最小缩放比、透明度,然后利用fraction计算出当前位置真实的缩放比、透明度设置之。


上面只贴了堆叠区域view的实现,完整实现见源码中的TrasitionListenerConvert


6
自动选中


1、滚动停止后自动选中


我的实现方式是这样的:监听onScrollStateChanged,在滚动停止时计算出应当停留的position,再计算出停留时的mHorizontalOffset值,播放属性动画将当前mHorizontalOffset不断更新至最终值即可。


具体代码参考源码中的onScrollStateChanged和smoothScrollToPosition。


(思考:能通过自定义SnapHelper实现么?)


2、点击非焦点view自动将其选中为焦点view


已经实现了setFocusdPosition方法。内部逻辑就是计算出实际position并调用smoothScrollToPosition或scrollToPosition 。


示例代码:


 public ViewHolder(@NonNull final View itemView) {
                super(itemView);
                itemView.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        int pos = getAdapterPosition();
                        if (pos == focusLayoutManager.getFocusdPosition()) {
                        //是焦点view
                        } else {
                            focusLayoutManager.setFocusdPosition(pos, true);
                        }
                    }
                });
            }


7
无限循环滚动


因为FocusLayoutManager内部没有遍历itemCount这种bad操作,你可以自己通过重写getItemCount返回Integer.MAX_VALUE实现伪无限循环。


示例代码:


public void initView(){
    recyclerView.post(new Runnable() {
                    @Override
                    public void run() {
                        focusLayoutManager.scrollToPosition(1000); //差不多大行了,毕竟mHorizontalOffset是会一直累加的
                    }
                });
}

public class Adapter extends RecyclerView.Adapter<Adapter.ViewHolder{
    @Override
    public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
        int realPosition = position % datas.size();
        Bean bean = datas.get(realPosition);
        //...
    }

    @Override
    public int getItemCount() {
        return Integer.MAX_VALUE;
    }

}


8
让开头(堆叠数-1)个View可见


按目前布局逻辑,开头的position = 0 到position = maxLayerCount - 1个view永远只能在堆叠区域,没法拉出来到焦点view。解决方式也简单,给你的源数据开头插入maxLayerCount - 1个假数据,然后当adapter中识别到假数据时让其布局不可见即可


结束


剩下的三个堆叠方向的实现就是加加减减的变化,不用贴出来了。



给个赞呗~

给个star呗~


项目地址:

https://github.com/CCY0122/FocusLayoutManager

推荐↓↓↓
安卓开发
上一篇:教你写一个弹幕库,确定不了解一下? 下一篇:开源一个自用的Android事件分发中心库