巧用onDraw与postInvalidateOnAnimation,自定义绘制动画

1,064 阅读4分钟

之前阅读网上的github周刊的时候,逛到月亮变成太阳、太阳变成月亮的动效,十分有趣,就把那篇文章收藏了。(读报纸的感觉很喜欢)

后来查看,发现竟然不是Java,而是基于 Jetpack Compose,语言是kotlin。不过该项目原本是也由一个react项目启发,既然转成了kotlin,那么没道理不能转成Java。

所以立即动手。Java可以在onDraw方法中实现自绘动画(Jetpack Compose也是这么做的吧,不是凭空而来),怎么画都可以,安卓绘图API的底层是skia,很强大。

但是,怎么让图画“动”起来呢? 这个嘛,当然是用某种延时、定时机制,不止安卓,每个平台有多种延时方案。比如 Win32 的 SetTimer,你也可以小题大做地开多线程(应该不会真的有人这么做吧),Javascript 有 setInterval、setTimeout、requestAnimationFrame,安卓则有 post、postDelayed、postInvalidate、postInvalidateDelayed、postInvalidateOnAnimation

为什么有这么多接口呢?当然是为了性能。不同需求场景需要不同的性能。动画呢,就是特效而已,需要流畅,更需要节能与环保,拿 Javascript 来说,用间隔时间很小的定时器更新界面,渐渐地就会挤占CPU,造成发热与卡顿,这个时候就可以试着用 requestAnimationFrame,兼顾流畅与性能。类似地,对于安卓来说也可以用 postInvalidateOnAnimation 替换普通的 invalidate,在onDraw之中循环调用,一直到动画结束,想必性能更高。

这里稍微提一下著名的 SubsamplingScaleImageView,这一用来加载大图的纯Java安卓组件,就是用onDraw + invalidate 实现惯性滑动的。但是呢,惯性滑动建议在 computeScroll 方法下处理,对于搭载低效能芯片的手机来说有着奇效。(有人把它转成了Kotlin,实为可耻,kt那么牛逼却导处搞破坏、撕裂Java社区、助长内卷而无实质性提升

贴一下基础版本的代码吧,但此处只有月亮变成太阳、太阳变成月亮的动效(行数少,注释多,方便你理解),后面俺还配合了偷师HTML webgl的云层特效、偷师 HTML canvas的星空特效,三相组合,才合成俺APP夜间模式的切换动画,HTML技术栈高山仰止,但偷师也容易,除非是那魔法一般的CSS。

Screenshot_20211225_160831_com.jpg

199行的 DarkToggleButton.java :

c04bc2b2738e4e088368f97b92d22783.gif

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PaintFlagsDrawFilter;
import android.graphics.Path;
import android.graphics.Region;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Interpolator;

import androidx.annotation.Nullable;
import androidx.appcompat.app.GlobalOptions;

public class DarkToggleButton extends View {
	/** 画布旋转角度 */
	private final static int rotation=0;
	/** 月蚀面X轴偏移比例 */
	private final static int maskCxRatio=1;
	/** 月蚀面Y轴偏移比例 */
	private final static int maskCyRatio=2;
	/** 月蚀面半径比例 */
	private final static int maskRadiusRatio=3;
	/** 日月面半径比例 */
	private final static int circleRadiusRatio=4;
	/** 动画属性数量。上面5个个变量 + 太阳8方圆点的画布缩放数值 */
	private final static int animatePropsCnt = 5 + 8;
	/** 太阳周围旋绕8方圆点,代表光芒。 */
	private final static int SurroundCircleNum = 8;
	/** 动画属性数组,存储当前状态的变量,等于|切换前|与|切换后|变量之间的插值。 */
	private final float[] values = new float[animatePropsCnt];
	/** 动画属性数组,存储切换后的变量。 */
	private final float[] targetValues = new float[animatePropsCnt];
	/** 动画属性数组,存储切换前的变量。 */
	private final float[] lastValues = new float[animatePropsCnt];
	/** 表示正在切换为或已切换至太阳状态。 */
	private boolean stateIsSun;
	
	/** 日月面最大直径(包围盒的宽度和高度) */
	private int size;
	private Paint paint;
	//Paint mskPaint;
	private Path mskPath = new Path();
	
	/** 弹簧插值器。
	 * https://blog.csdn.net/l474297694/article/details/79916864
	 * http://inloop.github.io/interpolator/
	 * pow(2, -10 * x) * sin((x - factor / 4) * (2 * PI) / factor) + 1 */
	public static class SpringInterpolator implements Interpolator {
		private float factor;
		public SpringInterpolator(float factor) {
			this.factor = factor;
		}
		@Override
		public float getInterpolation(float input) {
			return (float) (Math.pow(2, -10 * input) * Math.sin((input - factor / 4) * (2 * Math.PI) / factor) + 1);
		}
	}
	
	/** 插值器 */
	Interpolator interpolator = new SpringInterpolator(0.9f);
	float progress;
	boolean animating;
	long animatorTime;
	long duration = 1500;
	
	public DarkToggleButton(Context context) {
		this(context, null);
	}
	
	public DarkToggleButton(Context context, @Nullable AttributeSet attrs) {
		super(context, attrs);
		paint = new Paint(Paint.ANTI_ALIAS_FLAG);
		paint.setColor(Color.YELLOW);
		paint.setFilterBitmap(true);
		
		// 若用 mskPaint 清除绘制的月面显示出月牙,则没有 clipPath 锯齿缺点,但是需要给视图关闭硬件加速。
		//mskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
		//mskPaint.setColor(Color.BLACK);
		//mskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); // need to use software layer type.
		
		// 初始化数值
		stateIsSun = true;
		values[rotation] = 180;
		values[maskCxRatio] = 1;
		values[maskCyRatio] = 0;
		values[maskRadiusRatio] = 0.125f;
		values[circleRadiusRatio] = 0.2f;
		for (int i = 0; i < 8; i++) {
			values[5+i] = 1;
		}
	}
	
	/** 切换日月状态,并开始播放动画。 */
	public void toggle() {
		stateIsSun = !stateIsSun;
		// 根据日月状态,设置插值终点。
		if (stateIsSun) {
			targetValues[rotation] = 180;
			targetValues[maskCxRatio] = 1;
			targetValues[maskCyRatio] = 0;
			targetValues[maskRadiusRatio] = 0.125f;
			targetValues[circleRadiusRatio] = 0.2f;
		} else {
			targetValues[rotation]        =  45;
			targetValues[maskCxRatio]     =  0;
			targetValues[maskCyRatio]     =  -0.32f;
			targetValues[maskRadiusRatio] =  0.35f;
			targetValues[circleRadiusRatio] = 0.35f;
		}
		float val = stateIsSun?1:0;
		for (int i = 0; i < 8; i++) {
			targetValues[5+i] = val;
		}
		// 拷贝当前状态数值,作为插值起点。
		System.arraycopy(values, 0, lastValues, 0, animatePropsCnt);
		animating=true;
		animatorTime = System.currentTimeMillis();
		invalidate();
	}
	
	PaintFlagsDrawFilter mSetfil = new PaintFlagsDrawFilter(0, Paint.FILTER_BITMAP_FLAG);
	
	@Override
	protected void onDraw(Canvas canvas) {
		super.onDraw(canvas);
		canvas.drawColor(Color.BLACK);
		size = (int) (GlobalOptions.density * 200);
		int R = size/2;
		if(R==0) R=getWidth()/2;
		int CX = (int) (getWidth()*0.5f);
		int CY = (int) (getHeight()*0.5f);
		canvas.save();
		canvas.rotate(values[rotation], CX, CY); //旋转画布
		canvas.setDrawFilter( mSetfil );
		
		canvas.save();
		mskPath.reset();
		mskPath.addCircle(CX+R*values[maskCxRatio], CY+R*values[maskCyRatio], R*values[maskRadiusRatio], Path.Direction.CCW);
		canvas.clipPath(mskPath, Region.Op.DIFFERENCE); // 月蚀,遮挡月面显示出月牙。clipPath 有锯齿。
		// 绘制日月圆面
		canvas.drawCircle(CX, CY, R*values[circleRadiusRatio], paint);
		canvas.restore();
		// 绘制月蚀,遮挡月面显示出月牙。使用 PorterDuff.Mode.CLEAR 需关闭硬件加速。
		//canvas.drawCircle(CX+R*values[maskCxRatio], CY+R*values[maskCyRatio], R*values[maskRadiusRatio], paint);
		
		if (animating) {
			handleAnimation();
		}
		
		// 绘制太阳的8方圆点
		float SurroundCircleScale;
		for (int i = 0; i < SurroundCircleNum; i++) {
			SurroundCircleScale = values[i+5];
			if(SurroundCircleScale<0.2) break;
			canvas.save();  // 8方太阳圆点,次第缩放画布
			canvas.scale(SurroundCircleScale, SurroundCircleScale, CX, CY);
			float radians = (float) (Math.PI / 2 - i * 2 * Math.PI / SurroundCircleNum);
			float d = R / 3;
			float cx = (float) (CX + d * Math.cos(radians));
			float cy = (float) (CY - d * Math.sin(radians));
			canvas.drawCircle(cx, cy, R*0.05f, paint);
			canvas.restore();
		}
		canvas.restore();
	}
	
	/** 处理动画属性 */
	private void handleAnimation() {
		long now = System.currentTimeMillis();
		long elapsed = now - animatorTime;
		if(elapsed>=duration) animating = false;
		progress = Math.min(1, elapsed*1.f/duration);
		float interp = interpolator.getInterpolation(progress);
		boolean toSun = stateIsSun;
		// 5个动画属性的interp值是一致的,若正在切换为月亮,则8方圆点也使用一致的interp值。
		for (int i = 0; i < 5+(toSun?0:8); i++) {
			// 计算插值。
			values[i] =  (1-interp)*lastValues[i] + interp*targetValues[i];
		}
		if (toSun) {
			int idx;
			// 8方太阳圆点,用delayUnit次第缩放画布,分别计算interp值。
			float delayUnit = Math.max(5, Math.min(50, 1500f * 0.067f + 55)); // SpringForce.STIFFNESS_MEDIUM 等于 1500f
			for (int i = 0; i < 8; i++) {
				idx = 5+i;
				// 计算插值。
				interp = interpolator.getInterpolation(Math.max(0, Math.min(1, (now-animatorTime-delayUnit*i)*1.f/duration)));
				values[idx] =  Math.min(1, (1-interp)*lastValues[idx] + interp*targetValues[idx]);
			}
		}
		if (animating) {
			// onDraw中触发重绘。应该比直接invalidate更节省性能,类比于js的requestAnimationFrame和setTimeout。
			postInvalidateOnAnimation();
		}
	}
}

(复制后直接往Android Studio的目录树粘贴即可)

本来想发在csdn上,然后弄个付费资源什么的,但是实名制弄不了,就先发在这里吧,也好与道友交流交流。

github