用Kotlin撸一个自定义字母索引控件,性能优化
之前App使用Kotlin重构之后,最大的感触就是kotlin简洁的语法以及扩展函数等特性极大的提升了我们编写代码的速度。
如果说Java是K、T开头的普通火车的话,那Kotlin就是D、G开头的动车高铁了!
嗯,相信我,去用一用吧,绝对很爽。
好了,开始正文。
今天我们来用kotlin写一个自定义view,一个很常用的字母索引控件。
话不多说,先上图
我们在联系人之类的页面中经常会见到这中控件,网上也有很多轮子,有的是在View中创建的很多TextView实现,有的是用ListView实现等等,各有千秋。个人愚见觉得没必要弄这么麻烦,我们直接用画笔画就完事儿了,性能上也会好一些。
需求分析
- 首先我们要有自定义属性,可以在布局中设置字体颜色,字体大小等功能
- 绘制的时候要处理一下padding,虽然正常来说一般不会用到padding
- 在我们手指按下以及滑动的时候,要把当前字母传出去,让调用者知道当前手指是在哪个字母上
- 尽可能的优化性能
实现
需求大概分析清楚后,我们来一步一步实现
首先,肯定要先创建一个类继承自View,这里我的类名就叫做LetterIndexView好了
如下代码,我们先把三个构造方法给重写一下
class LetterIndexView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : View(context, attrs, defStyleAttr) {
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context) : this(context, null)
}
自定义属性和初始化
好了,创建好类之后我们需要声明自定义view属性,目前想到的自定义属性就两个,一个是字体颜色,一个是字体大小。当然,如果你有其他需求的话,可以自己新增。
如下,
看名字就很清楚了
letterViewTextSize 表示字体大小
letterViewTextColor 表示字体颜色
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LetterIndexView">
<attr name="letterViewTextSize" format="dimension"></attr>
<attr name="letterViewTextColor" format="color"></attr>
</declare-styleable>
</resources>
好了,自定属性也完成了
下面就是自定义view的常规套路了
测量,绘制,处理触摸
由于继承的是View,所以也不需要处理onLayout。
首先,我们来处理自定义属性以及做一些初始化操作,在init代码块中处理即可
可以看到,我们直接通过'A'..'Z'
即可声明一个从A-Z的区间,如果用java写的话,那可就没这么简单了
private var textColor: Int = Color.BLACK
private var textSize: Float = sp2px(14f)
private var letters: CharRange
private var letterHeight: Int = 0
init {
var typeArr = context.obtainStyledAttributes(attrs, R.styleable.LetterIndexView)
/*获取自定义属性*/
textColor = typeArr.getColor(R.styleable.LetterIndexView_letterViewTextColor, textColor)
textSize = typeArr.getDimension(R.styleable.LetterIndexView_letterViewTextSize, textSize)
typeArr.recycle()
/*初始化画笔*/
textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
textPaint.color = textColor
textPaint.textSize = textSize
/*字母索引区间*/
letters = 'A'..'Z'
}
处理测量
然后,我们来处理测量,注意测量的时候要对padding进行处理
因为后期要处理触摸反馈,我们必须知道当前手指所处于哪个字符上,所以这里要计算一下字符的高度,后面绘制以及计算位置的时候会用到
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
/*测量宽高度*/
var width = MeasureSpec.getSize(widthMeasureSpec) + paddingRight + paddingLeft
var height = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
/*算出每个字母的占用的高度*/
letterHeight = (h - paddingBottom - paddingTop) / letters.count()
}
处理绘制
接着,开始画文字
画文字的时候要注意每个字符本身的宽度时不一样的,比如A
肯定要比 I
的宽度大,所以我们要保证绘制的文字处于view的正中间,同时,在计算文字基线时也要注意高度,注释已经很清楚了。
override fun onDraw(canvas: Canvas?) {
for ((index, value) in letters.withIndex()) {
/*当前的字符*/
var currentLetter = value.toString()
/*测量当前字符的宽度*/
var letterW = textPaint.measureText(currentLetter)
/*算出起点坐标 保证字符画在水平正中间*/
var x = width / 2 - letterW / 2
/*算出基线*/
var fm = textPaint.fontMetricsInt
var dy = (fm.bottom - fm.top) / 2 - fm.bottom
/*注意这里的基线高度是基于之前所有letter的高度加上paddingtop的值再加上其本身的基线位置*/
var baseLine = letterHeight * index + letterHeight / 2 + dy + paddingTop
/*画文字*/
canvas!!.drawText(currentLetter, x, baseLine.toFloat(), textPaint)
}
}
做完这些,实际上我们已经画出我们需要的控件了,我们先来使用一下看看。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.yzq.widget.LetterIndexView
android:layout_width="30dp"
android:layout_height="match_parent"
android:layout_marginBottom="20dp"
android:layout_marginTop="20dp"
android:padding="10dp"
app:letterViewTextColor="@color/colorPrimaryDark"
app:letterViewTextSize="18sp"
android:background="@color/colorAccent"
app:layout_constraintRight_toRightOf="parent" />
</android.support.constraint.ConstraintLayout>
运行效果图如下
嗯 ,可以看到基本符合我们的预期,就是有点丑,还是改的正常点吧
<com.yzq.widget.LetterIndexView
android:layout_width="30dp"
android:layout_height="match_parent"
android:layout_marginBottom="10dp"
android:layout_marginTop="10dp"
app:layout_constraintRight_toRightOf="parent"
app:letterViewTextColor="@color/colorPrimary"
app:letterViewTextSize="16sp" />
嗯,这样一来好多了。
触摸反馈
好了,该画的我们画好之后,下面我们还要处理一下触摸反馈。
当我们手指按下和移动的时候,我们要将当前手指所在的字母返回给调用者。
首先我们需要一个接口,用来将触摸的字符传给调用者
/*监听器*/
interface onTouchLetterListener {
fun showLetter(letter: String)
fun hideLetter()
}
fun setOnTouchLetterListener(listener: onTouchLetterListener) {
this.listener = listener
}
然后处理一下触摸事件,注释写的也很清楚了,这里就不多解释了。需要注意的是最后return的时候要返回true,否则,表示事件没有被消费掉,导致无法再触发onTouchEvent事件,也就没办法处理MOVE和UP事件了
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> {
/*按下和移动事件*/
/*当前手指所处的y的值除以字符高度即可算出字符的下标*/
var eventIndex: Int = (event.y / letterHeight).toInt()
/*由于可能设置了margin或者padding 所以要对值进行校验 否则可能出现下标越界异常*/
if (eventIndex >= 0 && eventIndex < letters.count()) {
var eventLetter = letters.elementAt(eventIndex)
/*将当前字符通过接口传出去*/
if (listener != null) {
listener!!.showLetter(eventLetter.toString())
}
}
}
MotionEvent.ACTION_UP -> {
/*手指抬起时可以隐藏*/
if (listener != null) {
listener!!.hideLetter()
}
}
}
return true
}
好了,至此,我们的触摸也处理完了,下面我们来用一下。
布局中一般有个TextView用于显示触摸的字符
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/letterTv"
android:layout_width="60dp"
android:layout_height="60dp"
android:background="#6889ff"
android:gravity="center"
android:text="A"
android:visibility="gone"
android:textColor="#FFFFFF"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.yzq.widget.LetterIndexView
android:id="@+id/letterView"
android:layout_width="30dp"
android:layout_height="match_parent"
android:layout_marginBottom="10dp"
android:layout_marginTop="10dp"
app:layout_constraintRight_toRightOf="parent"
app:letterViewTextColor="@color/colorPrimary"
app:letterViewTextSize="16sp" />
</android.support.constraint.ConstraintLayout>
Activity中使用
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
letterView.setOnTouchLetterListener(object : LetterIndexView.onTouchLetterListener {
override fun showLetter(letter: String) {
letterTv.visibility = View.VISIBLE
letterTv.setText(letter)
}
override fun hideLetter() {
letterTv.visibility = View.GONE
}
})
}
}
下面我们来运行看一下
可以看到,已经达到了我们的需求。使用上已经没有什么问题了
但是,这就结束了吗
性能问题
下面我们来看看可能存在的问题,在下onTouchEvent的代码中,如果你加上日志,你就会发现,在手指移动的过程中会触发很多次接口回调,假入你此时有重新绘制的需求,比如你想在触摸的时候把触摸的字符画成红色,你还想给View加个背景色之类的。那么你肯定需要调用 invalidate()
,如果不做处理的话,会造成很多次没必要的绘制,浪费性能。
我们来看看日志,可以看到打印的频率很频繁
优化
在上面我们已经说了为什么会有性能问题,实际上我们不处理也是没什么大影响的,日常使用其实也感觉不出来性能问题,但是,像这种问题积少成多话还是有一定影响的。
再说了,作为一名合格的程序员,我们需要追求更好的代码质量,写出高性能的代码
如何优化呢,其实很简单,我们在手指移动的时候判断一下当前所处的区域是不是跟移动之前所处的是否是一个区域,如果是的话,就不做处理了,不是的话再做处理。这样的话,会大大减少回调和绘制。
我们来实现一下。
我们新增一个oldIndex用于记录上次手指所处的位置,然后将当前eventIndex跟oldIndex作比较即可知道是否是同一个字符
private var oldIndex: Int = -1
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> {
/*按下和移动事件*/
/*当前手指所处的y的值除以字符高度即可算出字符的下标*/
var eventIndex: Int = (event.y / letterHeight).toInt()
/*由于可能设置了margin或者padding 所以要对值进行校验 否则可能出现下标越界异常*/
if (eventIndex >= 0 && eventIndex < letters.count()) {
/*如果当前eventIndex不等于oldIndex 再执行*/
if (eventIndex != oldIndex) {
oldIndex = eventIndex
var eventLetter = letters.elementAt(eventIndex)
/*将当前字符通过接口传出去*/
if (listener != null) {
listener!!.showLetter(eventLetter.toString())
}
}
}
}
MotionEvent.ACTION_UP -> {
/*手指抬起时可以隐藏*/
if (listener != null) {
listener!!.hideLetter()
}
}
}
return true
}
再来看看运行效果和日志,可以看到,大大减少了触发的频率
ok,至此,我们已经完成了一个高性能的自定义字母索引控件啦,下面是Demo