美文网首页Android开发经验谈Android开发Android学习
Android自定义控件进阶:自定义LayoutManager

Android自定义控件进阶:自定义LayoutManager

作者: 06fd4cf1f427 | 来源:发表于2019-10-09 22:08 被阅读0次

    前言

    「知足常乐」,很多人不满足现状,各种折腾,往往舍本逐末,常乐才能少一分浮躁,多一分宁静。近期在小编身上发生了许多事情,心态也发生了很大的改变,有感于现实的无奈,在离家乡遥远城市里的落寂,追逐名利的浮躁;可能生活就是这样的,每个年龄段都有自己的烦恼。

    说道折腾,很久以前就看到了各种自定义LayoutManager做出各种炫酷的动画,就想自己也要实现。但每次都因为系统自带的LinearLayoutManager源码搞得一脸懵逼。正好这段时间不忙,折腾了一天,写了个简单的Demo,效果如下:

    效果预览

    RecyclerView的重要性不必多说,据过往开发经验而谈,超过一屏可滑动的界面,基本都可以采用 「RecyclerView的多类型」 来做,不仅维护还是扩展都是非常有效率的。RecyclerView相关的面试题也是各大厂常问的问题之一(权重非常高)。

    使用

    mRecyclerView.setLayoutManager(stackLayoutManager = new StackLayoutManager(this));
    

    跟系统的LinearLayoutManager使用方式一致,文本只是简单的Demo,功能单一,主要讲解流程与步骤,请根据特定的需求修改。

    各属性意义见图:

    凑合看,由于ps太烂。注意:因为item随着滑动会有不同的缩放,所以实际normalViewGap会被缩放计算。

    自定义LayoutManager基础知识

    有关自定义LayoutManager基础知识,请查阅以下文章,写的非常棒:

    1、陈小缘的自定义LayoutManager第十一式之飞龙在天(小缘大佬自定义文章逻辑清晰明了,堪称教科书,非常经典)

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

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

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

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

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

    4、勇朝陈的Android仿豆瓣书影音频道推荐表单堆叠列表RecyclerView-LayoutManager

    https://blog.csdn.net/ccy0122/article/details/90515386

    这几篇文章针对自定义LayoutManager的误区、注意事项,分析的非常到位,来来回回我看了好几篇,希望对你有所帮助。

    自定义LayoutManager基本流程

    让Items显示出来

    我们在自定义ViewGroup中,想要显示子View,无非就三件事:

    1. 添加 通过addView方法把子View添加进ViewGroup或直接在xml中直接添加;
    2. 测量 重写onMeasure方法并在这里决定自身尺寸以及每一个子View大小;
    3. 布局 重写onLayout方法,在里面调用子View的layout方法来确定它的位置和尺寸;

    其实在自定义LayoutManager中,在流程上也是差不多的,我们需要重写onLayoutChildren方法,这个方法会在初始化或者Adapter数据集更新时回调,在这方法里面,需要做以下事情:

    1. 进行布局之前,我们需要调用detachAndScrapAttachedViews方法把屏幕中的Items都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话);
    2. 分离了之后,我们就要想办法把它们再添加回去了,所以需要通过addView方法来添加,那这些View在哪里得到呢? 我们需要调用 Recycler的getViewForPosition(int position) 方法来获取;
    3. 获取到Item并重新添加了之后,我们还需要对它进行测量,这时候可以调用measureChild或measureChildWithMargins方法,两者的区别我们已经了解过了,相信同学们都能根据需求选择更合适的方法;
    4. 在测量完还需要做什么呢? 没错,就是布局了,我们也是根据需求来决定使用layoutDecorated还是layoutDecoratedWithMargins方法;
    5. 在自定义ViewGroup中,layout完就可以运行看效果了,但在LayoutManager还有一件非常重要的事情,就是回收了,我们在layout之后,还要把一些不再需要的Items回收,以保证滑动的流畅度;

    以上内容出自陈小缘的自定义LayoutManager第十一式之飞龙在天

    布局实现

    再看下相关参数:

    如果去掉itemView的缩放,透明度动画,那么效果是这样的:

    看到的效果与LinearLayoutManager一样,但本篇并不使用LinearLayoutManager,而是通过自定义LayoutManager来实现。

    索引值为0的view 一次完全滑出屏幕所需要的移动距离,定位为 firstChildCompleteScrollLength ;非索引值为0的view滑出屏幕所需要移动的距离为:
    firstChildCompleteScrollLength + onceCompleteScrollLength ; item 之间的间距为 normalViewGap

    我们在 scrollHorizontallyBy 方法中记录偏移量 dx,保存一个累计偏移量 mHorizontalOffset ,然后针对索引值为0与非0两种情况,在 mHorizontalOffset 小于 firstChildCompleteScrollLength 情况下,用该偏移量除以 firstChildCompleteScrollLength 获取到已经滚动了的百分比 fraction ;同理索引值非0的情况下,偏移量需要减去 firstChildCompleteScrollLength 来获取到滚动的百分比。根据百分比,怎么布局childview就很容易了。

    接下来开始写代码,先取个比较接地气的名字,就叫 StackLayoutManager ,好普通的名字,哈哈。

    StackLayoutManager 继承 RecyclerView.LayoutManager ,需要重写 generateDefaultLayoutParams 方法:

        @Override
        public RecyclerView.LayoutParams generateDefaultLayoutParams() {
            return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
        }
    

    先看看成员变量:

        /**
         * 一次完整的聚焦滑动所需要的移动距离
         */
        private float onceCompleteScrollLength = -1;
    
        /**
         * 第一个子view的偏移量
         */
        private float firstChildCompleteScrollLength = -1;
    
        /**
         * 屏幕可见第一个view的position
         */
        private int mFirstVisiPos;
    
        /**
         * 屏幕可见的最后一个view的position
         */
        private int mLastVisiPos;
    
        /**
         * 水平方向累计偏移量
         */
        private long mHorizontalOffset;
    
        /**
         * view之间的margin
         */
        private float normalViewGap = 30;
    
        private int childWidth = 0;
    
        /**
         * 是否自动选中
         */
        private boolean isAutoSelect = true;
        // 选中动画
        private ValueAnimator selectAnimator;
    

    接着看看 scrollHorizontallyBy 方法:

        @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;
            }
    
            // 误差处理
            float realDx = dx / 1.0f;
            if (Math.abs(realDx) < 0.00000001f) {
                return 0;
            }
    
            mHorizontalOffset += dx;
    
            dx = fill(recycler, state, dx);
    
            return dx;
        }
    
        private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
            int resultDelta = dx;
            resultDelta = fillHorizontalLeft(recycler, state, dx);
            recycleChildren(recycler);
            return resultDelta;
        }
    
        private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
            //----------------1、边界检测-----------------
            if (dx < 0) {
                // 已到达左边界
                if (mHorizontalOffset < 0) {
                    mHorizontalOffset = dx = 0;
                }
            }
    
            if (dx > 0) {
                if (mHorizontalOffset >= getMaxOffset()) {
                    // 根据最大偏移量来计算滑动到最右侧边缘
                    mHorizontalOffset = (long) getMaxOffset();
                    dx = 0;
                }
            }
    
            // 分离全部的view,加入到临时缓存
            detachAndScrapAttachedViews(recycler);
    
            float startX = 0;
            float fraction = 0f;
            boolean isChildLayoutLeft = true;
    
            View tempView = null;
            int tempPosition = -1;
    
            if (onceCompleteScrollLength == -1) {
                // 因为mFirstVisiPos在下面可能被改变,所以用tempPosition暂存一下
                tempPosition = mFirstVisiPos;
                tempView = recycler.getViewForPosition(tempPosition);
                measureChildWithMargins(tempView, 0, 0);
                childWidth = getDecoratedMeasurementHorizontal(tempView);
            }
    
            // 修正第一个可见view mFirstVisiPos 已经滑动了多少个完整的onceCompleteScrollLength就代表滑动了多少个item
            firstChildCompleteScrollLength = getWidth() / 2 + childWidth / 2;
            if (mHorizontalOffset >= firstChildCompleteScrollLength) {
                startX = normalViewGap;
                onceCompleteScrollLength = childWidth + normalViewGap;
                mFirstVisiPos = (int) Math.floor(Math.abs(mHorizontalOffset - firstChildCompleteScrollLength) / onceCompleteScrollLength) + 1;
                fraction = (Math.abs(mHorizontalOffset - firstChildCompleteScrollLength) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f);
            } else {
                mFirstVisiPos = 0;
                startX = getMinOffset();
                onceCompleteScrollLength = firstChildCompleteScrollLength;
                fraction = (Math.abs(mHorizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f);
            }
    
            // 临时将mLastVisiPos赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局
            mLastVisiPos = getItemCount() - 1;
    
            float normalViewOffset = onceCompleteScrollLength * fraction;
            boolean isNormalViewOffsetSetted = false;
    
            //----------------3、开始布局-----------------
            for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
                View item;
                if (i == tempPosition && tempView != null) {
                    // 如果初始化数据时已经取了一个临时view
                    item = tempView;
                } else {
                    item = recycler.getViewForPosition(i);
                }
    
                addView(item);
                measureChildWithMargins(item, 0, 0);
    
                if (!isNormalViewOffsetSetted) {
                    startX -= normalViewOffset;
                    isNormalViewOffsetSetted = true;
                }
    
                int l, t, r, b;
                l = (int) startX;
                t = getPaddingTop();
                r = l + getDecoratedMeasurementHorizontal(item);
                b = t + getDecoratedMeasurementVertical(item);
    
                layoutDecoratedWithMargins(item, l, t, r, b);
    
                startX += (childWidth + normalViewGap);
    
                if (startX > getWidth() - getPaddingRight()) {
                    mLastVisiPos = i;
                    break;
                }
            }
            return dx;
        }
    

    涉及的方法:

        /**
         * 最大偏移量
         *
         * @return
         */
        private float getMaxOffset() {
            if (childWidth == 0 || getItemCount() == 0) return 0;
            return (childWidth + normalViewGap) * (getItemCount() - 1);
        }
    
        /**
         * 获取某个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;
        }
    
    

    回收复用

    这里使用Android仿豆瓣书影音频道推荐表单堆叠列表RecyclerView-LayoutManager中使用的回收技巧:

     /**
         * @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);
            }
        }
    

    回收复用这里就不验证了,感兴趣的小伙伴可自行验证。

    动画效果

        private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
            // 省略 ......
            //----------------3、开始布局-----------------
            for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
                // 省略 ......
    
                // 缩放子view
                final float minScale = 0.6f;
                float currentScale = 0f;
                final int childCenterX = (r + l) / 2;
                final int parentCenterX = getWidth() / 2;
                isChildLayoutLeft = childCenterX <= parentCenterX;
                if (isChildLayoutLeft) {
                    final float fractionScale = (parentCenterX - childCenterX) / (parentCenterX * 1.0f);
                    currentScale = 1.0f - (1.0f - minScale) * fractionScale;
                } else {
                    final float fractionScale = (childCenterX - parentCenterX) / (parentCenterX * 1.0f);
                    currentScale = 1.0f - (1.0f - minScale) * fractionScale;
                }
                item.setScaleX(currentScale);
                item.setScaleY(currentScale);
                item.setAlpha(currentScale);
    
                layoutDecoratedWithMargins(item, l, t, r, b);
               // 省略 ......
            }
            return dx;
        }
    

    childView 越向屏幕中间移动缩放比越大,越向两边移动缩放比越小。

    自动选中

    1、滚动停止后自动选中

    监听 onScrollStateChanged,在滚动停止时计算出应当停留的 position,再计算出停留时的 mHorizontalOffset 值,播放属性动画将当前 mHorizontalOffset 不断更新至最终值即可。相关代码如下:

        @Override
        public void onScrollStateChanged(int state) {
            super.onScrollStateChanged(state);
            switch (state) {
                case RecyclerView.SCROLL_STATE_DRAGGING:
                    //当手指按下时,停止当前正在播放的动画
                    cancelAnimator();
                    break;
                case RecyclerView.SCROLL_STATE_IDLE:
                    //当列表滚动停止后,判断一下自动选中是否打开
                    if (isAutoSelect) {
                        //找到离目标落点最近的item索引
                        smoothScrollToPosition(findShouldSelectPosition());
                    }
                    break;
                default:
                    break;
            }
        }
    
         /**
         * 平滑滚动到某个位置
         *
         * @param position 目标Item索引
         */
        public void smoothScrollToPosition(int position) {
            if (position > -1 && position < getItemCount()) {
                startValueAnimator(position);
            }
        }
    
        private int findShouldSelectPosition() {
            if (onceCompleteScrollLength == -1 || mFirstVisiPos == -1) {
                return -1;
            }
            int position = (int) (Math.abs(mHorizontalOffset) / (childWidth + normalViewGap));
            int remainder = (int) (Math.abs(mHorizontalOffset) % (childWidth + normalViewGap));
            // 超过一半,应当选中下一项
            if (remainder >= (childWidth + normalViewGap) / 2.0f) {
                if (position + 1 <= getItemCount() - 1) {
                    return position + 1;
                }
            }
            return position;
        }
    
        private void startValueAnimator(int position) {
            cancelAnimator();
    
            final float distance = getScrollToPositionOffset(position);
    
            long minDuration = 100;
            long maxDuration = 300;
            long duration;
    
            float distanceFraction = (Math.abs(distance) / (childWidth + normalViewGap));
    
            if (distance <= (childWidth + normalViewGap)) {
                duration = (long) (minDuration + (maxDuration - minDuration) * distanceFraction);
            } else {
                duration = (long) (maxDuration * distanceFraction);
            }
            selectAnimator = ValueAnimator.ofFloat(0.0f, distance);
            selectAnimator.setDuration(duration);
            selectAnimator.setInterpolator(new LinearInterpolator());
            final float startedOffset = mHorizontalOffset;
            selectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    float value = (float) animation.getAnimatedValue();
                    mHorizontalOffset = (long) (startedOffset + value);
                    requestLayout();
                }
            });
            selectAnimator.start();
        }
    
    2、点击非焦点view自动将其选中为焦点view

    我们可以直接拿到 viewposition,直接调用 smoothScrollToPosition 方法,就可以实现自动选中为焦点。

    中间view覆盖在两边view之上

    效果是这样的:

    从效果中可以看出,索引为2的view覆盖在1,3的上面,同时1又覆盖在0的上面,以此内推。

    RecyclerView 继承于 ViewGroup ,那么在添加子view addView(View child, int index)index 的索引值越大,越显示在上层。那么可以得出,为2的绿色卡片被添加是 index 最大,分析可以得出以下结论:

    index 的大小:

    0 < 1 < 2 > 3 > 4

    中间最大,两边逐渐减小的原则。

    获取到中间 view 的索引值,如果小于等于该索引值则调用 addView(item) ,反之调用 addView(item, 0) ;相关代码如下:

        private int fillHorizontalLeft(RecyclerView.Recycler recycler, RecyclerView.State state, int dx) {
            //省略 ......
            //----------------3、开始布局-----------------
            for (int i = mFirstVisiPos; i <= mLastVisiPos; i++) {
                 //省略 ......
                int focusPosition = (int) (Math.abs(mHorizontalOffset) / (childWidth + normalViewGap));
                if (i <= focusPosition) {
                    addView(item);
                } else {
                    addView(item, 0);
                }
                 //省略 ...... 
            }
            return dx;
        }
    
    

    好了,文章到这里就结束了,如果你觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。

    最后这里是关于我自己的Android 学习,面试文档,视频收集大整理,有兴趣的伙伴们可以看看~

    如果你觉得还算有用的话,不妨把它们推荐给你的朋友。

    相关文章

      网友评论

        本文标题:Android自定义控件进阶:自定义LayoutManager

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