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 之流式标签控件
对于软件开发者而言,流式标签控件想必大家一定见过,如下图:
至于为什么叫做流式标签呢?我想可能因为是在 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 的高度是一致的。
好了,我们整理下思路。
- 测量子 View 的尺寸。
- 根据布局的特点,测量最小的宽高尺寸,并且这个数值不能大于 parent 给出的建议 size。
- 对于宽度而言,由于 TagView 每一行宽度可能不同,所以需要找出最宽的那一行。
- 对于高度而言,TagView 整体高度就是各行之和。
- 当然在 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>
然后,它的最终效果图如下:
自此,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,实际效果怎么样呢?
可以发现其实没有多大影响,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 会发生什么呢?
这个结果肯定就不是我们想要的了。我们希望每个子 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
}
}
如上面代码所示,给每行确定好高度之后,TagView 显示就很完善了。
TagView 的子 View
其实到了这里的时候,这个初级的 TagView 就已经完成了。但是功能还是比较简单。它的子 View 都是 TextView 然后背景在 xml 中用统一的 shape 来代替,所以我们可以实现圆角矩形的式样。如果我们还想更自由一点,那么就需要自定义一个 View 了,那将是另外一话题了。
自定义一个 View,步骤无非也是测量、绘制。因为篇幅过长,接下来的内容我简单带过。
我给自定义的 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>
效果如下:
OK! 结束。
总结
- kotlin 其实对于 Android 开发而言只是换汤不换药,只要知道之前 Java 编码流程,用 kotlin 也自然不在话下。
- kotlin 确实比 Java 要优雅一点,如果熟练后相比 Java 同样的功能可以少写很多代码。
- kotlin 现在还挑战不了 Java,大家感兴趣可以学学,如果最后用 kotlin 是大势所趋的话大家再来运用在工程中也不迟。
- 再重申一遍,我对 Java 有感情,我还需要对它深入研究。
Kotlin 第一弹:自定义 ViewGroup 实现流式标签控件的更多相关文章
- Android流式布局控件
1,自定义flowlayout代码 package com.hyang.administrator.studentproject.widget; import android.content.Cont ...
- Android控件进阶-自定义流式布局和热门标签控件
技术:Android+java 概述 在日常的app使用中,我们会在android 的app中看见 热门标签等自动换行的流式布局,今天,我们就来看看如何 自定义一个类似热门标签那样的流式布局吧,类 ...
- 【Android - 自定义View】之自定义可滚动的流式布局
首先来介绍一下这个自定义View: (1)这个自定义View的名称叫做 FlowLayout ,继承自ViewGroup类: (2)在这个自定义View中,用户可以放入所有继承自View类的视图,这个 ...
- asp.net 弹出式日历控件 选择日期 Calendar控件
原文地址:asp.net 弹出式日历控件 选择日期 Calendar控件 作者:逸苡 html代码: <%@ Page Language="C#" CodeFile=&quo ...
- Android 自定义View 三板斧之一——继承现有控件
通常情况下,Android实现自定义控件无非三种方式. Ⅰ.继承现有控件,对其控件的功能进行拓展. Ⅱ.将现有控件进行组合,实现功能更加强大控件. Ⅲ.重写View实现全新的控件 本文重点讨论继承现有 ...
- WPF自定义行为Behavior,实现双击控件复制文本
WPF引用xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity& ...
- 自定义type为file型input控件+该控件具有本地图片预览功能
最近的一个项目需求是写一个type为filex型的input控件,这个控件: 第一,要自定义样式: 第二,要能直接在本地预览上传的图片: 第三,要能检测图片的尺寸是否符合要求. 故综合网上的资源写了下 ...
- 集成自动化的条形码功能到internet应用程序,网站或自定义Java应用程序的条码控件Java Barcode Package
Java Barcode Package控件是一款条码生成控件,包含所有的JavaBean,Applets,Servlets和类库可以使用于装有Java虚拟机的任何平台,包括Windows®, Lin ...
- Android自定义垂直滚动自动选择日期控件
------------------本博客如未明正声明转载,皆为原创,转载请注明出处!------------------ 项目中需要一个日期选择控件,该日期选择控件是垂直滚动,停止滚动时需要校正日期 ...
随机推荐
- php如何实现定时任务,php定时任务方法,最佳解决方案,php自动任务处理
php如何实现定时任务,php定时任务方法,最佳解决方案,php自动任务处理 Joe PHP 2012-01-18 定时任务对于php来说一直都是很多朋友的一个难题,但却很多地方都遇到了.比如说:游戏 ...
- 参考MongoRepository,为接口生成bean实现注入
首先弄个注解,给代码个入口,这个就是mongo的@EnableMongoRepositories了. @Target(ElementType.TYPE) @Retention(RetentionPol ...
- logback logback.xml 常用配置详解(转)
本文转自:http://my.oschina.net/looly/blog/298675 推荐参考:http://blog.csdn.net/haidage/article/details/67945 ...
- addEventListener和attachEvent介绍, 原生js和jquery的兼容性写法
也许很多同仁一听到事件监听,第一想到的就是原生js的 addEventListener()事件,的确如此,当然如果只是适用于现代浏览器(IE9.10.11 | ff, chorme, safari, ...
- redis安装优化:
1)内存分配控制: vm.overcommit_memoryredis启动时肯呢个会出现这样的日志: :M Apr ::! Background save may fail under low mem ...
- MyBatis无限输出日志
最近在项目中使用mybatis与spring集成,由于项目使用maven分模块打包,经常遇到mybatis mapper少配置子模块或者maven pom中忘记引用子模块导致的mybatis加载不到d ...
- 【bzoj4806~bzoj4808】炮车马后——象棋四连击
bzoj4806——炮 题目传送门:bzoj4806 这种题一看就是dp...我们可以设$ f[i][j][k] $表示处理到第$ i $行,有$ j $列没放炮,$ k $列只放了一个炮.接着分情况 ...
- Nginx Rewrite 规则入门 伪静态规则
文件及目录匹配: -f 和 !-f 用来判断是否存在文件 -d 和 !-d 用来判断是否存在目录 -e 和 !-e 用来判断是否存在文件或目录 -x 和 !-x 用来判断文件是否可执行 正则表达式匹配 ...
- NO.2 You must restart adb and Eclipse多种情形分析与解决方案
一.问题描述: 运行android程序控制台输出 The connection to adb is down, and a severe error has occured. ...
- java实现定时任务的三种方法 - 转载
java实现定时任务的三种方法 /** * 普通thread * 这是最常见的,创建一个thread,然后让它在while循环里一直运行着, * 通过sleep方法来达到定时任务的效果.这样可以快速简 ...