Android 心跳效果

一、前言

电视剧《点亮你,温暖我》中有颗心跳动画,当时在程序员群体中引发了很多热议,昨天也看到掘金博客上有人使用css+javascript的实现版本,然后想了一下使用Android Canvas实现的效果,当然整体是实现出来了,但是在渲染和粒子分布这里没有找到更好的方法,渲染不分有个BlendMode,只有Android Q才有,因此渲染不出叠加效果,这点相比来说JetPack Compose也比Android领先的多,另外一个问题是粒子分布,这个需要设计一个算法,从外到内粒子减少的逻辑,理论上线性 扩散范围R*Math.cos(t) 即可,时间原因,没有设计,用了随机位置点渲染,总体上差点意思。

二、实现

当然本文的主要是介绍怎么使用Canvas 绘制图形,在这个过程中也有些知识点,希望能帮助到大家。

2.1 利用数学模型绘图

我们日常使用的Canvas Api 、Path、Shader、Xfermode只能绘制简单的图形,复杂图形就只能使用数学模型,之前有一篇《Android Canvas 3D视图构建》就使用了数学模型,因此在构建复杂图形,除了贝塞尔曲线,建议在graphtoy.com/ 多多研究一下,这个网站提供了很多函数的运动轨迹,通过下面的代码绕一周就能构建一个Path,有Path表明我们已经得到了矢量图形,后续只需要缩放即可,另外还可以辅助对区域进行裁剪,这样可以帮我们省去很多事。

css 复制代码
        double dt = 2 * Math.PI / pointNum;  //用200个点平分整个圆的弧度
        for (int i = 0; i < pointNum; i++) {
            // 每个点的旋转觉就是 i*dt
            float x = (float) (16 * Math.pow(Math.sin(i * dt), 3));
            float y = -(float) (13 * Math.cos(i * dt) - 5 * Math.cos(2 * i * dt) - 2 * Math.cos(3 * i * dt) - Math.cos(4 * i * dt));
            if (i == 0) {
                oriPath.moveTo(x, y);
            } else {
                oriPath.lineTo(x, y);
            }
        }

补充graphtoy网站截图

2.2 利用Matrix转换Path

Android 中有两种Matrix ,其中一种是open gl 的,本文用不到,我们用另一个Matrix

arduino 复制代码
matrix.setScale(scale, scale);
//矩阵转换oriPath.transform(matrix, transformPath);  
//计算出图形区域的Rect,方便我们后续进行粒子布置
transformPath.computeBounds(rect,false); 

Matrix Scale实际上乘了个矩阵,Matrix的postXXX\preXXX\setXXX 操作都是矩阵乘法,而不是矩阵乘以向量,这点一定要明白。Matrix默认是单位阵,然后setScale(scaleX,ScaleY)是乘以缩放矩阵。

csharp 复制代码
[1,    0 ,0]
[0,    1 ,0]
[0,    0  ,1]  乘以

[scaleX ,0     ,0]
[0,     scaleY ,0]
[0,     0      ,1]

2.3 粒子点分布算法

本篇采用的算法比较简单,分布可能没有那么好

ini 复制代码
       transformPath.computeBounds(rect,false);
//计算出半径,这里我们取最大半径,防止粒子覆盖不到范围        float dotRadius = Math.max(rect.width(), rect.height()) / 2L;

        for (int i = 0; i < 600; i++) {
            float radian = (float) (random.nextFloat() * 2 * Math.PI);
            float r = (float) dotRadius * random.nextFloat() * 3 / 5 + radius * 2 / 5;
            float dx = (float) (r * Math.cos(radian));
            float dy = (float) (r * Math.sin(radian));
            dotsPath.addCircle(dx, dy, 2, Path.Direction.CCW);  
 //着CCW和CW都行,顺时针逆时针影响不大
        }
        for (int i = 0; i < 300; i++) {
            float radian = (float) (random.nextFloat() * 2 * Math.PI);
            float r = (float) dotRadius * random.nextFloat();
            float dx = (float) (r * Math.cos(radian));
            float dy = (float) (r * Math.sin(radian));
            dotsPath.addCircle(dx, dy, 2, Path.Direction.CCW);
        }

2.4 区域裁剪

我们2.3绘制的点在一个圆范围内,但是我们只要"心"内部,同时使用INTERSECT,取出点和心型的重合区域,这样就能实现将所有点析出到心型内部。

scss 复制代码
transformPath.op(dotsPath, Path.Op.INTERSECT);
//绘制合成的点
canvas.drawPath(transformPath, mCommonPaint);

2.5 设置BlendMode

没办法,低版本不支持,具体效果我也没看

scss 复制代码
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {    
   mCommonPaint.setBlendMode(BlendMode.DARKEN);
}

2.6 描边

实际上这里是本文第二个问题点,描边没有做线性随机,因此缺点比较大,我们期望的算法越靠近中心点粒子数越少才对。

ini 复制代码
matrix.reset();
matrix.setScale(scale -  0.5f, scale -  0.5f);
oriPath.transform(matrix, transformPath);  //再次用原有Path缩放
pathMeasure.setPath(transformPath, true);
int dotNum = 200;
float fragmentLength = pathMeasure.getLength() / dotNum;
for (int i = 0; i < dotNum; i++) {    
     pathMeasure.getPosTan(fragmentLength * i, pos,null);   
     canvas.drawPoint(pos[0], pos[1], mCommonPaint);    
   for (int j = 0; j < 5; j++) {        
     canvas.drawCircle(pos[0] + random.nextFloat() * 10 * (random.nextBoolean() ? 1 : -1), pos[1] + random.nextFloat() * 15 * (random.nextBoolean()? 1 : -1), 2f, mCommonPaint);    
  }    
  canvas.drawCircle(pos[0], pos[1], 2f, mCommonPaint);
}

当然,这里还有另一个知识点,PathMeasure分割

ini 复制代码
float fragmentLength = pathMeasure.getLength() / dotNum;

我们可以对PathMeasure进行分割,这样我们可以减少描点。

当然,这个描边虽然不及预期,但是可以运用到喷涂效果,后续有时间的话写一篇手写板的文章。

2.7 增加动画

本部分核心是颜色渐变算法,这个之前好几篇都写过,可以多看以往的文章

scss 复制代码
int alpha = (int) (Color.alpha(COLOR_TABLE[nextIndex]) * animatedFraction + Color.alpha(COLOR_TABLE[colorIndex]) *(1-animatedFraction));
int red = (int) (Color.red(COLOR_TABLE[nextIndex]) * animatedFraction + Color.red(COLOR_TABLE[colorIndex]) *(1-animatedFraction));
int green = (int) (Color.green(COLOR_TABLE[nextIndex]) * animatedFraction + Color.green(COLOR_TABLE[colorIndex]) *(1-animatedFraction));
int blue = (int) (Color.blue(COLOR_TABLE[nextIndex]) * animatedFraction + Color.blue(COLOR_TABLE[colorIndex]) *(1-animatedFraction));
color = Color.argb(alpha,red,green,blue);

以上属于核心逻辑

2.8 全部代码

总体不到250行

ini 复制代码
public class HeartBeatView extends View {
    private final DisplayMetrics mDM;
    private TextPaint mCommonPaint;
    Path oriPath = new Path();
    Path transformPath = new Path();
    Path dotsPath = new Path();
    PathMeasure pathMeasure = new PathMeasure();
    float[] pos = new float[2];
    int pointNum = 200;
    Matrix matrix = new Matrix();
    RectF rect = new RectF();
    private float padding;
    private ValueAnimator animator;
    private float scale = 17;
    private Random random = new Random();

    private int[] COLOR_TABLE = new int[]{0xFFE91E63, 0xFF00ACC1, 0XFF00ACC1};
    private int colorIndex = 0;
    private int nextIndex = 0;
    private int color = COLOR_TABLE[colorIndex];

    public HeartBeatView(Context context) {
        this(context, null);
    }

    public HeartBeatView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public HeartBeatView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDM = getResources().getDisplayMetrics();
        initPaint();
    }

    private void initPaint() {
        //否则提供给外部纹理绘制
        mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mCommonPaint.setAntiAlias(true);
        mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
        padding = dp2px(20);

        startHeartBeat();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDM.widthPixels / 2;
        }
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();
        if (width < padding || height < padding) {
            return;
        }
        mCommonPaint.setColor(color);

        float radius = Math.min(width / 2f, height / 2f) - padding;
        int save = canvas.save();
        canvas.translate(width / 2f, height / 2f);
        //  mCommonPaint.setStyle(Paint.Style.STROKE);
        // canvas.drawCircle(0,0,radius,mCommonPaint);
        mCommonPaint.setStyle(Paint.Style.FILL);

        oriPath.reset();
        dotsPath.reset();
        transformPath.reset();
        mCommonPaint.setStrokeWidth(dp2px(.5f));

        double dt = 2 * Math.PI / pointNum;
        for (int i = 0; i < pointNum; i++) {
            float x = (float) (16 * Math.pow(Math.sin(i * dt), 3));
            float y = -(float) (13 * Math.cos(i * dt) - 5 * Math.cos(2 * i * dt) - 2 * Math.cos(3 * i * dt) - Math.cos(4 * i * dt));
            if (i == 0) {
                oriPath.moveTo(x, y);
            } else {
                oriPath.lineTo(x, y);
            }
        }

        oriPath.close(); //构建闭合空间

        matrix.reset();
        //将原始图像放大scale倍
        matrix.setScale(scale, scale);
        oriPath.transform(matrix, transformPath);  //矩阵转换
        transformPath.computeBounds(rect, false);

        float dotRadius = Math.max(rect.width(), rect.height()) / 2L;
        for (int i = 0; i < 600; i++) {
            float radian = (float) (random.nextFloat() * 2 * Math.PI);
            float r = (float) dotRadius * random.nextFloat() * 3 / 5 + radius * 2 / 5;
            float dx = (float) (r * Math.cos(radian));
            float dy = (float) (r * Math.sin(radian));
            dotsPath.addCircle(dx, dy, 2, Path.Direction.CCW);
        }
        for (int i = 0; i < 300; i++) {
            float radian = (float) (random.nextFloat() * 2 * Math.PI);
            float r = (float) dotRadius * random.nextFloat();
            float dx = (float) (r * Math.cos(radian));
            float dy = (float) (r * Math.sin(radian));
            dotsPath.addCircle(dx, dy, 2, Path.Direction.CCW);
        }


        transformPath.op(dotsPath, Path.Op.INTERSECT);
        //绘制合成的点
        canvas.drawPath(transformPath, mCommonPaint);


        mCommonPaint.setStyle(Paint.Style.FILL);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            mCommonPaint.setBlendMode(BlendMode.DARKEN);
        }

        transformPath.reset();
        matrix.reset();
        matrix.setScale(scale - 0.5f, scale - 0.5f);
        oriPath.transform(matrix, transformPath);
        pathMeasure.setPath(transformPath, true);
        int dotNum = 200;
        float fragmentLength = pathMeasure.getLength() / dotNum;

        for (int i = 0; i < dotNum; i++) {
            pathMeasure.getPosTan(fragmentLength * i, pos, null);
            canvas.drawPoint(pos[0], pos[1], mCommonPaint);
            for (int j = 0; j < 5; j++) {
                canvas.drawCircle(pos[0] + random.nextFloat() * 10 * (random.nextBoolean() ? 1 : -1), pos[1] + random.nextFloat() * 15 * (random.nextBoolean() ? 1 : -1), 2f, mCommonPaint);
            }
            canvas.drawCircle(pos[0], pos[1], 2f, mCommonPaint);
        }
        canvas.restoreToCount(save);

    }

    public void startHeartBeat() {

        if (animator != null) {
            animator.cancel();
        }

        ValueAnimator valueAnimator = ValueAnimator.ofFloat(15, 14, 15, 19, 19, 15);
        valueAnimator.setStartDelay(1000);
        valueAnimator.setDuration(800);
        valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
        valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                scale = (float) animation.getAnimatedValue();

                float animatedFraction = animation.getAnimatedFraction();

                if (colorIndex != nextIndex && COLOR_TABLE.length > 1) {
                    int alpha = (int) (Color.alpha(COLOR_TABLE[nextIndex]) * animatedFraction + Color.alpha(COLOR_TABLE[colorIndex]) * (1 - animatedFraction));
                    int red = (int) (Color.red(COLOR_TABLE[nextIndex]) * animatedFraction + Color.red(COLOR_TABLE[colorIndex]) * (1 - animatedFraction));
                    int green = (int) (Color.green(COLOR_TABLE[nextIndex]) * animatedFraction + Color.green(COLOR_TABLE[colorIndex]) * (1 - animatedFraction));
                    int blue = (int) (Color.blue(COLOR_TABLE[nextIndex]) * animatedFraction + Color.blue(COLOR_TABLE[colorIndex]) * (1 - animatedFraction));
                    color = Color.argb(alpha, red, green, blue);
                } else if (COLOR_TABLE.length > 0) {
                    color = COLOR_TABLE[0];
                }

                postInvalidate();
            }
        });
        valueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationRepeat(Animator animation) {
                if (COLOR_TABLE.length > 1) {
                    colorIndex++;
                    colorIndex = colorIndex % COLOR_TABLE.length;
                    nextIndex = colorIndex + 1;
                    nextIndex = nextIndex % COLOR_TABLE.length;
                } else {
                    colorIndex = nextIndex = 0;
                }
            }
        });
        valueAnimator.start();
        animator = valueAnimator;

    }

    public float dp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
    }

    public float sp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
    }

}

三、总结

本篇利用了数学模型构建Path,然后Path裁剪、合成、矢量性质,Matrix转换,圆分割、PathMeasure分割等技术点,虽然代码不多,但是很多点考察了对canvas 绘制的高级知识。当然还有Path闭合空间,我在以往的文章中多次强调闭合空间,主要原因是只有闭合空间才能贴图和绘制纹理,因此也需要时刻记住这个特性。

相关推荐
此生只爱蛋16 分钟前
【手撕排序2】快速排序
c语言·c++·算法·排序算法
bysking32 分钟前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
咕咕吖1 小时前
对称二叉树(力扣101)
算法·leetcode·职场和发展
王哲晓1 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
萌面小侠Plus1 小时前
Android笔记(三十三):封装设备性能级别判断工具——低端机还是高端机
android·性能优化·kotlin·工具类·低端机
慢慢成长的码农1 小时前
Android Profiler 内存分析
android
大风起兮云飞扬丶1 小时前
Android——多线程、线程通信、handler机制
android
fg_4111 小时前
无网络安装ionic和运行
前端·npm
理想不理想v1 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
L72561 小时前
Android的Handler
android