Halo-智慧屏焦点动效实现方案

1,590 阅读5分钟

记得之前有人在文章下问过,华为智慧屏那种焦点框的实现。对于厂商来说,优先考虑最高效的实现方案,肯定是用c++编写,毕竟Android上层的绘制效率来说,远不及底层来的高效。 碰巧前段时间,有个朋友他们公司有类似的需求,自己也不是特别忙,就抽空用上层实现帮朋友写了一个通用组件。

取名Halo,光环,已经远离游戏好多年了,算是致敬下士官长吧。题外话不说了,下面开始正文。

我们先看看华为智慧屏的效果。当焦点选中海报,按钮,选项卡的时候,这些组件外圈都有一个光晕效果在环绕旋转,说实话,在TV厂家的各种定制系统里,这焦点的动效设计真的是遥遥领先哈哈。

看智慧屏的效果,会发现大部分原生可聚焦的组件都会自带有这种效果,个人推测应该是系统统一客制化了这些基础控件。
那我们独立的应用开发,总不可能每个控件都去定制一遍实现吧,就像一些无缝换肤sdk的实现,虽然是通过拦截的方式统一把原生控件替换成自定义对应的控件,但是内部依旧需要维护一系列的自定义控件,去对应适配替换原生控件。因此,这里,首先淘汰定制化Button,TextView,CardView等等基础控件的方式。说实话个人开发者很少会有精力和时间去将其都重新实现一遍。因此,确定目标方案,通过wrapper方式包裹子控件实现,自然就会考虑到轻量的ViewGroup:FrameLayout。

我们先来看个实现的效果图吧,GIF为了压缩文件大小,降帧加速了,实际上是很流畅的。支持矩形,圆角,圆形三种类型,支持光环颜色设置,环绕速度等:
halo.gif

这里的设计有一个地方其实把我卡壳了半天,注意,智慧屏上的效果是光环和内部内容区域是透明的。这样一来,也就不能简单的直接往canvas上绘制了。 最初我想到了两种方案:

  1. canvas进行save,然后按path裁剪后再绘制光晕,恢复canvas后在绘制内容区域。这样的确可以实现光环和内容之间的间隔透明化,但是clipPath有一个大家都知道的致命缺点:锯齿!当然,为了验证效果,我还是实现了一遍,结果却有点意想不到。总结一下:性能比较高效,在TV上和一些低版本的手机上的确存在明显锯齿,尤其是圆形。但是在我的一加8,android 11系统下,clipPath的圆滑程度竟然比下面的方案2还要完美。这就让我尴尬了,具体原因未知,猜测是系统层面做了优化,有知道的同学麻烦告知下。
  2. 使用PorterDuffXfermode混合模式。这十多种模式,说简单简单,说复杂也复杂,坑是挺多的,你按照说明和官方给的混合效果图自己去写,很大概率不会出现官方效果图的结果。混合模式自己去看官方demo吧,这里就简单说下,混合模式必须是bitmap的混合叠加,并且要注意srcdst先后顺序。下面会介绍通过混合模式实现间隔透明化的具体实现:

##实现

  1. 首先我们聚焦点就是这个光环的光晕效果,它在动效执行过程是绕着内容移动的,其实仔细想想,本质上就是旋转嘛。渐变效果,并且需要在旋转过程中保持外环移动所在位置的渐变色相同,首选方案SweepGradient,我们先上一段创建光环的代码:
    private fun createHalo() {
        if (width > 0 && height > 0) {
            val shaderBound = sqrt((width * width + height * height).toDouble()).toInt()
            shaderBitmap = Bitmap.createBitmap(shaderBound, shaderBound, Bitmap.Config.ARGB_8888)
            val shaderCanvas = Canvas(shaderBitmap)
            val shaderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
                //      0.625      0.75       0.875
                //           +++++++++++++++++
                // white  0.5+---------------+0 white
                //           +++++++++++++++++
                //      0.375      0.25       0.125
                val shader = SweepGradient(shaderBound / 2f, shaderBound / 2f,
                        intArrayOf(haloColor, Color.TRANSPARENT, Color.TRANSPARENT, haloColor, Color.TRANSPARENT, Color.TRANSPARENT, haloColor),
                        floatArrayOf(0f, 0.125f, 0.375f, 0.5f, 0.625f, 0.875f, 1f)
                )
                this.shader = shader
            }
            shaderCanvas.drawCircle(shaderBound / 2f, shaderBound / 2f, shaderBound.toFloat(), shaderPaint)
            shaderLeft = -(shaderBound - width) / 2f
            shaderTop = -(shaderBound - height) / 2f
        }
    }

我们先分析下下图,白色是我们的canvas区域,我们的光环shader是圆形,在旋转过程中要始终环绕在内容区域外框,那该shader的圆形半径就是canvas的对角线的一半。上面提到混合模式是作用于bitmap,因此我们需要把shader绘制到一张bitmap上,而这张bitmap的尺寸就如图所示:

  1. 至此,我们完成了第一步,创建了一个光环效果。说到光环和内容区域的透明间隔,用混合模式怎么实现呢?有同学了解过SurfaceView的原理吧,挖孔,这个名词应该听过。我这里采取的就是这种方式,通过一张挖孔bitmap与光环bitmap进行混合,达到把实体的光环图中间挖出一个透明区域,供内容绘制,haloStrokeWidth是我们光环的宽度,左右上下各减去光环宽度,剩余的canvas区域就是我们绘制内容的区域了:
    private fun createHole() {
        if (width > 0 && height > 0) {
            val holeWidth = width - haloStrokeWidth * 2
            val holeHeight = height - haloStrokeWidth * 2
            holeBitmap = Bitmap.createBitmap(holeWidth.toInt(), holeHeight.toInt(), Bitmap.Config.ARGB_8888)
            val holeCanvas = Canvas(holeBitmap)
            val holePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
                color = Color.WHITE
                style = Paint.Style.FILL
            }
            when (shapeType) {
                SHAPE_RECT -> {
                    holeCanvas.drawRect(0f, 0f, holeWidth, holeHeight, holePaint)
                }
                SHAPE_ROUND_RECT -> {
                    holeCanvas.drawRoundRect(0f, 0f, holeWidth, holeHeight, cornerRadius.toFloat(), cornerRadius.toFloat(), holePaint)
                }
                SHAPE_CIRCLE -> {
                    holeCanvas.drawCircle(holeWidth / 2f, holeHeight / 2f, holeWidth / 2f, holePaint)
                }
            }
        }
    }
  1. 至此,我们就创建了shaderBitmapholeBitmap两张图片。开始混合运算,我们先通过混合把外圈的光环绘制处理好,再将剩余区域交给原生的绘制流程进行内容区域(ChildView)的绘制。同时我们构建一个基础的ValueAnimate进行动画运算,不断旋转重绘就能产生光环环绕移动的效果啦:
    private val holePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OUT)
    }

    override fun dispatchDraw(canvas: Canvas?) {
        if (isFocused && canvas != null) {
            canvas.drawBitmap(holeBitmap, haloStrokeWidth, haloStrokeWidth, null)
            canvas.let {
                canvas.save()
                canvas.rotate(degrees, centerX, centerY)
                canvas.drawBitmap(shaderBitmap, shaderLeft, shaderTop, holePaint)
                canvas.restore()
            }
        }
        super.dispatchDraw(canvas)
    }
  1. 核心代码就上面这些,剩下就是一些形状类型处理,资源释放,自定义属性,对外暴露设置参数方法等常规操作了。
  2. 最后看看使用方式:
    <com.seagazer.halo.Halo
        android:id="@+id/halo2"
        android:layout_width="230dp"
        android:layout_height="150dp"
        android:layout_marginStart="30dp"
        app:haloColor="#FFFF61" //光环颜色
        app:haloCornerRadius="10dp" //光环圆角(设置圆角时需要设置)
        app:haloInsertEdge="8dp" //光环与内容的间距(不能小于光环宽度)
        app:haloShape="roundRect" //光环类型:直角,圆角,圆形
        app:haloWidth="3dp">// 光环的宽度

        <androidx.cardview.widget.CardView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:cardBackgroundColor="@color/halo_card"
            app:cardCornerRadius="8dp">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:gravity="center"
                android:text="Round Rect"
                android:textColor="@color/white"
                android:textSize="18sp" />
        </androidx.cardview.widget.CardView>
    </com.seagazer.halo.Halo>

时间不早了,年纪大了,得早点休息,也就不多写了,完整代码和demo大家自己去看吧,喜欢的话点个赞支持下吧。 项目地址: github.com/seagazer/ha…