自定义 Drawable 实现任意高度纯圆角背景及玻璃效果

自定义胶囊 Drawable

我们可以继承 Drawable 实现我们自己的绘制逻辑,比如胶囊按钮(圆角为 50% 高度)的背景使用MyCircleRoundedDrawable实现,然后在 xml 中声明,最后直接将它作为background或者 foreground就可以适配任意高度的按钮了,这样做还有个好处是不与任何 View 绑定,可以对任意容器进行背景或者前景的定制。
MyCircleRoundedDrawable 代码实现

kt 复制代码
@Keep
class MyCircleRoundedDrawable : Drawable() {

    private val mDefaultColor = Color.WHITE

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = mDefaultColor
        style = Paint.Style.FILL
    }

    private val mRect = RectF()


    // tint 相关
    private var tintList: ColorStateList? = null
    private var tintFilter: ColorFilter? = null
    private var customColorFilter: ColorFilter? = null
    // 旧 & 新模式
    private var porterDuffMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN
    private var blendMode: BlendMode = BlendMode.SRC_IN

    private var cornerRadiusPx: Float = 0f
        set(value) {
            field = value
            invalidateSelf()
        }

    override fun draw(canvas: Canvas) {
        canvas.drawRoundRect(mRect, cornerRadiusPx, cornerRadiusPx, paint)
    }

    override fun onBoundsChange(bounds: Rect) {
        super.onBoundsChange(bounds)
        cornerRadiusPx = bounds.height() / 2f
        mRect.set(bounds)
    }

    override fun setAlpha(alpha: Int) {
        paint.alpha = alpha
    }

    override fun setColorFilter(colorFilter: ColorFilter?) {
        customColorFilter = colorFilter
        paint.colorFilter = customColorFilter
        invalidateSelf()
    }

    @Deprecated("Deprecated in Java")
    override fun getOpacity(): Int = PixelFormat.TRANSLUCENT

    override fun setTintList(tint: ColorStateList?) {
        tintList = tint
        updateTintFilter()
        invalidateSelf()
    }

    override fun setTintMode(mode: PorterDuff.Mode?) {
        super.setTintMode(mode)
        porterDuffMode = mode ?: PorterDuff.Mode.SRC_IN
        updateTintFilter()
        invalidateSelf()
    }

    override fun setTintBlendMode(mode: BlendMode?) {
        super.setTintBlendMode(mode)
        if (Build.VERSION.SDK_INT >= 29) {
            blendMode = mode ?: BlendMode.SRC_IN
            updateTintFilter()
            invalidateSelf()
        }
    }

    override fun isStateful(): Boolean {
        return tintList?.isStateful == true || super.isStateful()
    }

    override fun onStateChange(state: IntArray): Boolean {
        if (tintList != null) {
            updateTintFilter()
            invalidateSelf()
            return true
        }
        return false
    }

    private fun updateTintFilter() {
        if(customColorFilter != null) return

        val color = tintList?.getColorForState(state, mDefaultColor)
            ?: run {
                tintFilter = null
                return
            }

        tintFilter = when {
            Build.VERSION.SDK_INT >= 29 -> {
                BlendModeColorFilter(color, blendMode)
            }
            else -> {
                PorterDuffColorFilter(color, porterDuffMode)
            }
        }
        paint.colorFilter = tintFilter
    }

}

my_circle_drawable.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<drawable class="com.test.MyCircleRoundedDrawable" />

玻璃效果背景

在上面的基础上我们可以拓展实现类似 iOS 液态玻璃效果的背景,这样做的好处是与 View 逻辑解耦,可以填充到任意容器中。

首先这是静态玻璃效果,没有像 iOS 一样根据陀螺仪对描边做实时角度变化。

其次,这个渐变效果需要使用二维插值,iOS CAGradientLayer原生支持,为了在安卓上能使用我们需要依赖runtimeShader这个 API,但它在 Android 13(API 33)及以上才可用,所以低版本我只使用一维插值做了兼容。

步骤也还是一样,先写自定义 Drawable:
MyGradientBorderDrawable 代码

kt 复制代码
/**
 * 渐变 Border 的Drawable 默认玻璃效果
 *
 * 目前有两套,可以通过 [withLightBackground] 切换,默认深色背景
 * 深色背景 [strokeWidthPx] = 0.5, [colors] = [darkBgColor]
 * 浅色背景 [strokeWidthPx] = 1, [colors] = [lightBgBorder]
 *
 */
@Keep
class MyGradientBorderDrawable : Drawable() {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        strokeJoin = Paint.Join.ROUND
        strokeCap = Paint.Cap.ROUND
    }

    private val rect = RectF()

    private var strokeWidthPx: Float = 0.5f.vdpf
        set(value) {
            field = value
            paint.strokeWidth = value
        }

    private var isCustomCornerRadius = false
    private var cornerRadiusPx: Float = 0f
    private var colors: IntArray = darkBgColor

    var withLightBackground: Boolean = false
        set(value) {
            field = value
            colors = if (value) lightBgBorder else darkBgColor
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                updateRuntimeShaderColor()
            }
            strokeWidthPx = if (value) 1f.vdpf else (0.5f).vdpf
            invalidateSelf()
        }

    private val runtimeShader: RuntimeShader? by lazy {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            RuntimeShader(SHADER_GRADIENT)
        }else{
            null
        }
    }

    init {
        paint.strokeWidth = strokeWidthPx
    }

    override fun inflate(
        r: Resources,
        parser: XmlPullParser,
        attrs: AttributeSet,
        theme: Resources.Theme?
    ) {
        super.inflate(r, parser, attrs, theme)
        val a = theme?.obtainStyledAttributes(
            attrs,
            R.styleable.MyGradientBorderDrawable,
            0,
            0
        ) ?: r.obtainAttributes(attrs, R.styleable.MyGradientBorderDrawable)
        withLightBackground =
            a.getBoolean(R.styleable.MyGradientBorderDrawable_withLightBackground, false)

        a.recycle()

    }

    override fun draw(canvas: Canvas) {
        canvas.drawRoundRect(rect, cornerRadiusPx, cornerRadiusPx, paint)
    }

    override fun onBoundsChange(bounds: Rect) {
        super.onBoundsChange(bounds)
        val half = strokeWidthPx / 2f
        if (!isCustomCornerRadius) {
            cornerRadiusPx = bounds.height() / 2f
        }
        rect.set(
            bounds.left + half,
            bounds.top + half,
            bounds.right - half,
            bounds.bottom - half
        )
        updateShader()
    }

    override fun setAlpha(alpha: Int) {
        paint.alpha = alpha
    }

    override fun setColorFilter(colorFilter: ColorFilter?) {
        paint.colorFilter = colorFilter
    }

    fun updateCornerRadiusInPx(radiusInPx: Float) {
        this.cornerRadiusPx = radiusInPx
        this.isCustomCornerRadius = true
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            this.runtimeShader?.setFloatUniform("cornerRadius", cornerRadiusPx)
        }
        invalidateSelf()
    }

    @Deprecated("Deprecated in Java")
    override fun getOpacity(): Int = PixelFormat.TRANSLUCENT

    private fun updateShader() {
        if (rect.width() <= 0 || rect.height() <= 0) return

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            this.runtimeShader?.apply {
                this.setFloatUniform("cornerRadius", cornerRadiusPx)
                this.setFloatUniform(
                    "iResolution",
                    floatArrayOf(bounds.width().toFloat(), bounds.height().toFloat())
                )
                paint.shader = this
            }
        }else{
            paint.shader = LinearGradient(
                0f, 0f, this.bounds.width().toFloat(), this.bounds.height().toFloat(),
                colors, null, Shader.TileMode.CLAMP
            )
        }
    }

    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    private fun updateRuntimeShaderColor(){
        runtimeShader?.apply {
            this.setFloatUniform("colorStart", colors[0].asFloatArray())
            this.setFloatUniform("colorMid", colors[1].asFloatArray())
            this.setFloatUniform("colorEnd", colors[2].asFloatArray())
        }
    }

    private fun @receiver:ColorInt Int.asFloatArray(): FloatArray {
        return floatArrayOf(this.red / 255f,this.green / 255f,this.blue / 255f,this.alpha / 255f)
    }

    companion object {
        // 浅色背景下的颜色值
        val lightBgBorder = intArrayOf(
            Color.argb(0.7f, 1f, 1f, 1f),
            Color.TRANSPARENT,
            Color.argb(0.6f, 1f, 1f, 1f),
        )

        // 深色背景下的颜色值
        val darkBgColor = intArrayOf(
            Color.argb(0.4f, 1f, 1f, 1f),
            Color.TRANSPARENT,
            Color.argb(0.3f, 1f, 1f, 1f),
        )

        private const val SHADER_GRADIENT = """
        uniform float2 iResolution;   // View 尺寸
        uniform half4 colorStart;     // 左上角颜色
        uniform half4 colorMid;       // 中间透明
        uniform half4 colorEnd;       // 右下角颜色
        uniform float cornerRadius;       // 右下角颜色

        half4 main(float2 fragCoord) {
            // 归一化坐标
            float2 uv = fragCoord / iResolution;
            // 投影到左上 -> 右下对角线
            float t = (uv.x + uv.y) / 2.0;
            half4 color;
            if (t < 0.5) {
                // 左上 -> 中间 Interpolator 先缓后急
                color = mix(colorStart, colorMid, (t * 2.0) * (t * 2.0));
            } else {
                // 中间 -> 右下 先急后缓
                float pivot = (t - 0.5) * 2.0; // 左到右 => 0 -> 1
                float mixValue = 1 - (1 - pivot) * (1 - pivot);
                color = mix(colorMid, colorEnd, mixValue);
            }
            return half4(color.rgb * color.a, color.a);
        }
    """
    }
}


val Float.vdpf
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        this,
        Resources.getSystem().displayMetrics
    )

目前根据暗色、亮色背景预设了两种边框颜色,通过withLightBackground可以切换,为了支持从 drawable 的 XML 中指定暗色、亮色,还需要定义一个 styleable 文件attr_my_gradient_border.xml

xml 复制代码
<resources>
    <declare-styleable name="MyGradientBorderDrawable">
        <attr name="withLightBackground" format="boolean"/>
    </declare-styleable>
</resources>

然后新增 drawable 文件my_light_bg_glass_bordermy_dark_bg_glass_border 分别指向具体的类的实现,标识亮色背景和白色背景,仅修改withLightBackground属性的值,类似这样:

my_light_bg_glass_border.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<drawable xmlns:app="http://schemas.android.com/apk/res-auto"
    class="com.test.MyGradientBorderDrawable"
    app:withLightBackground="true" />

最后就可以直接在具体业务场景直接使用了。

相关推荐
秃了也弱了。2 小时前
ElasticSearch:优化案例实战解析(持续更新)
android·java·elasticsearch
恋猫de小郭2 小时前
Kotlin 在 2.0 - 2.3 都更新了什么特性,一口气带你看完这两年 Kotlin 更新
android·前端·flutter
墨狂之逸才3 小时前
React Native 移动项目目录导致的 Android 编译失败问题及解决方案
android·react native
feng一样的男子3 小时前
住在手机里的“小龙虾” (OpenClaw):接入本地模型,解决记忆“装死”顽疾
android·ai·智能手机·openclaw
hongtianzai4 小时前
MySQL中between and的基本用法
android·数据库·mysql
Zender Han4 小时前
Flutter Bloc / Cubit 最新详解与实战指南(2026版)
android·flutter·ios
sun0077005 小时前
pthread_once
android
阿拉斯攀登6 小时前
第 20 篇 RK 平台 NPU / 硬件编解码驱动适配与安卓调用
android·驱动开发·瑞芯微·rk安卓驱动
Volunteer Technology6 小时前
mysql面试场景题(二)
android·mysql·面试