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

来源:互联网 发布:java线程教程 编辑:程序博客网 时间:2024/05/21 08:54

一、前言

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

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

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

标签控件

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

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

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

源码地址

2 2
原创粉丝点击