手把手讲解 一个复杂动效的自定义绘制

前言

手把手讲解系列文章,是我写给各位看官,也是写给我自己的。
文章可能过分详细,但是这是为了帮助到尽量多的人,毕竟工作5,6年,不能老吸血,也到了回馈开源的时候.
这个系列的文章:
1、用通俗易懂的讲解方式,讲解一门技术的实用价值
2、详细书写源码的追踪,源码截图,绘制类的结构图,尽量详细地解释原理的探索过程
3、提供Github 的 可运行的Demo工程,但是我所提供代码,更多是提供思路,抛砖引玉,请酌情cv
4、集合整理原理探索过程中的一些坑,或者demo的运行过程中的注意事项
5、用gif图,最直观地展示demo运行效果

如果觉得细节太细,直接跳过看结论即可。
本人能力有限,如若发现描述不当之处,欢迎留言批评指正。

学到老活到老,路漫漫其修远兮。与众君共勉 !


引子

最近在深入研究android复杂动效,看到某些效果之后,觉得心里一惊,这个怎么这么酷炫,怎么实现的,如果此时让我动手去做,完全不知道从何下手。但是多年的高级开(码)(农)的职业嗅觉告诉我,一定存在我未曾使用过的api。于是花了2天时间研究一番,总结出此文,把复杂动效的绘制过程中的一些 梗,写下来,以便后人少踩坑.

github地址:下载之后找到其中的 DrawOverlayDemoView

效果图

下图中可以看到,首先我们看到了一个心形,然后有波浪在跳动,最后绿色填满了整个心形

大灰狼变绿了

乍一看

诶?心形是怎么绘制的?
诶?波浪是怎么画出来的,又是如何动起来的?
诶? 文字是怎么呈现出同一时刻的两种颜色的?

不知道是不是有人有这样的疑惑````请继续往下看.

效果拆解

拿到一个复杂特效,第一件事不要慌,先仔细分析一下,这个特效里面具体有哪些细节可以拆分出来。复杂的东西都是由简单的细节 组合而成。

开始拆解。
1、绘制区域是一个心形
2、波浪从最下面开始,逐渐用绿色填充了整个心形
3、中间有文字内容“一条大灰狼”,并且在波浪增长的过程中,文字存在一段时间的上下两部分颜色不同的状态.

本案例用到的知识点:

1、canvas.clipPath 画布裁剪
2、canvas.save 画布状态保存
3、canvas.restore 恢复
4、canvas.translate 画布平移
6、path.rCubicTo 构建三阶贝塞尔曲线(相当于上一个点位置)
5、属性动画 ValueAnimator / AnimatorSet

开始撸码

按照之前拆解的步骤,

1步:构建一个心形区域

当一个复杂图形摆在我们面前,而且还是不规则图形,我们首先应该想到的,就是android.graphics.Path 类,它可以记录复杂图形的全部点组成的路径。
关键代码:

/**
     * 构建心形
     * <p>
     * 注意,它这个是以 矩形区域中心点为基准的图形,所以绘制的时候,必须先把坐标轴移动到 区域中心
     */
    private void initHeartPath(Path path) {
        List<PointF> pointList = new ArrayList<>();
        pointList.add(new PointF(0, Utils.dp2px(-38)));
        pointList.add(new PointF(Utils.dp2px(50), Utils.dp2px(-103)));
        pointList.add(new PointF(Utils.dp2px(112), Utils.dp2px(-61)));
        pointList.add(new PointF(Utils.dp2px(112), Utils.dp2px(-12)));
        pointList.add(new PointF(Utils.dp2px(112), Utils.dp2px(37)));
        pointList.add(new PointF(Utils.dp2px(51), Utils.dp2px(90)));
        pointList.add(new PointF(0, Utils.dp2px(129)));
        pointList.add(new PointF(Utils.dp2px(-51), Utils.dp2px(90)));
        pointList.add(new PointF(Utils.dp2px(-112), Utils.dp2px(37)));
        pointList.add(new PointF(Utils.dp2px(-112), Utils.dp2px(-12)));
        pointList.add(new PointF(Utils.dp2px(-112), Utils.dp2px(-61)));
        pointList.add(new PointF(Utils.dp2px(-50), Utils.dp2px(-103)));

        path.reset();
        for (int i = 0; i < 4; i++) {
            if (i == 0) {
                path.moveTo(pointList.get(i * 3).x, pointList.get(i * 3).y);
            } else {
                path.lineTo(pointList.get(i * 3).x, pointList.get(i * 3).y);
            }

            int endPointIndex;
            if (i == 3) {
                endPointIndex = 0;
            } else {
                endPointIndex = i * 3 + 3;
            }

            path.cubicTo(pointList.get(i * 3 + 1).x, pointList.get(i * 3 + 1).y,
                    pointList.get(i * 3 + 2).x, pointList.get(i * 3 + 2).y,
                    pointList.get(endPointIndex).x, pointList.get(endPointIndex).y); //你的心形就是用贝塞尔曲线来画的吗
        }
        path.close();
        path.computeBounds(mHeartRect, false);//把path所占据的最小矩形区域,返回出去
    }

传入一个Path引用,然后在方法内部对path进行各种api调用改变其属性. 这里需要提及一个重点:最后一行代码 path.computeBounds(mHeartRect, false); 意思是,无论什么样的path,它都会占据一个最小矩形区域,computeBounds方法可以获取这个矩形区域,设置给入参mHeartRect.


2步:将心形区域裁剪出来, 裁剪之后,后续的绘制都只会显示在这个区域之内

(为了作图方便,我们通常先把坐标轴原点移动到 绘制区域的正中央)

    @Override
    protected void onDraw(Canvas canvas) {

        int width = getWidth();
        int height = getHeight();
        canvas.translate(width / 2, height / 2);//为了作图方便,我们通常先把坐标轴原点移动到 绘制区域的正中央
        ...省略无关代码

        canvas.clipPath(mMainPath);//裁剪心形区域
        canvas.save();//保存画布状态

        ...省略无关代码

    }

3步:绘制波浪区域

这里有两点细节
1)波浪区域分为两块,topbottom 上下两块

  1. 整个波浪区域的长度为 心形矩形范围宽度的2
    (?为什么是2倍?因为上面的波浪动画,其实是整个波浪区域平移造成的视觉效果,为了让这个动画可以无限执行,设计两倍宽度,当一半的宽度向右移动刚好触及心形矩形区域的右边框的时候,让它还原到原始位置,这样就能无缝衔接。)
关键代码1 - 波浪path的构建
    /**
     * @param ifTop   是否是上部分; 上下部分的封口位置不一样
     * @param r       心形的矩形区域
     * @param process 当前进度值
     */
    private void resetWavePath(boolean ifTop, RectF r, float process, Path path) {
        final float width = r.width();
        final float height = r.width();

        path.reset();

        if (ifTop) {
            path.moveTo(r.left - width, r.top);
        } else {
            path.moveTo(r.left - width, r.bottom); //下部,初始位置点在 下
        }

        float waveHeight = height / 8f;//波动的最大幅度

        //找到矩形区域的左边线中点
        path.lineTo(r.left - width, r.bottom - height * process);

        //做两个周期的贝塞尔曲线
        for (int i = 0; i < 2; i++) {
            float px1, py1, px2, py2, px3, py3;

            px1 = width / 4;
            py1 = -waveHeight;

            px2 = width / 4 * 3;
            py2 = waveHeight;

            px3 = width;
            py3 = 0;

            path.rCubicTo(px1, py1, px2, py2, px3, py3);
        }
        if (ifTop) {
            path.lineTo(r.right, r.top);
        } else {
            path.lineTo(r.right, r.bottom);
        }
        path.close();

    }
关键代码2- 属性动画 改变两个全局变量 波浪的向上增长系数 以及 横向波浪动画系数
    AnimatorSet animatorSet;
    // 动起来
    public void startAnimator() {

        if (animatorSet == null) {
            animatorSet = new AnimatorSet();
            ValueAnimator growAnimator = ValueAnimator.ofFloat(0f, 1f);
            growAnimator.addUpdateListener(animation -> growProcess = (float) animation.getAnimatedValue());
            growAnimator.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    animatorSet.cancel();
                }
            });
            growAnimator.setInterpolator(new DecelerateInterpolator());
            growAnimator.setDuration((long) (4000 / animatorSpeedCoefficient));

            ValueAnimator waveAnimator = ValueAnimator.ofFloat(0f, 1f);
            waveAnimator.setRepeatCount(ValueAnimator.INFINITE);
            waveAnimator.setRepeatMode(ValueAnimator.RESTART);
            waveAnimator.addUpdateListener(animation -> {
                waveProcess = (float) animation.getAnimatedValue();
                invalidate();
            });
            waveAnimator.setInterpolator(new LinearInterpolator());
            waveAnimator.setDuration((long) (1000 / animatorSpeedCoefficient));

            animatorSet.playTogether(growAnimator, waveAnimator);
            animatorSet.start();
        } else {
            animatorSet.cancel();
            animatorSet.start();
        }
    }
关键代码3- 利用属性动画改变的全局变量,构建动态效果
    @Override
    protected void onDraw(Canvas canvas) {

        int width = getWidth();
        int height = getHeight();
        canvas.translate(width / 2, height / 2);//为了作图方便,我们通常先把坐标轴原点移动到 绘制区域的正中央
        curXOffset = waveProcess * mHeartRect.width();//当前X轴方向上 波浪偏移量

        canvas.clipPath(mMainPath);
        canvas.save();

        mainRect = new Rect();
        ... 省略无关代码

        // 上波浪区域
        resetWavePath(true, mHeartRect, growProcess, topWavePath);
        canvas.translate(curXOffset, 0);
        canvas.clipPath(topWavePath);
        canvas.drawPath(topWavePath, mTopPaint);
        ... 省略无关代码
      
        //下波浪区域
        resetWavePath(false, mHeartRect, growProcess, bottomWavePath);
        canvas.restore();
        canvas.translate(curXOffset, 0);
        canvas.clipPath(bottomWavePath);
        canvas.drawPath(bottomWavePath, mBottomPaint);
       ... 省略无关代码

    }

4步:绘制”一条大灰狼“ 到心形中央,并且达成双色效果

这里有两个细节:
1.canvas.drawText, 就算你把paint 设置了.setTextAlign(Paint.Align.CENTER); 它也未必会在你给的x,y为中心 绘制。原因就不解释了,谷歌大佬就是这么设计的。
解决方法:利用paint.getTextBounds,获得文字的矩形区域。然后在真正canvas.drawText,计算y的时候考虑这个矩形区域,就像下面这样如下

        mainRect = new Rect();
        textBottomPaint.getTextBounds(text, 0, text.length(), mainRect);

2. 由于之前波浪的横向移动,坐标轴产生了平移,所以我绘制文字,要将平移的距离减去,再绘制,保证居中,且文字位置不随着波浪的横向移动而变化。

完整代码如下(此步骤的关键代码已经标红):
image.png

结语

来解答乍一看里面提出的3个问题:

诶?心形是怎么绘制的?
答:构建Path,然后canvas.clipPath裁剪画布,裁剪之后,所有的作图效果就只在这个心形区域内可见

诶?波浪是怎么画出来的,又是如何动起来的?
答:波浪,或者说波浪区域,也是Path构建,主要由一根波浪线以及三根直线组成,是一个封闭区域.
让波浪动起来,其实就是 canvas平移操作,利用属性动画+双倍宽度的波浪区域,形成无缝无限循环动画.

诶? 文字是怎么呈现出同一时刻的两种颜色的?
答:在两个相邻的波浪区域,使用不一样的颜色绘制两次文字。视觉效果上还是一串文字,但是实际上是两次绘制的组合效果。

欧了,很简单的一个特效,当然,对于懂的人来说很简单,对于知识不健全的人来说,难如登天。有了这个基础,无论什么复杂特效,都能做到胸有成竹。 真的欧了
~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,591评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,448评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,823评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,204评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,228评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,190评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,078评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,923评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,334评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,550评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,727评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,428评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,022评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,672评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,826评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,734评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,619评论 2 354

推荐阅读更多精彩内容