Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

古人学问无遗力, 少壮工夫老始成。纸上得来终觉浅, 绝知此事要躬行。 – 陆游 《冬夜读书示子聿》

上周 Google I/O 大会的召开,宣布了 Kotlin 语言正式成为了官方开发语言。一时间 Android 开发者的圈子炸开了锅,各种关于 Kotlin 的资料介绍也如雨后春笋不断的冒出。

大家都对这比较关心,我觉得最大的原因是,当初宣布 Android Studio 成为官方 IDE 后,很多开发者都还在坚守 Eclipse,但是现在来看,大部分都转为 Android Studio 开发了。所以,开发者肯定担心,Kotlin 会不会也最后完美取代 Java 呢?

我是在官网看了下资料,简单入门的。

我确实感受到了 Kotlin 与 Java 的不同,但我不觉得 Java 已经老态龙钟了,相反我对 Java 有感情,未来的几年我将会更深入地学习和研究它的语言特性和虚拟机底层细节。

我认为编程思想是最重要的,语言是其次。所以,我可以用 Kotlin 来替代平时通过 Java 实现的代码。

光说不练,假把式。语法大家都看得懂,关键是在于对于陌生事物,只有反复刻意的练习,你才能进入自己的舒适区。

好了,下面进入我们的主题,通过 Kotlin 来实现一个自定义 ViewGroup。这篇博文的目的也算作是个人针对 Kotlin 学习的编程练习吧。

当然,首先我已经默认大家知道怎么通过 Android Studio 创建 Kotlin 工程了。如果还不熟悉的话,请自行查阅相关资料。

然后,这篇文章目的也不是为了讲解 kotlin 的基础语法的,也希望不熟悉 kotlin 的同学先去官网通读一遍基础语法。

不过,我还是会在博文中适当地介绍一下 kotlin 一些语法特性。

自定义 ViewGroup 之流式标签控件

对于软件开发者而言,流式标签控件想必大家一定见过,如下图:

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

至于为什么叫做流式标签呢?我想可能因为是在 Html 开发时,网页的布局有个流式布局的概念的,模块都是自动向左贴紧,如果屏幕不能在一行显示内容,就会进行适当的换行。上面的这个控件的场景比较像,所以叫流式标签控件。也许讲得不对,但便于自己的理解,如有错误希望热心网友批评指出。

显然这个流式标签控件是一个 ViewGroup,所以我们就需要自定义这样一个 ViewGroup,取名字叫做 TagView,后方中所有的 TagView 都是指代要实现的这个流式标签控件。

测量尺寸

我们大多都知道,自定义一个 View 需要测量、布局、绘制三个流程。而我个人觉得这三个流程中,测量是最让初学者头痛的问题。因此我特地写了一篇博文《长谈:关于 View Measure 测量机制,让我一次把话说完 》 为的就是想一次性把测量细节说清楚,有兴趣的同学可以去看看。好了,回到主题,接下来我们就需要来思考怎么样测量 TagView 的尺寸。

自定义 View 需要考虑到两种测量模式:MeasureSpec.EXACTLY 和 MeasureSpec.AT_MOST。

MeasureSpec.EXACTLY

对于这种模式,我们知道 layout_width 或者 layout_height 的取值为 match_parent 或者是具体的尺寸如 30dp。针对这种情况,其实我们用不着处理,因为 parent 在子 View 的 onMeasure() 中传递的尺寸规格里面就包含了建议尺寸,而这个尺寸是精确的,所以我们只需要在 onMeasure() 方法的最后调用 setMeasureDimension() 并传入相应的值便是。

MeasureSpec.AT_MOST

对于这种测量模式,开发者面对的处境难一些。对于自定义 View 而言要根据业务需求,确定好自身的内容显示范围。而对于自定义 ViewGroup 而言,它的难度更加提高了。因为它的尺寸是要根据子 view 来确定的,所以测量子 View 的尺寸也就成了它的第一部。好在系统自带相应的 API,measureChildren() 和 measureChild() 方法,减少了开发者的负担。

但是,测量了子 View 只是第一步,接下来的这一步麻烦的地方是要结合布局来确定一个 ViewGroup 它最终在某个维度上的尺寸。而每个 ViewGroup 要实现的业务需求不一样,所以也没有用一种规格来适用于所有的 ViewGroup,只能是具体情况具体分析了。下面我们就来具体分析下 TagView。

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

经观察,TagView 最重要的尺寸信息其实就是它的 width。因为所有的子 View 不能在一行排列,所有的子 View 按自左向右的顺序排列,如果当前子 View 的显示范围超过了图中红框部分,也就是 parent 本身的尺寸范围,那么子 View 就应该换行在新的一行重新自左向右顺序排列,由于每个子 View 的宽度不一样,所以会造成每一行需要的宽度也不一样。

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

在上面的线框图中,TagView 有 3 行,而行所需要的宽度也是不一样的,这就造成了一个问题,对于 TagView 整体而言,在 layout_width 取值为 wrap_content 的时候,究竟哪一些行的宽度作为 TagView 的宽度尺寸呢?答案是明显的,肯定是宽度值最大的那一数值。

而 layout_height 为 wrap_content 而言,TagView 的高度值自然是每一行的高度值之和,这里为了美观而言。假定每个子 View 的高度是一致的。

好了,我们整理下思路。

  1. 测量子 View 的尺寸。
  2. 根据布局的特点,测量最小的宽高尺寸,并且这个数值不能大于 parent 给出的建议 size。
  3. 对于宽度而言,由于 TagView 每一行宽度可能不同,所以需要找出最宽的那一行。
  4. 对于高度而言,TagView 整体高度就是各行之和。
  5. 当然在 MeasureSpec.AT_MOST 测量规格下,尺寸数值是要包含 TagView 自身的 padding 和子 View 的 margin 值的。

布局

根据 TagView 的业务需求,所有的子 View 按自左向右的顺序排列,如果当前子 View 的显示范围超过了图中红框部分,也就是 parent 本身的尺寸范围,那么子 View 就应该换行在新的一行重新自左向右顺序排列。所以编码的思路便是遍历所有的子 View,然后依次排列,并且每次对子 View 进行 layout 之前,要预算它的显示范围,如果超出了 parent 的宽度,那么它就需要换行。

绘制

自定义 View 中绘制相关的方法是 onDraw(),但在 TagView 中它并不需要绘制特殊的界面效果,所以我们可以不理它。

具体编码

上面分析了要实现这样一个 TagView 的思路,接下来就是具体编码的过程。

创建一个 Kotlin 类

class TagView(context: Context) : ViewGroup(context) {

}

Kotlin 同 Java 一样,用关键字 class 来定义一个类,不同的是 Java 用 extends 表示继承,而 Kotlin 用一个 :实现。

TagView 需要在 xml 布局文件中使用,所以仅仅定义一个 TagView(context:Context) 构造函数是不够的,我们还需要定义另外一个。在 Kotlin 中构造函数与 Java 的构造方法也有不同。大家可以仔细感受一下。

class TagView(context: Context) : ViewGroup(context) {
    val TAG : String = "TagView"
    var mBackgroundDrawable: Drawable ? = null

    constructor(context: Context,attrs: AttributeSet): this(context) {
        val ta : TypedArray = context!!.obtainStyledAttributes(attrs,R.styleable.TagView)
        mBackgroundDrawable = ta.getDrawable(R.styleable.TagView_android_background)

        ta.recycle()

        if (mBackgroundDrawable != null ) {
            setBackgroundDrawable(mBackgroundDrawable)
        }
    }
}

大家仔细观察一下,第二个构造函数,它委托调用了 this()。这是因为有一条规则:

如果类有一个主构造函数(无论有无参数),每个次构造函数需要直接或间接委托给主构造函数,用this关键字

大家看到我在构造函数中获取了 mBackgroundDrawable 的值,其实这一步是有意为之,我特地为了测试在 kotlin 中获取自定义属性弄了这么一处。

attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="TagView">
        <attr name="android:background" />
    </declare-styleable>
</resources>

另外注意的地方是,我们希望子 View 拥有 margin 属性。所以我们要复写一个方法。

override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
    return MarginLayoutParams(context,attrs)
}

编写 onMeasure() 逻辑代码

前面已经详细分析了思路,所以呢接下来的编程自然是水到渠成。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val suggestWidth = MeasureSpec.getSize(widthMeasureSpec)

    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val suggestHeight = MeasureSpec.getSize(heightMeasureSpec)

    //测量子 View 尺寸信息
    measureChildren(widthMeasureSpec,heightMeasureSpec)

    /**
     *  主要处理 width 和 height AT_MOST 测量模式下的情况
     *  在 width 方面,TagView 中的子元素要求出所有行中的宽度最大的一行,并且这个数值
     *  不能大于 parent 给出的建议宽度
     * */

    var cWidth : Int
    var cHeight : Int
    var lineWidth : Int = paddingLeft + paddingRight
    var lineMaxWidth : Int = lineWidth
    var lineHeight : Int = paddingBottom + paddingTop
    var childlPara : MarginLayoutParams
    var resultW : Int = suggestWidth
    var resultH : Int = suggestHeight

    for ( index in 0..childCount - 1) {
        val view = getChildAt(index)
        childlPara = view.layoutParams as MarginLayoutParams
        // 子 View 的实际宽高包含它们的 margin
        cWidth = view.measuredWidth + childlPara.leftMargin + childlPara.rightMargin
        cHeight = view.measuredHeight + childlPara.topMargin + childlPara.bottomMargin

        if (widthMode == MeasureSpec.AT_MOST) {
            // 如果此次排列后,这一行的宽度超过 parent 提供的 size 就表明要换行了
            if ( lineWidth + cWidth > suggestWidth ) {
                // 换行后需要重置 lineWidth
                lineWidth = paddingLeft + paddingRight + cWidth
                lineHeight += cHeight

            } else {
                // lineWidth 对子 View 宽度进行累加
                lineWidth += cWidth
            }

            if ( lineWidth > lineMaxWidth ) {
                更新最大的行宽数值
                lineMaxWidth = lineWidth
            }
        }
    }

    if (widthMode == MeasureSpec.AT_MOST) {
        resultW = lineMaxWidth
    }

    if ( heightMode == MeasureSpec.AT_MOST) {
        resultH = lineHeight
        if (resultH > suggestHeight ) {
            resultH = suggestHeight
        }
    }

    setMeasuredDimension(resultW,resultH)

    Log.d(TAG,"onMeasure w:"+resultW+" h:"+resultH)

}

代码何其相似,简直和 Java 实现流程一模一样,不一样的只是变量和方法的定义形式。

kotlin 函数的定义

kotlin 用一个关键字 fun 定义函数,如果不指定返回值,它返回的是 Unit,Unit 跟 Java 中的 Void 类似,但 Unit 是真正的对象。典型的 kotlin 函数形式如下:

fun add(x: Int, y: Int) : Int {
    return x + y
}

kotlin 中变量的定义都是 x : 类型 的形式,并且不同于 Java,函数的返回值也是在方法名最后用 :类型如上面示例的右括号后面的 :Int。

kotlin 变量的定义

kotlin 的变量分为 val( 不可变) 和 var( 可变 )。val 同 Java 中的 final 关键字

val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val suggestWidth = MeasureSpec.getSize(widthMeasureSpec)

var cWidth : Int

kotlin 建议定义变量的时候尽量用 val,当然在确定变量会多次赋值时用 var。

kotlin 中的条件循环

上面的代码我们看到了一个 for 循环,但是跟 Java 中的也不一样。

通常的 for 循环如下形式


for ( item in collection ) {
    ......
}

collection 是一个集合,in 是关键字,表示遍历 collection 中每一个 item。

当然 for 循环还有以 index 形式,这是广大 Java 开发者乐于接受的。上面的代码,遍历子 View 时就是这种方式。

for ( index in 0..childCount - 1) {
    ......
}

好的,上面简单回顾了一下 kotlin 的基础语法。现在回到 TagView 代码本身。

在 onMeasure() 中我给代码进行了较为详细的注释,开发流程也是根据之前分析的思路。相信大家能看得比较明白。

核心就在于 MeasureSpec.AT_MOST 模式下,确定最宽的那一行的宽度值,然后根据行数确定 TagView 的高度。

编写 onLayout 的逻辑代码

onLayout 与布局有关,其实前面的 onMeasure() 方法中确定宽高尺寸的时候,就是根据布局方案来的。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    Log.d(TAG,"onLayout")
    var left : Int = paddingLeft
    val right : Int = width - paddingRight
    var top : Int = paddingTop
    val bottom : Int = height - paddingBottom
    var lp : MarginLayoutParams
    var cw : Int
    var ch : Int

    for (index in 0..childCount - 1){
       var view = getChildAt(index)
        lp = view.layoutParams as MarginLayoutParams
        cw = view.measuredWidth + lp.leftMargin + lp.rightMargin
        ch = view.measuredHeight + lp.topMargin + lp.bottomMargin

        //该换行了
        if (left + cw > right ) {
            left = paddingLeft
            top += ch
        }
        //如果高度超出了范围就退出绘制
        if (top >= bottom) break

        view.layout(left + lp.leftMargin,top+lp.topMargin,left + cw,top + ch)
        left += cw

    }
}

主要逻辑就是当子 View 一行的宽度要超过 TagView 本身尺寸时就换行。代码非常简单,不再详细讲解。

编写测试代码

我们默认为 TagView 的子 View 为 TextView。所以,为了美观大方,我们先给它定义一个背景。我们可以用一个 shape 实现。

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <corners android:radius="30dp" />
    <stroke android:color="#cc0033" android:width="1dp"/>
    <padding android:top="2dp" android:bottom="2dp" android:left="20dp" android:right="20dp" />
</shape>

我们现在可以对 TagView 进行测试了,我们可以在布局文件 activity_main.xml 中添加代码。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.frank.kotlindemo.MainActivity">
<com.frank.kotlindemo.TagView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:background="@drawable/test"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:text="Android" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Java" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Python" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="JavaScript" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Html" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="CSS" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Go语言" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Bootstrap" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Node.js" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Vue.js" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/test"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:text="PHP" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="MySQL" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/test"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:text="Oracle" />
</com.frank.kotlindemo.TagView>
</FrameLayout>

然后,它的最终效果图如下:

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

自此,TagView 就算初步完成了。但是还是有很多地方需要优化。

TagView 优化之处

针对子 View visibility 为 gone 的处理

上面的例子中,我们默认所有的子 View 都是可见的,实际上呢?如果我们将测试代码稍微改一下,会怎么样?

<com.frank.kotlindemo.TagView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:background="@drawable/test"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:visibility="gone"
        android:text="Android" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Java" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Python" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="JavaScript" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:visibility="gone"
        android:background="@drawable/test"
        android:text="Html" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="CSS" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Go语言" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Bootstrap" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Node.js" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="Vue.js" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/test"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:text="PHP" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:background="@drawable/test"
        android:text="MySQL" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/test"
        android:textSize="24sp"
        android:layout_marginLeft="2dp"
        android:layout_marginTop="2dp"
        android:text="Oracle" />
</com.frank.kotlindemo.TagView>

我们将两个选项设置为 gone,实际效果怎么样呢?

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

可以发现其实没有多大影响,TagView 还是按照正确的方式显示。我猜应该是获取子元素的时候,属性为 gone 的子元素不能获取。

那好,系统自动帮我们处理了这种情况。

TagView 中子 View 的高度问题。

按照之前的设想,我们假定的是每个子 View 的高度是一致的,但是如果实际运行中不一致呢?会出现什么情况?

<TextView
    android:layout_width="wrap_content"
    android:layout_height="50dp"
    android:textSize="24sp"
    android:background="@drawable/test"
    android:layout_marginLeft="2dp"
    android:layout_marginTop="2dp"
    android:text="Android" />

我们将第一个子 View 高度设置为 50 dp,显然它的高度比其它的 TextView 要高,这个时候 TagView 会发生什么呢?

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

这个结果肯定就不是我们想要的了。我们希望每个子 View 高度一致,如果不一致也行,尊重你,但是我们需要在 TagView 中进行处理,把每一行的行高变成那一行中最高的子 View 的高度值。所以 TagView 代码要做处理。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val suggestWidth = MeasureSpec.getSize(widthMeasureSpec)

    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val suggestHeight = MeasureSpec.getSize(heightMeasureSpec)

    measureChildren(widthMeasureSpec,heightMeasureSpec)

    /**
     *  主要处理 width 和 height AT_MOST 测量模式下的情况
     *  在 width 方面,TagView 中的子元素要求出所有行中的宽度最大的一行,并且这个数值
     *  不能大于 parent 给出的建议宽度
     * */

    var cWidth : Int
    var cHeight : Int
    var lineWidth : Int = paddingLeft + paddingRight
    var lineMaxWidth : Int = lineWidth
    var lineHeight : Int = paddingBottom + paddingTop
    // 每行的高度
    var singleLineHeight : Int = 0
    var childlPara : MarginLayoutParams
    var resultW : Int = suggestWidth
    var resultH : Int = suggestHeight

    for ( index in 0..childCount - 1) {
        val view = getChildAt(index)
        childlPara = view.layoutParams as MarginLayoutParams

        cWidth = view.measuredWidth + childlPara.leftMargin + childlPara.rightMargin
        cHeight = view.measuredHeight + childlPara.topMargin + childlPara.bottomMargin

        if (widthMode == MeasureSpec.AT_MOST) {
            if ( lineWidth + cWidth > suggestWidth ) {
                lineWidth = paddingLeft + paddingRight + cWidth
                lineHeight += singleLineHeight
                // 换行后要重置单行的高度
                singleLineHeight = cHeight

            } else {
                lineWidth += cWidth
                if ( lineWidth > lineMaxWidth ) {
                    lineMaxWidth = lineWidth
                }

            }

            if (singleLineHeight < cHeight) {
                    singleLineHeight = cHeight
                }

            if (index == childCount - 1) {
                lineHeight += singleLineHeight
            }
        }
    }

    if (widthMode == MeasureSpec.AT_MOST) {
        resultW = lineMaxWidth
    }

    if ( heightMode == MeasureSpec.AT_MOST) {
        resultH = lineHeight
        if (resultH > suggestHeight ) {
            resultH = suggestHeight
        }
    }

    setMeasuredDimension(resultW,resultH)

}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    Log.d(TAG,"onLayout")
    var left : Int = paddingLeft
    val right : Int = width - paddingRight
    var top : Int = paddingTop
    val bottom : Int = height - paddingBottom
    // 每行的高度
    var singleLineHeight : Int = 0
    var lp : MarginLayoutParams
    var cw : Int
    var ch : Int

    for (index in 0..childCount - 1){
       var view = getChildAt(index)
        lp = view.layoutParams as MarginLayoutParams
        cw = view.measuredWidth + lp.leftMargin + lp.rightMargin
        ch = view.measuredHeight + lp.topMargin + lp.bottomMargin

        //该换行了
        if (left + cw > right ) {
            left = paddingLeft
            top += singleLineHeight
            singleLineHeight = ch
        } else {
            if (singleLineHeight < ch) {
                singleLineHeight = ch
            }
        }
        //如果高度超出了范围就退出绘制
        if (top >= bottom) break

        view.layout(left + lp.leftMargin,top+lp.topMargin,left + cw,top + ch)
        left += cw
    }
}

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

如上面代码所示,给每行确定好高度之后,TagView 显示就很完善了。

TagView 的子 View

其实到了这里的时候,这个初级的 TagView 就已经完成了。但是功能还是比较简单。它的子 View 都是 TextView 然后背景在 xml 中用统一的 shape 来代替,所以我们可以实现圆角矩形的式样。如果我们还想更*一点,那么就需要自定义一个 View 了,那将是另外一话题了。

自定义一个 View,步骤无非也是测量、绘制。因为篇幅过长,接下来的内容我简单带过。

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

我给自定义的 View 取名叫做 Tag。它是一个封闭图形,左边一个半圆,中间一个矩形,右边是一个半圆。然后,内容区域主要是 title 部分,它可以自定义 textSize,还有距中间矩形的间距。

Tag 的尺寸测量

主要是在 MeasureSpec.AT_MOST 情况下,测量文字内容的大小,然后通过它的四个方向的间距,再加上两个半圆的尺寸再确定整个 Tag 的尺寸。相关代码如下:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val widthSize = View.MeasureSpec.getSize(widthMeasureSpec)
    val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)

    val heightSize = View.MeasureSpec.getSize(heightMeasureSpec)
    val heightMode = View.MeasureSpec.getMode(heightMeasureSpec)
    //用于保存最终尺寸
    var resultW = widthSize
    var resultH = heightSize

    // contentW contentH 用于确定中间矩形的尺寸
    var contentW = 0
    var contentH = 0

    val textWidth : Int

    if (widthMode == View.MeasureSpec.AT_MOST) {
        textWidth = mTextpaint?.measureText(text)!!.toInt()
        contentW += textWidth + mLeftRightPadding * 2 + radiu * 2

        resultW = if (contentW < widthSize) contentW else widthSize
    }

    if (heightMode == View.MeasureSpec.AT_MOST) {
        contentH += mTopBottomPadding * 2 + mTextSize
        resultH = if (contentH < heightSize) contentH else heightSize
    }

    // 修整圆形的半径
    radiu = resultH / 2
    setMeasuredDimension(resultW, resultH)
}

代码非常简单。接下来就是绘制。但是在分析绘制之前,先介绍下 Tag 自定义的属性。

<declare-styleable name="Tag">
    <attr name="android:text" />
    <attr name="android:background" />
    <attr name="android:textSize" />
    <attr name="stroke_color" format="color|reference" />
    <attr name="title_color" format="color|reference" />
    <attr name="tag_background" format="color|reference" />
    <attr name="tag_padding_top_bottom" format="dimension|reference" />
    <attr name="tag_padding_left_right" format="dimension|reference" />
</declare-styleable>

然后属性的获取在 Tag 的构造函数当中。

constructor(context: Context, attrs: AttributeSet):this(context) {
    val ta : TypedArray = context.obtainStyledAttributes(attrs,R.styleable.Tag)
    text = ta.getString(R.styleable.Tag_android_text)

    mTextSize = ta.getDimensionPixelSize(R.styleable.Tag_android_textSize,
            mDefaultTextSize)
    mStrokeColor = ta.getColor(R.styleable.Tag_stroke_color, Color.RED)
    mTextColor = ta.getColor(R.styleable.Tag_title_color, Color.WHITE)

    mTopBottomPadding = ta.getDimensionPixelOffset(R.styleable.Tag_tag_padding_top_bottom, 10)
    mLeftRightPadding = ta.getDimensionPixelOffset(R.styleable.Tag_tag_padding_left_right, 10)
    mTagBackgroundColor = ta.getColor(R.styleable.Tag_tag_background, Color.WHITE)

    ta.recycle()

    initDatas()
}

Tag 的绘制

Tag 的绘制主要包括两个步骤:绘制封闭图形和绘制文字。

Tag 的图形可以由 Path 实现。所以,我们可以在 onSizeChange() 方法中确定这个 Path。然后在 onDraw() 方法中绘制。

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)

    if (mPath == null) {
        mPath = Path()
    }

    mPath?.reset()

    var leftStart = paddingLeft + mStrokeWidth
    var topStart  = paddingTop + mStrokeWidth
    var rightEnd = width - paddingRight - mStrokeWidth
    var bottomEnd = height - paddingBottom - mStrokeWidth

    leftRect?.set(leftStart.toFloat(), topStart.toFloat(), (leftStart + radiu * 2).toFloat(), bottomEnd.toFloat())
    rightRect?.set((rightEnd - radiu * 2).toFloat(), topStart.toFloat(), rightEnd.toFloat(), bottomEnd.toFloat())
    //path 起始位置
    mPath?.moveTo((leftStart+radiu).toFloat(),bottomEnd.toFloat())
    // 左边半圆
    mPath?.arcTo(leftRect,
            90.0f, 180f)
    //连接到右边半圆
    mPath?.lineTo((rightEnd - radiu * 2).toFloat(), topStart.toFloat())
    // 右边半圆
    mPath?.arcTo(rightRect,
            270.0f, 180f)
    // path 闭合
    mPath?.close()

    var textDescent = mTextpaint?.fontMetrics?.descent
    val textAscent : Float? = mTextpaint?.fontMetrics?.ascent
    delta = Math.abs(textAscent!!) - textDescent!!

    cx = width / 2
    cy = height / 2

}

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)

    drawPath(canvas!!)

   canvas?.drawText(text, cx.toFloat(), (cy + delta / 2),mTextpaint)

}

private fun drawPath(canvas: Canvas) {

    // 以填充的方向将图形填充为指定的背景色
    canvas.drawPath(mPath, paint)

    paint?.style = Paint.Style.STROKE
    paint?.color = mStrokeColor
    canvas.drawPath(mPath, paint)

    paint?.style = Paint.Style.FILL_AND_STROKE
    paint?.color = mTagBackgroundColor
}

代码注释中,我已经解释得很详细了。注意绘制文字的时候要做到居中显示。

最后张贴完整代码。

class Tag(context: Context) : View(context) {
    val TAG : String = "TAG"
    var text : String = ""
    private var mPath: Path? = null
    private var leftRect: RectF? = null
    private var rightRect: RectF? = null

    var mTextpaint : TextPaint? = null
    var mStrokeColor: Int = 0
    var mTextColor: Int = 0
    var paint: Paint? = null
    var defaultRadiu: Int = 12
    var mDefaultTextSize: Int = 48
    var mTextSize: Int = mDefaultTextSize
    var radiu: Int = 0
    var mTagBackgroundColor: Int = 0
    var  mStrokeWidth: Float = 1.0f
    var  delta: Float = 1.0f
    private var  cx: Int = 0
    private var  cy: Int = 0
    private var  mTopBottomPadding: Int = 0
    private var  mLeftRightPadding: Int = 0

    init {
        leftRect = RectF()
        rightRect = RectF()
    }

    constructor(context: Context, attrs: AttributeSet):this(context) {
        val ta : TypedArray = context.obtainStyledAttributes(attrs,R.styleable.Tag)
        text = ta.getString(R.styleable.Tag_android_text)

        mTextSize = ta.getDimensionPixelSize(R.styleable.Tag_android_textSize,
                mDefaultTextSize)
        mStrokeColor = ta.getColor(R.styleable.Tag_stroke_color, Color.RED)
        mTextColor = ta.getColor(R.styleable.Tag_title_color, Color.WHITE)

        mTopBottomPadding = ta.getDimensionPixelOffset(R.styleable.Tag_tag_padding_top_bottom, 10)
        mLeftRightPadding = ta.getDimensionPixelOffset(R.styleable.Tag_tag_padding_left_right, 10)
        mTagBackgroundColor = ta.getColor(R.styleable.Tag_tag_background, Color.WHITE)

        ta.recycle()

        initDatas()
    }

    private fun initDatas() {
        paint = Paint()
        paint?.isAntiAlias = true
        paint?.color = mTagBackgroundColor
        paint?.style = Paint.Style.FILL_AND_STROKE

        mTextpaint = TextPaint()
        mTextpaint?.color = Color.BLACK
        mTextpaint?.textAlign = Paint.Align.CENTER
        mTextpaint?.textSize = 48.0f

        radiu = 24
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val widthSize = View.MeasureSpec.getSize(widthMeasureSpec)
        val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)

        val heightSize = View.MeasureSpec.getSize(heightMeasureSpec)
        val heightMode = View.MeasureSpec.getMode(heightMeasureSpec)
        //用于保存最终尺寸
        var resultW = widthSize
        var resultH = heightSize

        // contentW contentH 用于确定中间矩形的尺寸
        var contentW = 0
        var contentH = 0

        val textWidth : Int

        if (widthMode == View.MeasureSpec.AT_MOST) {
            textWidth = mTextpaint?.measureText(text)!!.toInt()
            contentW += textWidth + mLeftRightPadding * 2 + radiu * 2

            resultW = if (contentW < widthSize) contentW else widthSize
        }

        if (heightMode == View.MeasureSpec.AT_MOST) {
            contentH += mTopBottomPadding * 2 + mTextSize
            resultH = if (contentH < heightSize) contentH else heightSize
        }

        // 修整圆形的半径
        radiu = resultH / 2
        setMeasuredDimension(resultW, resultH)
        Log.d(TAG," w:$resultW,h:$resultH lrpadding:$mLeftRightPadding")
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        if (mPath == null) {
            mPath = Path()
        }

        mPath?.reset()

        var leftStart = paddingLeft + mStrokeWidth
        var topStart  = paddingTop + mStrokeWidth
        var rightEnd = width - paddingRight - mStrokeWidth
        var bottomEnd = height - paddingBottom - mStrokeWidth

        leftRect?.set(leftStart.toFloat(), topStart.toFloat(), (leftStart + radiu * 2).toFloat(), bottomEnd.toFloat())
        rightRect?.set((rightEnd - radiu * 2).toFloat(), topStart.toFloat(), rightEnd.toFloat(), bottomEnd.toFloat())
        //path 起始位置
        mPath?.moveTo((leftStart+radiu).toFloat(),bottomEnd.toFloat())
        // 左边半圆
        mPath?.arcTo(leftRect,
                90.0f, 180f)
        //连接到右边半圆
        mPath?.lineTo((rightEnd - radiu * 2).toFloat(), topStart.toFloat())
        // 右边半圆
        mPath?.arcTo(rightRect,
                270.0f, 180f)
        // path 闭合
        mPath?.close()

        var textDescent = mTextpaint?.fontMetrics?.descent
        val textAscent : Float? = mTextpaint?.fontMetrics?.ascent
        delta = Math.abs(textAscent!!) - textDescent!!

        cx = width / 2
        cy = height / 2

    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        drawPath(canvas!!)

       canvas?.drawText(text, cx.toFloat(), (cy + delta / 2),mTextpaint)

    }

    private fun drawPath(canvas: Canvas) {

        // 以填充的方向将图形填充为指定的背景色
        canvas.drawPath(mPath, paint)

        paint?.style = Paint.Style.STROKE
        paint?.color = mStrokeColor
        canvas.drawPath(mPath, paint)

        paint?.style = Paint.Style.FILL_AND_STROKE
        paint?.color = mTagBackgroundColor
    }
}

最后的测试

现在我们就可以用编码好的 Tag 代替之前的 TextView 来进行测试,把它们放进 TagView 中

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:tag="http://schemas.android.com/apk/res-auto"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context="com.frank.kotlindemo.MainActivity">
    <com.frank.kotlindemo.TagView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">
    <com.frank.kotlindemo.Tag
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tag:tag_padding_left_right="20dp"
        tag:tag_padding_top_bottom="4dp"
        tag:tag_background="#616161"
        android:textSize="24sp"
        android:text="Android"/>
    <com.frank.kotlindemo.Tag
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tag:tag_padding_left_right="20dp"
        tag:tag_padding_top_bottom="4dp"
        android:textSize="24sp"
        android:text="IOS"/>
    <com.frank.kotlindemo.Tag
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tag:tag_padding_left_right="20dp"
        tag:tag_padding_top_bottom="4dp"
        android:textSize="24sp"
        android:text="Python"/>
    <com.frank.kotlindemo.Tag
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tag:tag_padding_left_right="20dp"
        tag:tag_padding_top_bottom="4dp"
        android:textSize="24sp"
        android:text="Html"/>
    <com.frank.kotlindemo.Tag
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tag:tag_padding_left_right="20dp"
        tag:tag_padding_top_bottom="4dp"
        android:textSize="24sp"
        android:text="Node.js"/>
    </com.frank.kotlindemo.TagView>
</LinearLayout>

效果如下:

Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件

OK! 结束。

总结

  1. kotlin 其实对于 Android 开发而言只是换汤不换药,只要知道之前 Java 编码流程,用 kotlin 也自然不在话下。
  2. kotlin 确实比 Java 要优雅一点,如果熟练后相比 Java 同样的功能可以少写很多代码。
  3. kotlin 现在还挑战不了 Java,大家感兴趣可以学学,如果最后用 kotlin 是大势所趋的话大家再来运用在工程中也不迟。
  4. 再重申一遍,我对 Java 有感情,我还需要对它深入研究。
上一篇:CentOS7 service/chkconfig replace commands


下一篇:Sersync实时同步