之前阅读网上的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。
199行的 DarkToggleButton.java :
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上,然后弄个付费资源什么的,但是实名制弄不了,就先发在这里吧,也好与道友交流交流。