一、前言
电视剧《点亮你,温暖我》中有颗心跳动画,当时在程序员群体中引发了很多热议,昨天也看到掘金博客上有人使用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闭合空间,我在以往的文章中多次强调闭合空间,主要原因是只有闭合空间才能贴图和绘制纹理,因此也需要时刻记住这个特性。