• Android实战——一步一步实现流动的炫彩边框


    1 前言

    本文打算一步一步地实现流动的炫彩边框,用来装饰一个布局,如广告布局,图片,使它们可以看起来更加地醒目,更加地吸引用户。

    流动的炫彩边框就是这样的效果:

    在这里插入图片描述

    2 正文

    2.1 方案选择

    先不考虑具体的实现细节,从大的方面来说,可以选择的方案有:

    • 使用自定义 View 来实现;
    • 使用自定义 Drawable 来实现。

    流动的炫彩边框仅仅是为了装饰一个布局,它自身并不需要处理触摸反馈事件,所以,这种情况下,使用自定义 Drawable 来实现是比较合适的。

    如果使用自定义 View 来实现,就需要把自定义的边框 View 和原有的待装饰的布局叠加在一起显示,这会使得布局变得复杂一些;但是,自定义 View 的实现方式,如果使用到 SurfaceView 这种双缓冲技术,会比自定义 Drawable 有一定的性能优势。

    本文采用自定义 Drawable 的方式来实现。

    2.2 给布局增加前景边框

    2.2.1 根据绘制顺序产生前景效果

    这里我们以一个图片的布局来举例子,也就说,我们要做的是给图片控件增加前景边框。

    自然地,会想到有没有官方支持的 setForeground() 这样的 API 呢?这种想法是合理的,如果官方有相应支持的 API,就应该去使用官方提供的 API;如果没有的话,自己再去想办法。

    但是,官方确实没有提供这样的 API。只好自己去想办法了。

    我们打算创建一个继承于 ImageView 的子类,重写它的 onDraw() 方法,在这个方法里面的 super.onDraw(canvas) 之后添加绘制边框的代码,这样不就是前景边框的效果了。

    class MyImageView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null
    ) : AppCompatImageView(context, attrs) {
        
        override fun onDraw(canvas: Canvas) {
            super.onDraw(canvas)
            // 在这里添加绘制边框的代码,就是前景边框效果了。
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    activity_main.xml 中使用自定义的 MyImageView

    
    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="16dp"
        tools:context=".MainActivity">
    
        <com.example.fluidcolorfulframe.MyImageView
            android:id="@+id/iv"
            app:srcCompat="@drawable/road"
            android:scaleType="fitXY"
            app:layout_constraintDimensionRatio="h,16:9"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_width="0dp"
            android:layout_height="0dp" />
    
    androidx.constraintlayout.widget.ConstraintLayout>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    好了,我们已经知道在哪里添加前景边框了。

    运行程序,只可以看到一张图片显示:

    在这里插入图片描述

    2.2.2 自定义 Drawable 绘制边框

    下面就要写绘制前景边框的代码了,前面已经说过要使用自定义 Drawable 的方式来做。

    创建继承于 Drawable 的子类 FluidColorfulFrameDrawable

    class FluidColorfulFrameDrawable: Drawable() {
        override fun draw(canvas: Canvas) {
            TODO("Not yet implemented")
        }
    
        override fun setAlpha(alpha: Int) {
            TODO("Not yet implemented")
        }
    
        override fun setColorFilter(colorFilter: ColorFilter?) {
            TODO("Not yet implemented")
        }
    
        override fun getOpacity(): Int {
            TODO("Not yet implemented")
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    居然有 4 个方法还未实现。别担心,我们现在就去实现它们吧。

    class FluidColorfulFrameDrawable: Drawable() {
        private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        override fun draw(canvas: Canvas) {
            // 这里就是绘制边框的地方
        }
    
        override fun setAlpha(alpha: Int) {
            paint.alpha = alpha
        }
    
        override fun setColorFilter(colorFilter: ColorFilter?) {
            paint.colorFilter = colorFilter
        }
    
        override fun getOpacity(): Int {
            return PixelFormat.TRANSLUCENT
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    对于自定义 Drawable 来说,还需要重写一个 setBounds 方法,用来决定绘制的范围,也就是说当它的 draw() 方法被调用时,Drawable 要绘制在哪里。

    class FluidColorfulFrameDrawable : Drawable() {
        private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        private lateinit var bounds: RectF
        private val rectF = RectF()
        // 10.dp 是代码里面对 Int 类型定义的扩展属性,把 dp 值转为 px。
        private val defaultRadius: Float = 10.dp
        private val defaultStrokeWidth: Float = 5.dp
    
        init {
            // 配置画笔
            paint.color = Color.RED
            paint.style = Paint.Style.STROKE
            paint.strokeWidth = defaultStrokeWidth
        }
    
        override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
            super.setBounds(left, top, right, bottom)
            // 记录 Drawable 的绘制范围在 bounds 对象里面
            bounds = RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())
        }
    
        override fun draw(canvas: Canvas) {
            // 绘制带圆角的矩形边框
            canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    现在直接去运行,是看不到效果的。因为我们还没有去使用自定义的 Drawable。

    MyImageView 里面使用自定义 Drawable:

    class MyImageView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null
    ) : AppCompatImageView(context, attrs) {
        // 创建自定义 Drawable 对象
        private val drawable = FluidColorfulFrameDrawable()
        override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
            super.onSizeChanged(w, h, oldw, oldh)
            // 设置 Drawable 的范围
            drawable.setBounds(0, 0, w, h)
        }
    
        override fun onDraw(canvas: Canvas) {
            super.onDraw(canvas)
            // 绘制 Drawable
            drawable.draw(canvas)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    运行程序,查看效果如下:

    在这里插入图片描述

    可以看到,边框是作为前景显示的,这点是没有问题的。

    但是,有两个不对的地方:图片的每个角都在圆角边框之外了;边框的线宽显示偏细。

    图片的每个角都在圆角边框之外了:这是因为图片的显示区域和边框的边界是一样大的,而边框是有圆角的,这样图片的四个角就一定是在边框之外了。这个问题可以通过给图片添加 padding 来解决。代码如下:

    class MyImageView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null
    ) : AppCompatImageView(context, attrs) {
        ...
        init {
            // 这里的 5dp 是和边框的线宽保持一致的。
            setPadding(5.dp.toInt())
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    运行效果如下:

    在这里插入图片描述

    边框的线宽显示偏细:这是因为绘制边框时使用的矩形区域是 MyImageView 传入的边界矩形,而画笔是有 5dp 的宽度的。这个问题可以通过创建新的矩形对象,给这个矩形对象设置抵消掉画笔宽度的左上右下值,并使用新的矩形对象来绘制。代码如下:

    class FluidColorfulFrameDrawable : Drawable() {
        private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        private lateinit var bounds: RectF
        private val rectF = RectF()
        private val defaultRadius: Float = 10.dp
        private val defaultStrokeWidth: Float = 5.dp
        ...
        override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
            super.setBounds(left, top, right, bottom)
            bounds = RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())
            rectF.left = defaultStrokeWidth / 2
            rectF.top = defaultStrokeWidth / 2
            rectF.right = bounds.width() - defaultStrokeWidth / 2
            rectF.bottom = bounds.height() - defaultStrokeWidth / 2
        }
    
        override fun draw(canvas: Canvas) {
            canvas.drawRoundRect(rectF, defaultRadius, defaultRadius, paint)
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    运行效果如下:

    在这里插入图片描述

    2.3 让边框炫彩

    由一种颜色组成的边框,看着实在单调。我们希望边框可以由多种颜色组成,看着流光溢彩一样地。

    这可以通过给画笔设置一个 SweepGradient 类型对象的着色器来实现。

    class FluidColorfulFrameDrawable : Drawable() {
        ...
        private val colors: IntArray
        private val positions: FloatArray
    
        init {
            paint.style = Paint.Style.STROKE
            paint.strokeWidth = defaultStrokeWidth
            colors = intArrayOf(
                "#FF0000FF".toColorInt(), // 蓝 0f
                "#FF000000".toColorInt(), // 黑 0.02f
                "#FF000000".toColorInt(), // 黑 0.25f
                "#FFFF0000".toColorInt(), // 红 0.27f
                "#FFFF0000".toColorInt(), // 红 0.37f
                "#FF00FF00".toColorInt(), // 绿 0.39f
                "#FF0000FF".toColorInt(), // 蓝 0.49f
                "#FFFFFF00".toColorInt(), // 黄 0.51f
                "#FF000000".toColorInt(), // 黑 0.53f
                "#FF000000".toColorInt(), // 黑 0.75f
                "#FFFF0000".toColorInt(), // 红 0.77f
                "#FFFF0000".toColorInt(), // 红 0.87f
                "#FFFFFF00".toColorInt(), // 黄 0.91f
                "#FF0000FF".toColorInt(), // 蓝 0.96f
            )
    
            positions = floatArrayOf(
                0f,
                0.02f,
                0.25f,
                0.27f,
                0.37f,
                0.39f,
                0.49f,
                0.51f,
                0.53f,
                0.75f,
                0.77f,
                0.87f,
                0.91f,
                0.96f,
            )
        }
    
        override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
            super.setBounds(left, top, right, bottom)
            ...
            paint.shader = SweepGradient(bounds.centerX(), bounds.centerY(), colors, positions)
        }
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    这里使用的是 SweepGradient 可以配置多个颜色,多个位置的构造方法。

    public SweepGradient(float cx, float cy, @NonNull @ColorInt int[] colors,
                @Nullable float[] positions)
    
    • 1
    • 2

    需要说明的是:

    colors 参数是不可以为 null 的,并且至少要包括两个颜色值。

    positons 参数是可以为 null 的:

    • 如果 positionsnull,那么 colors 中的颜色值会自动均匀分布开来。

    • 如果 positions 不为 null,那么它的长度必须与 colors 的长度保持一致;而且,它的元素值需要是依次递增的,范围在 0f 到 1f 之间。另外,官方文档里面说:

      The relative position of each corresponding color in the colors array, beginning with 0 and ending with 1.0.

      positions 数组里的元素以 0 开始,以 1.0 结束。

      官方文档的说法是不对的。实际上,positions 数组的元素并非要以 0 开始,以 1.0 结束。

    运行程序,查看效果:

    在这里插入图片描述

    2.4 让边框流动起来

    让边框流动起来,就是让边框旋转起来。这里要使用到属性动画和 Shader 的本地矩阵方法来处理。

    class FluidColorfulFrameDrawable : Drawable() {
        ...
        private val mtx = Matrix()
        private var degree: Float = 0f
            set(value) {
                field = value
                // 刷新自己
                invalidateSelf() 
            }
        ...
        override fun draw(canvas: Canvas) {
            // 设置本地矩阵
            mtx.reset()
            mtx.setRotate(degree, bounds.centerX(), bounds.centerY())
            (paint.shader as SweepGradient).setLocalMatrix(mtx)
            canvas.drawRoundRect(rectF, defaultRadius, defaultRadius, paint)
        }
    	...
        private var fluidAnim: ObjectAnimator? = null
    
        // 开始流动
        fun startFluid() {
            fluidAnim = ObjectAnimator.ofFloat(this, "degree", 0f, 360f).apply {
                duration = 2000L
                interpolator = LinearInterpolator()
                repeatCount = ValueAnimator.INFINITE
                start()
            }
        }
    	// 取消流动
        fun cancelFluid() {
            fluidAnim?.cancel()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    MyImageView 里面调用开始流动和取消流动的方法:

    class MyImageView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null
    ) : AppCompatImageView(context, attrs) {
        private val drawable = FluidColorfulFrameDrawable()
        ...
        override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
            super.onSizeChanged(w, h, oldw, oldh)
            drawable.setBounds(0, 0, w, h)
            drawable.startFluid()
        }
    	...
        override fun onDetachedFromWindow() {
            super.onDetachedFromWindow()
            drawable.cancelFluid()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    运行程序,查看流动效果:

    在这里插入图片描述

    居然没有流动效果!

    需要具体分析一下,这里在 degree 的 setter 方法里面和 draw 方法里面增加日志打印:

    private var degree: Float = 0f
        set(value) {
            field = value
            Log.d(TAG, "degree setter called")
            invalidateSelf()
        }
    override fun draw(canvas: Canvas) {
        Log.d(TAG, "draw: ")
        mtx.reset()
        mtx.setRotate(degree, bounds.centerX(), bounds.centerY())
        (paint.shader as SweepGradient).setLocalMatrix(mtx)
        canvas.drawRoundRect(rectF, defaultRadius, defaultRadius, paint)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    运行程序后,可以看到下面这行日志在不停地打印:

    D/FluidColorfulFrame: draw: 
    D/FluidColorfulFrame: degree setter called
    D/FluidColorfulFrame: degree setter called
    D/FluidColorfulFrame: degree setter called
    ... // 后面全是重复 degree setter called 的日志
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这就说明 invalidateSelf() 方法并没有触发 draw 方法的调用了。现在去看一下 Drawable 类的 invalidateSelf 方法的源码:

    public void invalidateSelf() {
        final Callback callback = getCallback();
        if (callback != null) {
            callback.invalidateDrawable(this);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    内部是通过 getCallback() 方法获取一个 Callback 对象;如果 Callback 对象不为 null,则调用其 invalidateDrawable 方法并且把 Drawable 对象传入这个方法。

    继续查看 getCallback() 方法以及相关的字段和方法:

    private WeakReference<Callback> mCallback = null;
    public final void setCallback(@Nullable Callback cb) {
        mCallback = cb != null ? new WeakReference<>(cb) : null;
    }
    public Callback getCallback() {
        return mCallback != null ? mCallback.get() : null;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    我们并没有调用自定义 Drawable 对象的 setCallback 方法,所以 getCallback() 方法的返回值是 null,在 invalidateSelf 方法里面就不会回调 invalidateDrawable 方法了。

    MyImageView 中设置 Drawable 对象的 setCallback 方法并重写 invalidateDrawable 方法:

    class MyImageView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null
    ) : AppCompatImageView(context, attrs) {
        private val drawable = FluidColorfulFrameDrawable()
        init {
            setPadding(5.dp.toInt())
            // 设置 callback
            drawable.callback = this
        }
        ...
        // 重写 invalidateDrawable
        override fun invalidateDrawable(dr: Drawable) {
            super.invalidateDrawable(dr)
            // 如果回调的 dr 就是 drawable,就调用重绘方法。
            if (dr === drawable) {
                invalidate()
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    重新运行程序,查看效果:

    在这里插入图片描述

    3 最后

    本文一步一步地展示了流动炫彩边框的实现过程,用到了自定义 Drawable,画笔的着色器(扫描渐变,本地矩阵),属性动画,Drawable 与 View 的刷新回调等知识。

    本文并没有演示如何给一个 ViewGroup 类型的控件添加边框,但是相信这个是难不倒大家了。

    代码已经上传到 Github。希望可以帮助到大家,也欢迎大家点赞分享。

  • 相关阅读:
    ant-design国际化扩展新语言
    二维码那点事
    虚拟偶像的歌声原来是这样生成的!
    java计算机毕业设计vue教学管理系统源码+数据库+系统+lw文档
    【数据库系统概论】第九章关系查询处理何查询优化
    船用电缆和普通电缆有什么区别?
    android 13.0 静默安装app和静默卸载app功能实现
    【设计模式实战】命令模式:原理篇
    巧用 API 网关构建大型应用体系架构
    最新AI创作系统ChatGPT网站源码Midjourney-AI绘画系统,Suno-v3-AI音乐生成大模型。
  • 原文地址:https://blog.csdn.net/willway_wang/article/details/126698866