项目初衷
自定义 view 呢我是打算写几个练手的,想了想,第一个自定义 view 的练手还是实现一个可换行的简单 textview 为好
刚接触自定义 view 的同学一定会头疼于 view 的测量和绘制,绘制是个复杂的事,但是测量才是初学者们首先要玩顺溜的
我们来再来看看经典的自定义 view 测量写法:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 获取宽的测量模式
int wSpecMode = MeasureSpec.getMode(widthMeasureSpec);
// 获取符控件提供的 view 宽的最大值
int wSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int hSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int hSpecSize = MeasureSpec.getSize(heightMeasureSpec);
if (wSpecMode == MeasureSpec.AT_MOST && hSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(300, 300);
} else if (wSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(300, hSpecSize);
} else if (hSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(wSpecSize, 300);
}
}
自定义view 测量的难点就是如何处理 warpConten 的情况,在面临 warpConten 时我们如果不做处理那么 view 的宽高就是 matchParent 的,但是不是所有情况我们都能这么粗暴的解决的,有时我们自定义的 view 必要要能处理 warpConten
何为处理 warpConten ,就是在 warpConten 时我们根据需要绘制的内容,计算所需的宽高大小,然后返回通知该自定义view
所以我在这里准备了一个 自定义的 textview 给大家找找感觉,另外也是熟练下 canvas 绘制文字,我认为这是练习自定义view,提高熟练度最好的开始了
我的目标是让萌新们跟着我一起快快乐乐,简简单单的熟悉自定义 view,希望大家多点赞,点个喜欢,关注,github 给个 start 啥的,多谢大家啦,么么哒 ~~
项目地址:BW_Libs
CustomeTextView 思路
这里我们不需要做的很复杂和 textview 一样,我们的目标是实现一个能处理 warpConten ,能实现文字换行,正确显示文字的 view 即可
首先说明一下,不同的字符占用的宽度是不同的,a < A < 我,所以中英文混合的字符串,每行能够显示的文字数量是不同的,大家可以用 mPaint.measureText() 方法自己去试试
在 view 中我们如何做到文字的自动换行,这点其实不复杂,我们根据 view 的最大宽度,计算出 view 每一行能容纳的字符数,然后依次绘制出每一行的文字即可。以前我把这块想的可复杂了,以为有黑科技在里面,但是试过之后才知道,不难嘛 ~ 想的太复杂可不是好事啊
那么我们正式开始讲解思路啦:
1. 处理 warpContent
面临 warpConten ,我们需要根据设置的文字,算出所需要的宽高。这里我们要借助 mPaint.measureText(text) 这个 API
宽好算,我们可以拿到 view 所能获取到的最大宽度 maxWdith,然后用 mPaint.measureText 计算出传入文字的宽度 textWidth
- textWidth < maxWdith
说明文字不足一行,我们以文字所需的宽度 textWidth 为 view 的宽度即可 - textWidth = maxWdith
说明文字正好一行,view 的最大宽度 maxWdith 就是 view 所需的宽度 - textWidth > maxWdith
说明文字一行显示不下,有多行,这时view 所需的宽度就是 view 的最大宽度 maxWdith 了
/**
* 计算 view 所需宽度,view 的宽是 warpContent 时需要处理
*/
fun calculateWidth(width: Int): Int {
val measureWidth = mPaint.measureText(mText)
return if (measureWidth >= width) width else measuredWidth
}
高度其实也好算,我们只要知道了文字绘制的行数 * 每行文字的高度,就是 view 所需的高度了
/**
* 计算 view 总共的高度,view 的高是 warpContent 时需要处理
*/
fun calculateHeight(width: Int): Int {
val measureWidth = mPaint.measureText(mText).toInt()
if (measureWidth <= width) {
return mLineHeight.toInt()
}
return (mlinesNumber * mLineHeight).toInt()
}
整个 view 的测量方法如下:
/**
* 计算 view 大小
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
var widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
var heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 当 view 的宽高是 warpContent 时,根据文字计算 view 所需大小
if (widthMode == MeasureSpec.AT_MOST) {
widthSize = calculateWidth(widthSize)
}
if (heightMode == MeasureSpec.AT_MOST) {
heightSize = calculateHeight(widthSize)
}
// 计算文字分成几行
calculateLines(widthSize)
// 设置 view 的大小
setMeasuredDimension(widthSize, heightSize)
}
2. 绘制文字
这里的难点是我们把文字分割成一行一行的,上面我们知道中英文字符占用的宽度是不一样的,view 每一样能显示的字符数是不固定的,这里我们需要动态计算出每一行能显示的文字数,然后根据这个字符数截取字符串,最后再一行行的去绘制
计算每一行最大字符数:
/**
* 根据传入的文字,获取一行最多能显示的字符数
*/
fun getSigleLineTextNumber(text: String, width: Int, centerTextNum: Int): Int {
// 判断是不是最后一行,最后一行返回字符串长度
if (text.length <= centerTextNum || mPaint.measureText(text) < width) {
return text.length
}
var index = centerTextNum
while (true) {
// 从每行文字的中间数开始,一个字符的一个字符的增加文字测量数,一直到超过或等于指定宽度时,就是 view 每行能显示文字的字数
val measureWidth = mPaint.measureText(text.substring(0, index) + 0.5f).toInt()
if (measureWidth > width) {
return index - 1
break
}
if (measureWidth == width) {
return index
break
}
index++
}
}
分割字符串成一行一行:
/**
* 分割文字成一行一行的
* 为了减少计算量,我们算下每行文字数量的平均数,从这个平均数开始比对
*/
fun splitText() {
var centerTextNum = mText.length / mlinesNumber
var text: String = mText
while (true) {
// 先获取每行文字的数量
val sigleLineTextNumber = getSigleLineTextNumber(text, width, centerTextNum)
// 然后根据这个数量裁剪文字,把这行文字取出来,
val lineText = text.substring(0, sigleLineTextNumber)
// 把取出的每行文字存入集合
textList.add(lineText)
// 然后把取出的这行文字从源文字中删除,以便接下来的计算
text = text.substring(sigleLineTextNumber, text.length)
if (text.isEmpty()) break
}
}
恩,这里大家看注释就行,分割字符串的思路可能绕一点,但是没啥问题,这里我的代码没有经过修饰整理,看着不是非常好,大家谅解
所有代码如下:
<?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="com.bloodcrown.bw.customeview.CustomeTextviewActivity">
<com.bloodcrown.bw.customeview.CustomeTextView
android:layout_width="800px"
android:layout_height="wrap_content"
android:text="A1111111111111111B2222222222222222C333333333333333D444444444444444E55555555555555555F66666666666666"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</android.support.constraint.ConstraintLayout>
<declare-styleable name="CustomeTextView">
<attr name="android:textSize" tools:ignore="ResourceName"></attr>
<attr name="android:text" tools:ignore="ResourceName"></attr>
</declare-styleable>
class CustomeTextView : View {
var mPaint = TextPaint()
var mText = ""
// 行高
var mLineHeight: Float = 0f
// 文字拆分行数
var mlinesNumber: Int = 0
// 存储每行文字集合
var textList = arrayListOf<String>()
@JvmOverloads
constructor(context: Context, attributeSet: AttributeSet? = null, defAttrStyle: Int = 0)
: super(context, attributeSet, defAttrStyle) {
// 初始化画笔
initPaint()
// 初始化各种自定义参数
initAttrs(context, attributeSet, defAttrStyle)
// 计算行高
mLineHeight = calculateLineHeight()
}
/**
* 初始化画笔
*/
fun initPaint() {
mPaint.color = Color.BLACK
mPaint.strokeWidth = 1f
mPaint.style = Paint.Style.FILL
mPaint.isAntiAlias = true
}
/**
* 初始化各种自定义参数
*/
private fun initAttrs(context: Context, attributeSet: AttributeSet?, defAttrStyle: Int) {
val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.CustomeTextView)
(0..typedArray.indexCount)
.asSequence()
.map { typedArray.getIndex(it) }
.forEach {
when (it) {
// 获取文字内容
R.styleable.CustomeTextView_android_text -> {
mText = typedArray.getString(R.styleable.CustomeTextView_android_text)
}
// 获取文字大小
R.styleable.CustomeTextView_android_textSize -> {
var textSize = typedArray.getDimensionPixelSize(R.styleable.CustomeTextView_android_textSize, 0).toFloat()
mPaint.textSize = textSize
}
}
}
typedArray.recycle()
}
/**
* 计算 view 大小
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
var widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
var heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 当 view 的宽高是 warpContent 时,根据文字计算 view 所需大小
if (widthMode == MeasureSpec.AT_MOST) {
widthSize = calculateWidth(widthSize)
}
if (heightMode == MeasureSpec.AT_MOST) {
heightSize = calculateHeight(widthSize)
}
// 计算文字分成几行
calculateLines(widthSize)
// 设置 view 的大小
setMeasuredDimension(widthSize, heightSize)
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// 把传入的文字根据 view 的宽度,分割成一行一行的,便于绘制,我们一次只能绘制一行,多行文字就是一行行绘制出来的
splitText()
// 第一行文字的 baseline 的起始坐标
var startX = 0f
var startY = 0f - mPaint.fontMetrics.ascent
// 遍历储存每行文字的集合,绘制每一行文字
for ((index, text) in textList.withIndex()) {
canvas?.drawText(text, startX, startY + index * mLineHeight, mPaint)
}
}
/**
* 计算行高
*/
fun calculateLineHeight(): Float {
return -mPaint.fontMetrics.ascent + mPaint.fontMetrics.bottom
}
/**
* 计算文字会分割成几行绘制,由余数的话行数 +1
*/
fun calculateLines(width: Int) {
val measureWidth = mPaint.measureText(mText).toInt()
mlinesNumber = if (measureWidth % width != 0) measureWidth / width + 1 else measureWidth / width
}
/**
* 计算 view 所需宽度,view 的宽是 warpContent 时需要处理
*/
fun calculateWidth(width: Int): Int {
val measureWidth = mPaint.measureText(mText)
return if (measureWidth >= width) width else measuredWidth
}
/**
* 计算 view 总共的高度,view 的高是 warpContent 时需要处理
*/
fun calculateHeight(width: Int): Int {
val measureWidth = mPaint.measureText(mText).toInt()
if (measureWidth <= width) {
return mLineHeight.toInt()
}
return (mlinesNumber * mLineHeight).toInt()
}
/**
* 分割文字成一行一行的
*/
fun splitText() {
var centerTextNum = mText.length / mlinesNumber
var text: String = mText
while (true) {
// 先获取每行文字的数量
val sigleLineTextNumber = getSigleLineTextNumber(text, width, centerTextNum)
// 然后根据这个数量裁剪文字,把这行文字取出来,
val lineText = text.substring(0, sigleLineTextNumber)
// 把取出的每行文字存入集合
textList.add(lineText)
// 然后把取出的这行文字从源文字中删除,以便接下来的计算
text = text.substring(sigleLineTextNumber, text.length)
if (text.isEmpty()) break
}
}
/**
* 根据传入的文字,获取一行最多能显示的字符数
*/
fun getSigleLineTextNumber(text: String, width: Int, centerTextNum: Int): Int {
// 判断是不是最后一行,最后一行返回字符串长度
if (text.length <= centerTextNum || mPaint.measureText(text) < width) {
return text.length
}
var index = centerTextNum
while (true) {
// 从每行文字的中间数开始,一个字符的一个字符的增加文字测量数,一直到超过或等于指定宽度时,就是 view 每行能显示文字的字数
val measureWidth = mPaint.measureText(text.substring(0, index) + 0.5f).toInt()
if (measureWidth > width) {
return index - 1
break
}
if (measureWidth == width) {
return index
break
}
index++
}
}
}
最后
可能大家都不会看到这里,因为谁会在一大段代码后面接着写文字呢
这里我们使用 StaticLayout 来绘制多行文字的话会方面很多啊,不用我们自己去算有多少行,不用我们自己去截取每一行的文字再去绘制了,StaticLayout 都帮我们做了,并且通过 StaticLayout 我们可以获取文字实际会占用的宽高是多少
// 可以获取文字在指定宽度限制下所需空间
staticLayout.width
staticLayout.height
预知 StaticLayout 的详细请看:自定义 view - 绘制文字
好了,这次真的没了 ~