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 小时前
Android 代码热度统计(概述)
android
xchenhao6 小时前
基于 Flutter 的开源文本 TTS 朗读器(支持 Windows/macOS/Android)
android·windows·flutter·macos·openai·tts·朗读器
coder_pig6 小时前
跟🤡杰哥一起学Flutter (三十五、玩转Flutter滑动机制📱)
android·flutter·harmonyos
消失的旧时光-19437 小时前
OkHttp SSE 完整总结(最终版)
android·okhttp·okhttp sse
ansondroider8 小时前
OpenCV 4.10.0 移植 - Android
android·人工智能·opencv
hsx66611 小时前
Kotlin return@label到底怎么用
android
itgather12 小时前
安卓设备信息查看器 - 源码编译
android
whysqwhw12 小时前
OkHttp之buildSrc模块分析
android
hsx66612 小时前
从源码角度理解Android事件的传递流程
android
刺客xs14 小时前
MYSQL数据库----DCL语句
android·数据库·mysql