美文网首页Android自定义控件Android控件专题我的Andorid 收藏
那些年我们熬夜打造一可收缩流式标签控件

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

作者: 文淑 | 来源:发表于2017-04-28 16:27 被阅读151次

    一、前言

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

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

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

    标签控件

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

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

    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

    源码地址

    相关文章

      网友评论

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

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