一个可以自动换行来适应行宽的标签列表控件——教你如何使用Android自定义ViewGroup的onMeasure和onLayout

前言

添加标签,但是标签的字数不固定,所以造成一个问题,如果每行的标签的个数固定,可能某些行的标签会溢出屏幕。那么自适应行宽,能自动换行的标签容器控件就可以完美解决此类问题。因此,这篇文章向大家介绍如何通过自定义View实现此类可能会经常遇见需求。

 

效果图

 

使用方法

第一步:添加JitPack repository到项目的build.gradle

allprojects {
		repositories {
			...
			maven { url 'https://jitpack.io' }
		}

第二步:添加依赖到app的build.gradle

dependencies {
	        implementation 'com.github.Simonzhuqi:SmartTabContainerLayout:1.0'
	}

第三步:在需要显示此控件的xml布局文件中添加控件

    <com.doophe.smarttabcontainerlib.SmartTabContainerLayout
        android:id="@+id/tab_container_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/darker_gray"
        android:padding="30dp"
        app:tab_background="@drawable/default_bg_tab"
        app:tab_text_size="8sp"
        app:tab_text_color="@android:color/black"
        app:tab_vertical_span="6dp"
        app:tab_horizontal_span="12dp"
        app:tab_padding_left="20dp"
        app:tab_padding_top="6dp"
        app:tab_padding_right="20dp"
        app:tab_padding_bottom="6dp"
        ></com.doophe.smarttabcontainerlib.SmartTabContainerLayout>

第四步:在Java代码中查找到控件并调用相关方法

 

参数说明

    <declare-styleable name="TabContainerLayout">
        <!-- 标签的背景 -->
        <attr name="tab_background" format="reference"></attr>        
        <!-- 标签的中字体大小 -->
        <attr name="tab_text_size" format="dimension"></attr>
        <!-- 标签的中字体颜色 -->
        <attr name="tab_text_color" format="color"></attr>
        <!-- 标签之间的行间距-->
        <attr name="tab_vertical_span" format="dimension"></attr>
        <!-- 标签之间的水平间距 -->
        <attr name="tab_horizontal_span" format="dimension"></attr>
        <!-- 标签的左内边距 -->
        <attr name="tab_padding_left" format="dimension"></attr>
        <!-- 标签的上内边距 -->
        <attr name="tab_padding_top" format="dimension"></attr>
        <!-- 标签的右内边距 -->
        <attr name="tab_padding_right" format="dimension"></attr>
        <!-- 标签的下内边距 -->
        <attr name="tab_padding_bottom" format="dimension"></attr>
    </declare-styleable>

 

目前支持方法

    /**
     * 添加标签
     *
     * @param t 标签上显示的文字
     */
    public void addTab(String t) {
        if (t != null && !t.trim().isEmpty()) {
            addView(createTabView(t), getChildCount());
        }
    }

    /**
     * 批量添加标签
     *
     * @param tabs 
     */
    public void addTabs(String[] tabs) {
        if (tabs != null && tabs.length > 0) {
            for (String tab : tabs) {
                addTab(tab);
            }

        }
    }

    /**
     * 监听回调(单击和长按的回调)
     * @param callback
     */
    public void addCallback(Callback callback) {
        this.mCallback = callback;
    }

 

实现思路

首先考虑用哪种形式的自定义View。关于自定义View的实现有以下几种方式:

  1. 继承View

  2. 继承ViewGroup

  3. 继承Android已经封装好的控件(例如 RelativeLayout)

  4. 组合Android已经封装好的控件

这里选择第2种方式,继承ViewGroup实现。原因是相比直接继承View的方式实现起来更加方便,而如果使用继承RelativeLayout的方式也能实现,相对布局可以通过子View的位置来定位其他子View,并支持Margin参数,但是这些特征对于实现以上的需求可有可无,因此笔者选择直接继承ViewGroup的实现方式。

既然选择好了大方向,那边就可以进入正题了。如何自定义一个全新的ViewGroup?讲到这里,恰巧今天早上在鸿洋的微信公众号上看到一篇文章,里面有句话说,“据官方统计,超过一半的开发者没有写过自定义ViewGroup;接近六成的人不知道MeasureSpec......”。我不确认这是否属实,但是个人觉得这些都是Android开发者必须掌握的基础。其实有时候就算了解这些原理,现场要你完全自己手写一个比较复杂的自定义控件,并且具有良好的封装,那也是挺难的。所以平时还是要多实践,“纸上得来终觉浅,绝知此事要躬行”。回到正题,“如何自定义一个全新的ViewGroup”。很简单,无非就算三部曲:onMeasure、onLayout以及onDraw。这里无需onDraw,只要重写前两个方法就可以。

重写onMeasure——测量出自定义控件整体的宽和高

这里不详细介绍Android的测量原理,各自书籍和文章很多都有详细解读。

Android中的View类中对onMeasure方法做了默认的实现,下面会讲到。ViewGroup中没有对onMeasure方法进行重写,但继承ViewGroup的控件都会去重写onMeasure方法,其主要目的是在onMeasure方法中实现对子控件的测量。

下面介绍Android对于View的测量的大致过程。

根据屏幕尺寸测量出DecorView的宽高,这里的DecorView读者可以理解为根View。DecorView是继承自FrameLayout,因此它下面肯定还有子控件,我们在Activity的onCreate方法下的set的contentView就算其中的子控件之一。我们的contentView宽高的测量就是在DecorView的父类FrameLayout的onMeasure方法中测量出来的,测量的依据是父控件的宽高和子控件的参数设置。这边贴下源码作为证据,在measureChildWithMargins中分别对子控件进行测量。

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int count = getChildCount();

        final boolean measureMatchParentChildren =
                MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY ||
                MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY;
        mMatchParentChildren.clear();

        int maxHeight = 0;
        int maxWidth = 0;
        int childState = 0;

        for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (mMeasureAllChildren || child.getVisibility() != GONE) {
                //在此测量子控件
                measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
                maxWidth = Math.max(maxWidth,
                        child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
                maxHeight = Math.max(maxHeight,
                        child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
                childState = combineMeasuredStates(childState, child.getMeasuredState());
                if (measureMatchParentChildren) {
                    if (lp.width == LayoutParams.MATCH_PARENT ||
                            lp.height == LayoutParams.MATCH_PARENT) {
                        mMatchParentChildren.add(child);
                    }
                }
            }
        }
...
}

由此可见,在父控件的onMeasure方法中完成了对子控件的测量,就这样一层层类似递归的进行测量,直到某个不包含子控件的View为止。要注意的是如果是自定义ViewGroup,在onMeasure中必须对其含有的子控件进行测量,否则子控件是无法显示出来的。

当我们重写这个自定义ViewGroup的onMeasure的时候,如何测量出准确的宽和高呢?

首先读者需要了解,如果自定义ViewGroup不重写onMeasure方法,它的宽和高将和父控件的宽和高大小一样。从源码分析可以得出这个结论,查看View下默认的onMeasure:

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

最终宽和高的大小通过getDefaultSize得到:

 /**
     * Utility to return a default size. Uses the supplied size if the
     * MeasureSpec imposed no constraints. Will get larger if allowed
     * by the MeasureSpec.
     *
     * @param size Default size for this view
     * @param measureSpec Constraints imposed by the parent(父控件的测量大小)
     * @return The size this view should be.
     */
    public static int getDefaultSize(int size, int measureSpec) {
        int result = size;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }

这里的reslut = specSize这个specSize就是父控件的测量大小。

根据需求,我们不能放任我们的自定义控件的高跟随父控件的高来决定,必须根据一定的规则计算出精确的高度,而宽可以和父控件保持一致。分析到这里,现在的小目标就是要计算出自定义控件的高。接下来继续分析得出决定自定义控件高度的因素有三个,第一是子View的行高(子View的高+行间距),第二就是子View的行数,第三自定义控件Padding值(在View的测量过程中不能忽略Padding)。得出公式:

自定义控件高度 = (子View的高+行间距)x 子View的行数 + PaddingTop + PaddingBottom

这里子View的高度可以通过对子View的测量得到,行间距是用户可以设置的,直接可以得到,Padding的值也很容易获取;那么子View的行数如何获得?答:可以遍历子View根据累加子View的宽度+横向间距判断是否需要换行,从而通过累加计算出准确无误的行数。这样整个onMeasure的核心数据“自定义控件高度”就计算好了,接下来setMeasuredDimension传入默认的宽和计算得到的高就完成测量了。在此贴上具体实现的代码:

  @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        mWidth = View.MeasureSpec.getSize(widthMeasureSpec);
        mHeight = 0;
        int leftPadding = getPaddingLeft();
        int rightPadding = getPaddingRight();
        int topPadding = getPaddingTop();
        int bottomPadding = getPaddingBottom();
        int lineTotalWidth = 0;
        int totalLine = 1;
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if(child.getVisibility() == GONE){
                continue;
            }
            //在此测量子View
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            //测量后子View的宽
            int childWidth = child.getMeasuredWidth();
            //测量后子View的高
            int childHeight = child.getMeasuredHeight();
            //通过累加子View宽度的和判断是否超过父控件的宽,超过则进行换行
            lineTotalWidth += childWidth;
            if (lineTotalWidth > mWidth - leftPadding - rightPadding) {
                //通过累加计算总行数
                totalLine++;
                lineTotalWidth = childWidth;
            }
            lineTotalWidth += mHorizontalSpan;

            if (mHeight == 0) {
                mHeight = (int) (childHeight + mVerticalSpan);
            }
        }
        if (mHeight != 0) {
            // 自定义控件高度 = (子View的高+行间距)x 子View的行数 + PaddingTop + PaddingBottom
            mHeight = (int) (totalLine * mHeight - mVerticalSpan) + topPadding + bottomPadding;
        }
        setMeasuredDimension(mWidth, mHeight);
    }

重写onLayout方法——对各个子View进行精准定位

在完成了对这个自定义控件的测量主要是对子View的测量后,已经确定的整体的大小,接下来就是要确定各个子View在以及测量好大小的控件中显示的位置。

相比onMeasure,onLayout方法更好理解。

在Android中View和ViewGroup的源码均未对onLayout进行实现。

在View的源码中,onLayout方法是一个空方法。

    /**
     * Called from layout when this view should
     * assign a size and position to each of its children.
     *
     * Derived classes with children should override
     * this method and call layout on each of
     * their children.
     * @param changed This is a new size or position for this view
     * @param left Left position, relative to parent
     * @param top Top position, relative to parent
     * @param right Right position, relative to parent
     * @param bottom Bottom position, relative to parent
     */
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    }

而在ViewGroup的源码中,onLayout方法被设置成了抽象方法,因此继承自ViewGroup的控件必须去重写onLayout方法。

    @Override
    protected abstract void onLayout(boolean changed,
            int l, int t, int r, int b);

那么在我们这个自定义控件中,如何去重写这个方法呢?

重写onLayout的关键是对子View进行遍历,分别调用View的layout方法对各个可见的子View定位。View的layout方法需要传入四个参数,分别对应子View在父控件中left、top、right、bottom位置。可以通过计算得到这四个值:

left = PaddingLeft + 同一行的子View的宽之和(子View宽+ 水平间距)

top = (子View的高 + 行间距)x 行数 + PaddingTop

right = left + 子View的宽

bottom = top + 子View的高

源码如下:

 @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

        int lineTotalWidth = 0;
        int totalLine = 0;
        int childCount = getChildCount();

        int leftPadding = getPaddingLeft();
        int rightPadding = getPaddingRight();
        int topPadding = getPaddingTop();

        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if(child.getVisibility() == GONE){
                continue;
            }
            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();

            lineTotalWidth += childWidth;
            if (lineTotalWidth > mWidth - leftPadding - rightPadding) {
                totalLine++;
                lineTotalWidth = childWidth;
            }

            l = lineTotalWidth - childWidth + leftPadding;
            t = (int) (totalLine * (childHeight + mVerticalSpan)) + topPadding;
            r = l + childWidth;
            b = t + childHeight;

            child.layout(l, t, r, b);

            lineTotalWidth += mHorizontalSpan;
        }
    }

 

至此,主要的两个核心方法介绍完成了!关于这个项目还有很多功能可以迭代进去,一些地方可以封装成类运用一些设计模式。而关于Android自定义控件还有如何重写onDraw方法绘制图形,以及对滑动事件的处理等等,还要考虑最终的性能,和后期的迭代。只有多实践多思考才能运用自如,具备工匠精神才能打造出优秀的控件。很多程序员一直在搬砖,是时候尝试着自己制造些砖头。

最后附上GitHub的传送门。

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android自定义ViewGroup是指在Android开发中,通过继承ViewGroup类来创建自定义的布局容器自定义ViewGroup可以用于实现一些特殊的布局效果,比如侧滑菜单、滑动卡片等等。通过自定义ViewGroup,我们可以更灵活地控制子视图的布局和交互行为,以满足特定的需求。自定义ViewGroup的实现主要包括重写onMeasure()方法和onLayout()方法,来测量和布局子视图。同时,我们还可以通过重写onInterceptTouchEvent()方法和onTouchEvent()方法来处理触摸事件,实现自定义的交互效果。如果你对自定义ViewGroup还不是很了解,或者正想学习如何自定义,可以参考相关的程和文档,如引用\[1\]和引用\[2\]所提到的博客和官方文档。 #### 引用[.reference_title] - *1* [Android 手把手自定义ViewGroup(一)](https://blog.csdn.net/iteye_563/article/details/82601716)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [使用LayoutParams自定义安卓ViewGroup](https://blog.csdn.net/lfq88/article/details/127268493)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [Android自定义ViewGroup](https://blog.csdn.net/farsight2009/article/details/62046643)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值