Android自定义控件进阶篇,自定义LayoutManager

前言

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

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

效果预览

在这里插入图片描述
在这里插入图片描述
RecyclerView的重要性没必要多说,据过往开发经验而谈,超过一屏可滑动的界面,基本均可以采用 「RecyclerView的多类型」 来作,不只维护仍是扩展都是很是有效率的。RecyclerView相关的面试题也是各大厂常问的问题之一(权重很是高)。

使用

mRecyclerView.setLayoutManager(stackLayoutManager = new StackLayoutManager(this));
复制代码

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

各属性意义见图: 面试

在这里插入图片描述
凑合看,因为ps太烂。注意:由于item随着滑动会有不一样的缩放,因此实际normalViewGap会被缩放计算。

自定义LayoutManager基础知识

有关自定义LayoutManager基础知识,请查阅如下文章,写的很是棒:缓存

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

blog.csdn.net/u011387817/…布局

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

blog.csdn.net/zxt0601/art…this

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

blog.csdn.net/zxt0601/art…

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

blog.csdn.net/ccy0122/art…

这几篇文章针对自定义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) {
        //----------------一、边界检测-----------------
        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;

        //----------------三、开始布局-----------------
        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) {
        // 省略 ......
        //----------------三、开始布局-----------------
        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 越向屏幕中间移动缩放比越大,越向两边移动缩放比越小。

自动选中

一、滚动中止后自动选中

监听 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();
    }
复制代码
二、点击非焦点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) {
        //省略 ......
        //----------------三、开始布局-----------------
        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;
    }
复制代码

文章到这里就差很少要结束了。

源码地址:

github.com/HpWens/MeiW…

star ~

结语

爱笑的人,运气通常都不会太差。同时也给本身一个鼓励,咱们下期见。

相关文章
相关标签/搜索