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)
    }
}
相关推荐
编程洪同学2 小时前
Spring Boot 中实现自定义注解记录接口日志功能
android·java·spring boot·后端
氤氲息4 小时前
Android 底部tab,使用recycleview实现
android
Clockwiseee4 小时前
PHP之伪协议
android·开发语言·php
小林爱4 小时前
【Compose multiplatform教程08】【组件】Text组件
android·java·前端·ui·前端框架·kotlin·android studio
小何开发5 小时前
Android Studio 安装教程
android·ide·android studio
开发者阿伟6 小时前
Android Jetpack LiveData源码解析
android·android jetpack
weixin_438150996 小时前
广州大彩串口屏安卓/linux触摸屏四路CVBS输入实现同时显示!
android·单片机
CheungChunChiu7 小时前
Android10 rk3399 以太网接入流程分析
android·framework·以太网·eth·net·netd
木头没有瓜7 小时前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
键盘侠0077 小时前
springboot 上传图片 转存成webp
android·spring boot·okhttp