查看原文
其他

自定义可点击、可滑动的通用RatingBar

技术最TOP 2022-08-26

作者:Loren, 链接:https://juejin.cn/post/7007322024062222343

介绍

一个可以设置间距,设置选中未选中图标及数量,选中图标的类型(整,半,任意),可点击,可滑动选择的类似原生RatingBar的自定义View

效果图预览


实现

自定义属性

<declare-styleable name="CommonRatingBar">
    <attr name="starCount" format="integer" />
    <attr name="starPadding" format="dimension" />
    <!-- 默认选中时的图标,可不设置,使用纯色starColor -->
    <attr name="starDrawable" format="reference" />
    <!-- 默认未选中时的图标 -->
    <attr name="starBgDrawable" format="reference" />
    <!-- 纯色样式 -->
    <attr name="starColor" format="color" />
    <attr name="starClickable" format="boolean" />
    <attr name="starScrollable" format="boolean" />
    <attr name="starType" format="enum">
        <enum name="normal" value="0" />
        <enum name="half" value="1" />
        <enum name="whole" value="2" />
    </attr>
</declare-styleable>
测量View

将控件的高度设置为测量高度,测量宽度为星星的数量+每个星星之间的padding

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    starSize = measuredHeight
    setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
}
绘制ratingbar

绘制未选中的背景

/**
 * 未选中Bitmap
 */

private val starBgBitmap: Bitmap by lazy {
    val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
    starDrawable?.setBounds(00, starSize, starSize)
    starDrawable?.draw(canvas)
    bitmap
}

/**
 * 绘制星星默认未选中背景
 */

private fun drawStar(canvas: Canvas) {
    for (i in 0 until starCount) {
        val starLeft = i * (starSize + starPadding)
        canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
    }
}
绘制选中图标

这里bitmap宽度使用starSize + starPadding,配合BitmapShaderrepeat模式,可以方便绘制出高亮的图标

/**
 * 选中icon的Bitmap
 */

private val starBitmap: Bitmap by lazy {
    val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    val starDrawable = ContextCompat.getDrawable(context, starDrawable)
    starDrawable?.setBounds(00, starSize, starSize)
    starDrawable?.draw(canvas)
    bitmap
}

/**
 * 绘制高亮图标
 */

private fun drawStarDrawable(canvas: Canvas) {
    starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
    canvas.drawRect(0f0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
}
绘制纯色的选中效果

使用离屏缓冲,纯色矩形与未选中背景相交的地方进行显示。具体使用可以参考扔物线大佬的文章

/**
 * 星星纯色画笔
 */

private val starPaint = Paint().apply {
    isAntiAlias = true
    xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

canvas?.let {
    // xfermode需要使用离屏缓存
    val saved = it.saveLayer(nullnull)
    drawStar(it)
    if (starDrawable == -1) {
        drawStarBgColor(it)
    } else {
        drawStarDrawable(it)
    }
    it.restoreToCount(saved)
}

/**
 * 绘制高亮纯颜色
 */

private fun drawStarBgColor(canvas: Canvas) {
    canvas.drawRect(0f0f, getStarProgressWidth(), height.toFloat(), starPaint)
}
绘制进度

根据type更正显示效果,是取半,取整还是任意取进度。open方法,可以方便修改

/**
 * 获取星星绘制宽度
 */

private fun getStarProgressWidth()Float {
    val percent = progress / 100f
    val starDrawCount = percent * starCount
    return when (starType) {
        StarType.HALF.ordinal -> {
            ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
        }
        StarType.WHOLE.ordinal -> {
            ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
        }
        else -> {
            starDrawCount * starSize + starDrawCount.toInt() * starPadding
        }
    }
}

/**
 * 取整规则
 */

private fun ceilWhole(x: Float)Float {
    return ceil(x)
}

/**
 * 取半规则
 */

private fun ceilHalf(x: Float)Float {
    // 四舍五入 1.3->1+0.5->1.5 1.7->2
    val round = round(x)
    return when {
        round < x -> round + 0.5f
        round > x -> round
        else -> x
    }
}
点击+滑动

点击+滑动就是重写onTouchEvent事件:

判断点击位置是否在范围内

/**
 * 点击的point是否在view范围内
 */

private fun pointInView(x: Float, y: Float)Boolean {
    return Rect(00, width, height).contains(x.toInt(), y.toInt())
}

记录按下位置,抬起位置。

MotionEvent.ACTION_DOWN -> {
    downX = event.x
    downY = event.y
}
MotionEvent.ACTION_UP -> {
    if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
        parent.requestDisallowInterceptTouchEvent(true)
        val progress = (event.x / width * 100).toInt()
        setProgress(progress)
        listener?.onClickProgress(progress)
    } else {
        parent.requestDisallowInterceptTouchEvent(false)
    }
}

滑动记录手指move

MotionEvent.ACTION_MOVE -> {
    if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
        parent.requestDisallowInterceptTouchEvent(true)
        val progress = (event.x / width * 100).toInt()
        setProgress(progress)
        listener?.onScrollProgress(progress)
    } else {
        parent.requestDisallowInterceptTouchEvent(false)
    }
}
添加监听

添加OnCommonRatingBarListener,监听点击事件以及滑动事件,返回进度

完整实现代码

class CommonRatingBar(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    /**
     * 星星数量
     */

    private var starCount = 5

    /**
     * 星星间隔
     */

    private var starPadding = 0f

    /**
     * 星星大小
     */

    private var starSize = 30

    /**
     * 星星选中背景图
     */

    private var starDrawable: Int = -1

    /**
     * 星星未选中背景图
     */

    private var starBgDrawable: Int = -1

    /**
     * 星星选择类型
     */

    private var starType = StarType.NORMAL.ordinal

    /**
     * 星星颜色
     */

    private var starColor: Int = Color.parseColor("#F7B500")

    /**
     * 星星可点击
     */

    private var starClickable = false

    /**
     * 星星可滑动选择
     */

    private var starScrollable = false

    /**
     * 星星未选中画笔
     */

    private val starBgPaint = Paint().apply {
        isAntiAlias = true
    }

    /**
     * 星星选中画笔
     */

    private val starDrawablePaint = Paint().apply {
        isAntiAlias = true
    }

    /**
     * 星星纯色画笔
     */

    private val starPaint = Paint().apply {
        isAntiAlias = true
        xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
    }

    private var progress = 0

    /**
     * 选中icon的Bitmap
     */

    private val starBitmap: Bitmap by lazy {
        val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        val starDrawable = ContextCompat.getDrawable(context, starDrawable)
        starDrawable?.setBounds(00, starSize, starSize)
        starDrawable?.draw(canvas)
        bitmap
    }

    /**
     * 未选中Bitmap
     */

    private val starBgBitmap: Bitmap by lazy {
        val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
        starDrawable?.setBounds(00, starSize, starSize)
        starDrawable?.draw(canvas)
        bitmap
    }

    init {
        initView(context, attrs)
        starPaint.color = starColor
    }

    private fun initView(context: Context, attrs: AttributeSet?) {
        val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.CommonRatingBar)
        starCount = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starCount, 5)
        starPadding = obtainStyledAttributes.getDimension(R.styleable.CommonRatingBar_starPadding, 10f)
        starDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starDrawable, -1)
        starBgDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starBgDrawable, -1)
        starType = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starType, StarType.NORMAL.ordinal)
        starColor = obtainStyledAttributes.getColor(R.styleable.CommonRatingBar_starColor, Color.parseColor("#F7B500"))
        starClickable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starClickable, false)
        starScrollable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starScrollable, false)
        obtainStyledAttributes.recycle()
    }

    override fun dispatchTouchEvent(event: MotionEvent?)Boolean {
        // super.dispatchTouchEvent(event) -> 当前view的onTouchEvent
        // false -> viewGroup的onTouchEvent
        return if (starClickable || starScrollable) super.dispatchTouchEvent(event)
        else false
    }

    /**
     * 最小触摸范围
     */

    private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
    private var downX = 0f
    private var downY = 0f

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent?)Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                downX = event.x
                downY = event.y
            }
            MotionEvent.ACTION_MOVE -> {
                if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
                    parent.requestDisallowInterceptTouchEvent(true)
                    val progress = (event.x / width * 100).toInt()
                    setProgress(progress)
                    listener?.onScrollProgress(progress)
                } else {
                    parent.requestDisallowInterceptTouchEvent(false)
                }
            }
            MotionEvent.ACTION_UP -> {
                if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
                    parent.requestDisallowInterceptTouchEvent(true)
                    val progress = (event.x / width * 100).toInt()
                    setProgress(progress)
                    listener?.onClickProgress(progress)
                } else {
                    parent.requestDisallowInterceptTouchEvent(false)
                }
            }
        }
        return true
    }

    /**
     * 点击的point是否在view范围内
     */

    private fun pointInView(x: Float, y: Float)Boolean {
        return Rect(00, width, height).contains(x.toInt(), y.toInt())
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        starSize = measuredHeight
        setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        if (starBgDrawable == -1) {
            return
        }
        canvas?.let {
            // xfermode需要使用离屏缓存
            val saved = it.saveLayer(nullnull)
            drawStar(it)
            if (starDrawable == -1) {
                drawStarBgColor(it)
            } else {
                drawStarDrawable(it)
            }
            it.restoreToCount(saved)
        }
    }

    /**
     * 绘制星星默认未选中背景
     */

    private fun drawStar(canvas: Canvas) {
        for (i in 0 until starCount) {
            val starLeft = i * (starSize + starPadding)
            canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
        }
    }

    /**
     * 绘制高亮纯颜色
     */

    private fun drawStarBgColor(canvas: Canvas) {
        canvas.drawRect(0f0f, getStarProgressWidth(), height.toFloat(), starPaint)
    }

    /**
     * 绘制高亮图标
     */

    private fun drawStarDrawable(canvas: Canvas) {
        starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
        canvas.drawRect(0f0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
    }

    /**
     * 获取星星绘制宽度
     */

    private fun getStarProgressWidth()Float {
        val percent = progress / 100f
        val starDrawCount = percent * starCount
        return when (starType) {
            StarType.HALF.ordinal -> {
                ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
            }
            StarType.WHOLE.ordinal -> {
                ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
            }
            else -> {
                starDrawCount * starSize + starDrawCount.toInt() * starPadding
            }
        }
    }

    private fun ceilWhole(x: Float)Float {
        return ceil(x)
    }

    private fun ceilHalf(x: Float)Float {
        // 四舍五入 1.3->1+0.5->1.5 1.7->2
        val round = round(x)
        return when {
            round < x -> round + 0.5f
            round > x -> round
            else -> x
        }
    }

    /**
     * 星星的绘制进度
     */

    fun setProgress(progress: Int) {
        var p = progress
        if (p < 0) p = 0
        if (p > 100) p = 100
        this.progress = p
        postInvalidate()
    }

    fun setProgress(currentValue: Float, totalValue: Float) {
        setProgress((currentValue * 100 / totalValue).toInt())
    }

    fun setOnCommonRatingBarListener(listener: OnCommonRatingBarListener) {
        this.listener = listener
    }

    private var listener: OnCommonRatingBarListener? = null

    interface OnCommonRatingBarListener {
        fun onClickProgress(progress: Int)
        fun onScrollProgress(progress: Int)
    }

    enum class StarType {
        NORMAL, HALF, WHOLE
    }

}
拓展

修改纯色方法配合LinearGradient,可以有渐变的选中效果


---END---


推荐阅读:
Gradle 7.0 依赖统一管理的全新方式!了解一下
不做跟风党,LiveData,StateFlow,SharedFlow 的使用场景对比
快手开源项目KOOM分析,一个高性能线上内存监控方案!
Android 自定义控件 | 弹幕的两种实现及性能对比
在Flutter中实现酷炫的文本动画(含代码示例)

更文不易,点个“在看”支持一下👇

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存