那些年我们熬夜打造一可收缩流式标签控件

一、前言

时间匆匆,一眨眼来厦门已经一个多月了。似乎已经适应了这边的生活,喜欢这边的风,温和而舒适,还有淡淡海的味道 。。。

还在再次跟大家致个歉意,博客的更新又延期了。新的环境,新的工作加上项目大改版,基本每天都有大量的事情,周末也不得空闲。

非常感谢大家的陪伴,一路有你们,生活会充满美好。

标签控件

本文还是继续讲解自定义控件,近期在项目中有这样一个控件。

实现可收缩的流式标签控件,具体效果图如下:

flow

  • 支持多选,单选,反选

  • 子 View 定制化

效果图不是很清晰,文章后面会提供下载地址。

主要实现功能细分如下:

  • 实现流式布局(第一个子 View 始终位于首行的最右边)

  • 布局可定制化(采取适配模式)

  • 实现控件的收缩

主要有这三个小的功能组成。第一个流式布局实现需要注意的是,第一个元素(子 View)需要固定在首行的最右边,采取的解决方案是首先绘制第一个元素且绘制在最右边;第二个布局可定制化,怎么来理解这句话呢?我希望实现的子 View 不单单是圆角控件,而是高度定制的所有控件,由用户来决定,采取的解决方案是采用了适配模式;第三个控件的收缩,这个实现起来就比较简单了,完成了第一步就可以获取到控件的高度,采用属性动画来动态改变控件的高度。具体我们一起来往下面看看。

流式布局

效果图一栏:

flow

实现效果图的流式布局,有两种方案。一、直接使用 recyclerView ;二、自定义继承 ViewGroup。本文采用第二种方案,相信大家一定非常熟悉自定义 View 三部曲 ->onMeasure() ->onLayout() ->onDraw() ,吐血推荐以下文章:

自定义View系列教程02–onMeasure源码详尽分析

自定义View系列教程03–onLayout源码详尽分析

自定义View系列教程04–Draw源码分析及其实践

onMeasure()测量

要实现标签流式布局,需要涉及到以下几个问题:

(1)、【下拉按钮】 的测量和布局

flow

标签布局当中【下拉按钮】始终固定在首行的最右边,如果依次绘制子 View 可能导致【下拉按钮】处于第二行,或未处于最右边(与最右边还有一定的间距)。为了满足需求,优先测量和布局【下拉按钮】并把第一个 View 作为【下拉按钮】。

(2)、何时换行

如果当前行已经放不下下一个控件,那么就需要把这个控件移到下一行显示。所以我们要有个变量记录当前行已经占据的宽度,以判断剩下的空间是否还能容得下下一个控件。

(3)、如何得到布局的宽度

为了得到布局的宽度,我们记录每行的高度取最大值。

(4)、如何得到布局的高度

记录每行的高度,布局的高度就是所有行高度之和。

声明的变量如下:

    int lineWidth = 0; //记录每行的宽度
    int lineHeight = 0; //记录每行的高度
    int height = 0; //布局高度
    int width = 0; //布局宽度
    int count = getChildCount(); //所有子控件数量
    boolean firstLine = true; //是否处于第一行
    firstLineCount = 0; //第一行子 View 个数

然后开始测量(贴出 onMeasure 的全部代码,再细讲):

    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
        //测量子View
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
        int childWidth = 0;
        int childHeight = 0;

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

        if (lineWidth + childWidth > measureWidth) {
            //需要换行
            width = Math.max(lineWidth, width);
            height += lineHeight;
            //需要换行,而将此控件调到下一行,所以将此控件的高度和宽度初始化给lineHeight、lineWidth
            lineHeight = childHeight;
            lineWidth = childWidth;
            firstLine = false;
        } else {
            // 否则累加值lineWidth,lineHeight取最大高度
            lineHeight = Math.max(lineHeight, childHeight);
            lineWidth += childWidth;
            if (firstLine) {
                firstLineCount++;
                firstLineHeight = lineHeight;
            }
        }
        //注意这里是用于添加尾部收起的布局,宽度为父控件宽度。所以要单独处理
        if (i == count - 1) {
            height += lineHeight;
            width = Math.max(width, lineWidth);
            if (firstLine) {
                firstLineCount = 1;
            }
        }
    }
    //如果未超过一行
    if (mFirstHeight) {
        measureHeight = height;
    }
    setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth
            : width, (measureHeightMode == MeasureSpec.EXACTLY) ? measureHeight
            : height);

首先我们循环遍历每个子控件,计算每个子控件的宽度和高度,代码如下:

        View child = getChildAt(i);
        //测量子View
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
        int childWidth = 0;
        int childHeight = 0;

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

需要注意 child.getMeasuredWidth() , child.getMeasuredHeight() 能够获取到值,必须先调用 measureChild() 方法;同理调用 onLayout() 后,getWidth() 才能获取到值。以下以子控件所占宽度来讲解:

childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;

子控件所占宽度=子控件宽度+左右的 Margin 值 。还得注意一点为了获取到子控件的左右 Margin 值,需要重写以下方法:

    @Override
    protected LayoutParams generateLayoutParams(LayoutParams lp) {
        return new MarginLayoutParams(lp);
    }

    @Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new MarginLayoutParams(LayoutParams.MATCH_PARENT,
                LayoutParams.MATCH_PARENT);
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

下面就是计算是否需要换行,以及计算父控件的宽高度:

    if (lineWidth + childWidth > measureWidth) {
        //需要换行
        width = Math.max(lineWidth, width);
        height += lineHeight;
        //因为由于盛不下当前控件,而将此控件调到下一行,所以将此控件的高度和宽度初始化给lineHeight、lineWidth
        lineHeight = childHeight;
        lineWidth = childWidth;
        firstLine = false; //控件超过了一行
    } else {
        // 否则累加值lineWidth,lineHeight取最大高度
        lineHeight = Math.max(lineHeight, childHeight);
        lineWidth += childWidth;
        if (firstLine) { //控件未超过一行
            firstLineCount++; //记录首行子控件个数
            firstLineHeight = lineHeight;//获取第一行控件的高度
        }
    }

由于 lineWidth 表示当前行已经占据的宽度,所以 lineWidth + childWidth > measureWidth,加上下一个子控件的宽度大于了父控件的宽度,则说明当前行已经放不下当前子控件,需要放到下一行;先看 else 部分,在未换行的情况 lineHeight 为当前行子控件的最大值,lineWidth 为当前行所有控件宽度之和。

在需要换行时,首先将当前行宽 lineWidth 与目前的最大行宽 width 比较计算出最新的最大行宽 width,作为当前父控件所占的宽度,还要将行高 lineHeight 累加到height 变量上,以便计算出父控件所占的总高度。

        width = Math.max(lineWidth, width);
        height += lineHeight;

在需要换行时,需要对当前行宽,高进行赋值。

        lineHeight = childHeight;
        lineWidth = childWidth;

我们还需要处理一件事情,记录首行子控件的个数以及首行的高度。

        if (firstLine) { //控件未超过一行
            firstLineCount++; //记录首行子控件个数
            firstLineHeight = lineHeight;//获取第一行控件的高度
        }

如果超过了一行 firstLine 赋值为 false 。

最后一个子控件我们需要单独处理,获取最终的父控件的宽高度。

        //最后一行是不会超出width范围的,所以要单独处理
        if (i == count - 1) {
            height += lineHeight;
            width = Math.max(width, lineWidth);
            if (firstLine) {
                firstLineCount = 1;
            }
        }

最后就是调用 setMeasuredDimension() 方法,设置到系统中。

        setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth : width, (measureHeightMode ==
                MeasureSpec.EXACTLY) ? measureHeight : height);

onLayout()布局

布局所有的子控件,由于控件要后移和换行,所以我们要标记当前控件的 left 坐标和 top 坐标,申明的几个变量如下:

    int count = getChildCount();
    int lineWidth = 0;//累加当前行的行宽
    int lineHeight = 0;//当前行的行高
    int top = 0, left = 0;//当前坐标的top坐标和left坐标
    int parentWidth = getMeasuredWidth(); //父控件的宽度

首先我们需要布局第一个子控件,使它位于首行的最右边。调用 child.layout 进行子控件的布局。layout 的函数如下,分别计算 l , t , r , b

layout(int l, int t, int r, int b)

l = 父控件的宽度 - 子控件的右Margin - 子控件高度

t = 子控件的顶部Margin

r = l + 子控件宽度

b = t + 子控件高度

具体布局代码如下:

   if (i == 0) {
       child.layout(parentWidth - lp.rightMargin - child.getMeasuredWidth(), lp.topMargin, parentWidth - lp
               .rightMargin, lp.topMargin + child.getMeasuredHeight());
       firstViewWidth = childWidth;
       firstViewHeight = childHeight;
       continue;
   }

接着按着顺序对子控件进行布局,先计算出子控件的宽高:

    View child = getChildAt(i);
    MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
    //宽度(包含margin值和子控件宽度)
    int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
    //高度同上
    int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

然后判断当前布局子控件是否为首行最后布局的控件,并对 lineWidthlineHeight 再次计算:

    if (firstLineCount == (i + 1)) {
        lineWidth += firstViewWidth;
        lineHeight = Math.max(lineHeight, firstViewHeight);
    }

然后根据是否要换行来计算当行控件的 top 坐标和 left 坐标:

if (childWidth + lineWidth >getMeasuredWidth()){  
    //如果换行,当前控件将跑到下一行,从最左边开始,所以left就是0,而top则需要加上上一行的行高,才是这个控件的top点;  
    top += lineHeight;  
    left = 0;  
     //同样,重新初始化lineHeight和lineWidth  
    lineHeight = childHeight;  
    lineWidth = childWidth;  
}else{  
    // 否则累加值lineWidth,lineHeight取最大高度  
    lineHeight = Math.max(lineHeight,childHeight);  
    lineWidth += childWidth;  
}  

在计算好 left,top 之后,然后分别计算出控件应该布局的上、下、左、右四个点坐标,需要非常注意的是 margin 不是 padding,margin 的距离是不绘制的控件内部的,而是控件间的间隔。

   //计算childView的left,top,right,bottom
   int lc = left + lp.leftMargin;
   int tc = top + lp.topMargin;
   int rc = lc + child.getMeasuredWidth();
   int bc = tc + child.getMeasuredHeight();
   child.layout(lc, tc, rc, bc);
   //将left置为下一子控件的起始点
   left += childWidth;

最后在 onLayout 方法当中,我们需要保存当前父控件的高度来实现收缩,展开效果。

   if (mFirstHeight) {
       contentHeight = getHeight();
       mFirstHeight = false;
       if (mListener != null) {
           mListener.onFirstLineHeight(firstLineHeight);
       }
   }

onLayout 的完整代码如下:

    private void buildLayout() {
        int count = getChildCount();
        int lineWidth = 0;//累加当前行的行宽
        int lineHeight = 0;//当前行的行高
        int top = 0, left = 0;//当前坐标的top坐标和left坐标

        int parentWidth = getMeasuredWidth(); //父控件的宽度

        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            if (i == 0) {
                child.layout(parentWidth - lp.rightMargin - child.getMeasuredWidth(), lp.topMargin, parentWidth - lp
                        .rightMargin, lp.topMargin + child.getMeasuredHeight());
                firstViewWidth = childWidth;
                firstViewHeight = childHeight;
                continue;
            }

            if (firstLineCount == (i + 1)) {
                lineWidth += firstViewWidth;
                lineHeight = Math.max(lineHeight, firstViewHeight);
            }

            if (childWidth + lineWidth > getMeasuredWidth()) {
                //如果换行
                top += lineHeight;
                left = 0;
                lineHeight = childHeight;
                lineWidth = childWidth;
            } else {
                lineHeight = Math.max(lineHeight, childHeight);
                lineWidth += childWidth;
            }
            //计算childView的left,top,right,bottom
            int lc = left + lp.leftMargin;
            int tc = top + lp.topMargin;

            int rc = lc + child.getMeasuredWidth();
            int bc = tc + child.getMeasuredHeight();

            child.layout(lc, tc, rc, bc);
            //将left置为下一子控件的起始点
            left += childWidth;
        }
        if (mFirstHeight) {
            contentHeight = getHeight();
            mFirstHeight = false;
            if (mListener != null) {
                mListener.onFirstLineHeight(firstLineHeight);
            }
        }
    }

布局可定制化

为了实现布局的可定制化,采用了适配模式,

    public void setAdapter(ListAdapter adapter) {
        if (adapter != null && !adapter.isEmpty()) {
            buildTagItems(adapter);//构建标签列表项
        }
    }

先贴出构建标签列表项的代码:

 private void buildTagItems(ListAdapter adapter) {
     //移除所有控件
     removeAllViews();
     //添加首view
     // addFirstView();
     for (int i = 0; i < adapter.getCount(); i++) {
         final View itemView = adapter.getView(i, null, this);
         final int position = i;
         if (itemView != null) {
             if (i == 0) {
                 firstView = itemView;
                 itemView.setVisibility(View.INVISIBLE);
                 itemView.setOnClickListener(new OnClickListener() {
                     @Override
                     public void onClick(View v) {
                         //展开动画
                         expand();
                     }
                 });
             } else {
                 itemView.setOnClickListener(new OnClickListener() {
                     @Override
                     public void onClick(View v) {
                         if (mListener != null) {
                             //item 点击回调
                             mListener.onClick(v, position);
                         }
                     }
                 });
             }
             itemView.setTag(TAG + i);
             mChildViews.put(i, itemView);
             //添加子控件
             addView(itemView);
         }
     }
     //添加底部收起试图
     addBottomView();
 }

获取子控件:

  final View itemView = adapter.getView(i, null, this);

针对第一个子控件,点击展开试图:

    if (i == 0) {
        firstView = itemView;
        itemView.setVisibility(View.INVISIBLE);
        itemView.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                //展开
                expand();
            }
        });

然后添加子控件:

 addView(itemView);

最后添加底部:

    addBottomView(); 

源码在文章的末尾,文章有点长,希望各位继续往后面看。

控件的展开和收缩

控件展开为例:

private void expand() {
    //属性动画
    ValueAnimator animator = ValueAnimator.ofInt(firstLineHeight, contentHeight);
    animator.setDuration(mDuration);
    animator.setInterpolator(new LinearInterpolator());
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            //获取到属性动画值,并刷新控件
            int value = (int) animation.getAnimatedValue();
            getLayoutParams().height = value;
            requestLayout();//重新布局
        }
    });
    animator.addListener(new Animator.AnimatorListener() {
        @Override
        public void onAnimationStart(Animator animation) {
            if (mListener != null) { //主要对蒙层的处理
                mListener.showMask();
            }
            firstView.setVisibility(View.INVISIBLE);//第一个View不可见                
            bottomCollapseLayout.setVisibility(View.VISIBLE);//底部控件可见
        }
        @Override
        public void onAnimationEnd(Animator animation) {
        }
        @Override
        public void onAnimationCancel(Animator animation) {
        }
        @Override
        public void onAnimationRepeat(Animator animation) {
        }
    });
    animator.start();
}

如果你对属性动画还有疑问的话,请参考如下文章:

自定义控件三部曲之动画篇(四)——ValueAnimator基本使用

自定义控件三部曲之动画篇(七)——ObjectAnimator基本使用

文章讲到这里差不多就要结束了,提前预祝大家【五一快乐】

第二种简单实现方式,效果图如下:

GIF.gif

如有什么疑问,欢迎讨论,以下是联系方式:

qq

源码地址

猜你喜欢

转载自blog.csdn.net/u012551350/article/details/70666554