一,背景
今天继续巩固自己写过的那些简单的自定义View,本文将介绍如何自定义APP主页底部的Tab,以往写底部Tab总是堆积XML,导致可能在某个Tab加个小红点都改动很大,看起来也非常糟糕
二,效果
三,绘制流程
- 在绘制前,我们得定义tab的数据结构(tab的名称,选中以及未选中的icon...),本文我将简单的定义一个数据结构,各位大佬可自行扩展想象力,让其更炫酷(文字颜色渐变...)
kotlin
data class TabInfoData(
val name: String,// tab名称
val selectedIcon: Int,// 选中状态下的icon
val unselectedIcon: Int,// 未选中状态下的icon
val selectedFontColor: String,// 选中状态下的文字的颜色
val unselectedFontColor: String// 未选中状态下的文字颜色
) : Serializable
- 接下便是自定义View的三部曲(onMeasure,onLayout,onDraw),但是在此之前,我觉得还得有最重要的一步,我们得先初始化一些画笔或者自定义属性,但本文是简单的实现没有去做自定义属性,只有简单的初始化画笔,把画笔设置为抗锯齿(不然的话画出来的东西会没有那么光滑),设置字体加粗,设置字体大小,颜色
ini
/**
* 初始化画笔
*/
private fun initPaint() {
mTextPaint.isAntiAlias = true
mTextPaint.color = mSelectTextColor
mTextPaint.isFakeBoldText = true
mTextPaint.textSize = mTextSize.toFloat()
}
- 接下来我们测量View的宽高,由于我这边项目的要求,我就直接写死高度了,宽度则是填充屏幕宽度
kotlin
/**
* 测量宽高
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = DisplayUtil.getScreenWidth(context)
val height = DisplayUtil.dp2px(context, 50)
setMeasuredDimension(width, height)
}
- 由于是自定义View而并非是自定义ViewGroup,所以就没有onLayout这一步,我们直接一步到位进入onDraw,绘制我们的内容,首先我们通过View的宽度除以tab的个数得到了每个tab的宽度,然后onDraw就两步,第一步绘制tab的名称,第二步绘制tab的icon
kotlin
/**
* 绘制
*/
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
mTabList?.let { tabList ->
val itemWidth = width / tabList.size
tabList.forEachIndexed { index, tabData ->
canvas?.let {
drawText(canvas, tabData, index + 1, itemWidth)
decodeImg(canvas, tabData, index + 1, itemWidth)
}
}
}
}
- onDraw的第一步,绘制tab的标题,在这里相信大家都认为绘制文字so easy,其实我认为绘制文字还是有点知识的,这里需要我们计算文字的基线,至于不知道基线是什么位置的各位可自行百度,因为我讲的肯定没有其他伙伴好,简单来说就是不计算出绘制文字的基线,会导致我们绘制的文字显示不全,绘制文字主要是计算所绘制的文本的x轴(横向坐标,如文字水平居中),y轴(纵向坐标,如在图标底部绘制)
scss
/**
* 绘制文字
*/
private fun drawText(
canvas: Canvas,
tabData: JuLangAuditTabInfoData,
position: Int,
itemWidth: Int
) {
// 设置画笔的颜色
if (position - 1 == mSelectPosition)
mTextPaint.color = Color.parseColor(tabData.selectedFontColor)
else
mTextPaint.color = Color.parseColor(tabData.unselectedFontColor)
// 通过画笔测量所绘制的文本的宽高
val textBounds = Rect()
mTextPaint.getTextBounds(tabData.name, 0, tabData.name.length, textBounds)
// 计算所绘制的文本的x坐标,我这里是水平居中显示,所以计算方式是tab的位置(80) - tab宽度的一半(10) - 文本宽度的一半(3)
val x = itemWidth * position
val dx = (x - (itemWidth / 2)) - (textBounds.width() / 2)
// 这里便是绘制文本的重中之重,不会的直接复制我这条公式进行修改即可,我也说不明白其中的原理
// 我这里是在tab的底部,所以是tab的高度(50)- 文字距离底部的边距(5)- 绘制文本的基线
val fontMetricsInt = mTextPaint.fontMetricsInt
val dy = (fontMetricsInt.bottom - fontMetricsInt.top) / 2 - fontMetricsInt.bottom
val baseLine = height - mBitmapTop - dy.toFloat()
canvas.drawText(tabData.name, dx.toFloat(), baseLine, mTextPaint)
}
- onDraw的第二步,绘制tab的图标,绘制图片我个人感觉是相对简单的,几乎生成一个bitmap对象,再指定一个绘制的坐标即可在指定的位置绘制一张图片,但我们这里不同的是需要给绘制的图片一个大小,不然的话每个tab的图标大小不一致,视觉效果就非常糟糕了 drawBitmap(Bitmap bitmap, Rectsrc, RectF dst, Paint paint);
Rect src: 是对图片进行裁截,若是空null则显示整个图片
RectF dst:是图片在Canvas画布中显示的区域,大于src则把src的裁截区放大,小于src则把src的裁截区缩小
kotlin
/**
* 解析图片,网络图片,通过url获取对应的bitmap
*/
private fun decodeImg(
canvas: Canvas,
tabData: JuLangAuditTabInfoData,
position: Int,
itemWidth: Int
) {
val url =
if (position - 1 == mSelectPosition) tabData.selectedIcon else tabData.unselectedIcon
val bitmap = mBitmapMap[url]
if (bitmap != null) drawBitmap(canvas, bitmap, position, itemWidth)
}
/**
* 绘制图片
*/
private fun drawBitmap(
canvas: Canvas,
bitmap: Bitmap,
position: Int,
itemWidth: Int
) {
val x = itemWidth * position
val y = mBitmapTop
// 文字文字的x坐标,跟上面绘制文字的x坐标计算原理一致,所以计算方式是tab的位置(80) - tab宽度的一半(10) - 文本宽度的一半(9)
val dx = (x - (itemWidth / 2)) - (mBitmapSize / 2)
// 图片裁截区域,这里不需要裁截,所以是0到图片的宽度,0到图片的高度
val src = Rect(0, 0, bitmap.width, bitmap.height)
// 绘制图片区域,指定绘制图片的xy坐标,绘制的大小
val dest = Rect(dx, y, dx + mBitmapSize, y + mBitmapSize)
// 绘制图片
canvas.drawBitmap(bitmap, src, dest, mTextPaint)
}
- 最后一部,实现触摸点击事件,其实很简单,当手指抬起的时候,计算手指触摸的x坐标/tab的宽度就能得到对应的tab的索引,最后请求绘制
kotlin
/**
* 处理触摸事件
*/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
mTabList?.let { tabList ->
event?.let {
if (event.action == MotionEvent.ACTION_UP) {
mSelectPosition = ceil(event.x / (width / tabList.size)).toInt() - 1
mCallback?.onSelect(mSelectPosition)
invalidate()
}
}
}
return true
}
源码(show you the code)
kotlin
/**
* 底部tab
*/
class JuLangAuditTabView : View {
private var mTabList: List<TabInfoData>? = null
private var mSelectPosition = 0
private var mTextPaint: Paint = Paint()
private var mSelectTextColor = Color.BLUE
private var mTextSize = DisplayUtil.dp2px(context, 12)
private var mBitmapSize = DisplayUtil.dp2px(context, 18)
private var mBitmapTop = DisplayUtil.dp2px(context, 5)
private val mBitmapMap = HashMap<String, Bitmap>()
private var mCallback: SelectCallback? = null
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
initPaint()
}
/**
* 初始化画笔
*/
private fun initPaint() {
mTextPaint.isAntiAlias = true
mTextPaint.color = mSelectTextColor
mTextPaint.isFakeBoldText = true
mTextPaint.textSize = mTextSize.toFloat()
}
/**
* 测量宽高
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = DisplayUtil.getScreenWidth(context)
val height = DisplayUtil.dp2px(context, 50)
setMeasuredDimension(width, height)
}
/**
* 绘制
*/
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
mTabList?.let { tabList ->
val itemWidth = width / tabList.size
tabList.forEachIndexed { index, tabData ->
canvas?.let {
drawText(canvas, tabData, index + 1, itemWidth)
decodeImg(canvas, tabData, index + 1, itemWidth)
}
}
}
}
/**
* 绘制文字
*/
private fun drawText(
canvas: Canvas,
tabData: TabInfoData,
position: Int,
itemWidth: Int
) {
// 设置画笔的颜色
if (position - 1 == mSelectPosition)
mTextPaint.color = Color.parseColor(tabData.selectedFontColor)
else
mTextPaint.color = Color.parseColor(tabData.unselectedFontColor)
// 通过画笔测量所绘制的文本的宽高
val textBounds = Rect()
mTextPaint.getTextBounds(tabData.name, 0, tabData.name.length, textBounds)
// 计算所绘制的文本的x坐标,我这里是水平居中显示,所以计算方式是tab的位置(80) - tab宽度的一半(10) - 文本宽度的一半(6)
val x = itemWidth * position
val dx = (x - (itemWidth / 2)) - (textBounds.width() / 2)
// 这里便是绘制文本的重中之重,不会的直接复制我这条公式进行修改即可,我也说不明白其中的原理
// 我这里是在tab的底部,所以是tab的高度(50)- 文字距离底部的边距(5)- 绘制文本的基线
val fontMetricsInt = mTextPaint.fontMetricsInt
val dy = (fontMetricsInt.bottom - fontMetricsInt.top) / 2 - fontMetricsInt.bottom
val baseLine = height - mBitmapTop - dy.toFloat()
canvas.drawText(tabData.name, dx.toFloat(), baseLine, mTextPaint)
}
/**
* 解析图片,网络图片,通过url获取对应的bitmap
*/
private fun decodeImg(
canvas: Canvas,
tabData: TabInfoData,
position: Int,
itemWidth: Int
) {
val url =
if (position - 1 == mSelectPosition) tabData.selectedIcon else tabData.unselectedIcon
val bitmap = mBitmapMap[url]
if (bitmap != null) drawBitmap(canvas, bitmap, position, itemWidth)
}
/**
* 绘制图片
*/
private fun drawBitmap(
canvas: Canvas,
bitmap: Bitmap,
position: Int,
itemWidth: Int
) {
val x = itemWidth * position
val y = mBitmapTop
// 文字文字的x坐标,跟上面绘制文字的x坐标计算原理一致,所以计算方式是tab的位置(80) - tab宽度的一半(10) - 文本宽度的一半(9)
val dx = (x - (itemWidth / 2)) - (mBitmapSize / 2)
// 图片裁截区域,这里不需要裁截,所以是0到图片的宽度,0到图片的高度
val src = Rect(0, 0, bitmap.width, bitmap.height)
// 绘制图片区域,指定绘制图片的xy坐标,绘制的大小
val dest = Rect(dx, y, dx + mBitmapSize, y + mBitmapSize)
// 绘制图片
canvas.drawBitmap(bitmap, src, dest, mTextPaint)
}
/**
* 获取图片
*/
private suspend fun getBitmap(url: String) =
suspendCoroutine<BitmapInfo?> { job ->
Glide.with(context).asBitmap().load(url).into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
job.resume(BitmapInfo(url, resource))
}
override fun onLoadCleared(placeholder: Drawable?) {
job.resume(null)
}
})
}
/**
* 处理触摸事件
*/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
mTabList?.let { tabList ->
event?.let {
if (event.action == MotionEvent.ACTION_UP) {
mSelectPosition = ceil(event.x / (width / tabList.size)).toInt() - 1
mCallback?.onSelect(mSelectPosition)
invalidate()
}
}
}
return true
}
/**
* 设置数据
*/
fun setData(tabList: List<TabInfoData>) {
mTabList = tabList
GlobalScope.launch(Dispatchers.IO) {
val asyncArr = ArrayList<Deferred<BitmapInfo?>>()
mTabList?.forEach {
asyncArr.add(async { getBitmap(it.selectedIcon) })
asyncArr.add(async { getBitmap(it.unselectedIcon) })
}
val awaitAll = awaitAll(* asyncArr.toTypedArray())
awaitAll.forEach {
if (it != null) mBitmapMap[it.url] = it.bitmap
}
post {
invalidate()
}
}
}
/**
* 设置选中回调
*/
fun setSelectCallback(callback: SelectCallback) {
mCallback = callback
}
/**
* 图片信息
*/
private data class BitmapInfo(val url: String, val bitmap: Bitmap) : Serializable
/**
* tab选中回调
*/
interface SelectCallback {
/**
* tab被选中
* @param position 下标位置
*/
fun onSelect(position: Int)
}
}