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

本文介绍了如何使用Kotlin创建一个自定义的ViewGroup——TagView,实现流式标签布局。文章详细讲解了自定义View的测量、布局和绘制过程,包括MeasureSpec的处理,子View的布局策略以及高度计算。此外,文章还讨论了如何处理子View的可见性和高度问题,以及如何优化TagView。最后,通过测试代码展示了TagView的实际效果。
摘要由CSDN通过智能技术生成

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

上周 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 之流式标签控件

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

至于为什么叫做流式标签呢?我想可能因为是在 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。

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

这里写图片描述

在上面的线框图中,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 +
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

frank909

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值