Android自定义底部TabGroup

一,背景

今天继续巩固自己写过的那些简单的自定义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)
    }
}
相关推荐
后端码匠3 小时前
MySQL 8.0安装(压缩包方式)
android·mysql·adb
梓仁沐白4 小时前
Android清单文件
android
董可伦7 小时前
Dinky 安装部署并配置提交 Flink Yarn 任务
android·adb·flink
每次的天空7 小时前
Android学习总结之Glide自定义三级缓存(面试篇)
android·学习·glide
恋猫de小郭8 小时前
如何查看项目是否支持最新 Android 16K Page Size 一文汇总
android·开发语言·javascript·kotlin
flying robot9 小时前
小结:Android系统架构
android·系统架构
xiaogai_gai9 小时前
有效的聚水潭数据集成到MySQL案例
android·数据库·mysql
鹅鹅鹅呢10 小时前
mysql 登录报错:ERROR 1045(28000):Access denied for user ‘root‘@‘localhost‘ (using password Yes)
android·数据库·mysql
在人间负债^10 小时前
假装自己是个小白 ---- 重新认识MySQL
android·数据库·mysql
Unity官方开发者社区10 小时前
Android App View——团结引擎车机版实现安卓应用原生嵌入 3D 开发场景
android·3d·团结引擎1.5·团结引擎车机版