篇章目标介绍
之前看到网易云,酷我音乐都发布过用于播放器页面粒子动效的效果,于是打算自己也动手做一个,产品目标是对标酷我手机app的动效设计,实现过程完全基于自身的推测理解予以实现。计划通过两次完全完成这个项目,第一篇文章重点介绍粒子动效实现的核心问题和完成效果的主要代码介绍;计划在第二篇文章针对粒子动效的资源占用进行优化和完善UI展示效果。本文是第一篇文章
粒子动效核心问题
粒子动效的是需要模拟太阳光扩散的效果,要体现粒子运动的规律和源源不断的粒子发散的效果。要达成目标,重点需要认识以下4个问题
问题1:移动方向
如下图示意,粒子的运动方向需要按照法线方向向外扩散,且扩散过程中,粒子的透明度alpha呈现扩散趋势。需要在粒子信息中记录粒子半径,圆形x坐标,圆形y坐标,透明度alpha,运动速度
问题2:粒子分布
粒子需要较为均匀的从光源外圈发射,但是粒子分布又不宜绝对均匀分布,要体现一定随机性。因此基本思路是等分圆弧角度,在划定弧度范围内释放一组粒子,粒子位置在弧度范围内随机。如果希望粒子密度增大,只需增加一组粒子的个数值即可。这样即可兼顾均匀分布性和随机性两个特征要求
问题3:移动速度
为了保证同一时间发射的粒子扩散范围具有一定相关性,因为速度随机值差异不宜太大。速度的计算容易理解,但是执行过程中移动过程中速度的概念容易被误解,比如误将x向移动量等效为速度,这种错误等效会导致粒子扩散过程无法保持粒子星云效果,速度应当是x向和y向变化量的合成值。计算如下:
问题4:粒子补充
粒子补充是模拟太阳光发射需要考虑的一个现象,即光源是源源不断的在发射光子,而非待粒子回收到一定程度才补充。这个是容易走错的误区。
其他诸如粒子超出边界回收属于常规手段,在此不重点介绍
实现效果
效果图1
效果图2
效果图3
主要源码介绍
源码包括数据对象和绘制逻辑两部分构成。
数据实体
首先是粒子和气泡的数据对象,因为其本质都是画圆,因此定义了一个抽象接口用于粒子和气泡实现。
/**
* 用于生产圆形的工厂接口类,气泡/粒子均实现此接口
*/
public interface CircleFactory<T extends CircleFactory> {
//设置圆形x坐标
T setX(int x);
//设置圆形y坐标
T setY(int y);
//构建形状
T build();
//改变形状信息
void change();
//从集合中移除形状
void remove();
}
下边是粒子的数据实体,主要包括粒子的信息构成,粒子信息更新(坐标和透明度更新),粒子超出边界移除
/**
* 粒子信息由半径,x坐标,y坐标,alpha透明度,velocity移动速度构成
* 半径范围控制在1~2以内
* alpha呈现减少趋势
*/
private class Particle implements CircleFactory<Particle>{
private int r;
private int x;
private int y;
private int alpha;
private int velocity;
//斜率
private float slope;
//方向,x向增加为1,x向减小为-1
private int direction;
//粒子寿命,velocity降为0或者超出粒子云外径视为寿命终结
private int age;
@Override
public Particle setX(int x) {
this.x = x;
return this;
}
@Override
public Particle setY(int y) {
this.y = y;
return this;
}
public int getR() {
return r;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public int getAlpha() {
return alpha;
}
public float getSlope() {
return slope;
}
public int getDirection() {
return direction;
}
public int getVelocity() {
return velocity;
}
public int getAge() {
return age;
}
public void setR(int r) {
this.r = r;
}
public void setAlpha(int alpha) {
this.alpha = alpha;
}
public void setVelocity(int velocity) {
this.velocity = velocity;
}
public void setSlope(float slope) {
this.slope = slope;
}
public void setDirection(int direction) {
this.direction = direction;
}
public void setAge(int age) {
this.age = age;
}
@Override
public Particle build() {
r = mRandomInt.nextInt(1) + 1;
alpha = 255;
velocity = mRandomInt.nextInt(3) + 3;
slope = 1.0000f*(y - height/2) / (x - width/2);
direction = (x >= width/2 ? 1 : -1);
return this;
}
/**
* 改变该粒子的x,y坐标
*/
@Override
public void change() {
float sqrt = (float) (1.0000f / Math.sqrt(1 + Math.pow(slope, 2)));
x += direction*velocity *sqrt;
y += direction*velocity*slope *sqrt;
alpha -=30;
ageEnd();
}
/**
* 粒子寿命终结时移除
*/
public void ageEnd(){
boolean exceedingBoarder = (Math.pow(x - width/2, 2) + Math.pow(y - height/2, 2) - Math.pow(mOutSideStarRadius, 2) >= 0.001f);
boolean moveState = (velocity > 0);
boolean visible = (alpha > 0);
if(exceedingBoarder | !moveState | !visible){
remove();
}
}
@Override
public void remove(){
mParticleList.remove(this);
}
}
气泡数据实体因为与粒子基本相同,只是信息更新和移除有所差异,其思路是继承粒子实体,重写更新和移除方法
/**
* 气泡的整体元素复用粒子,差异点在于透明度偏低,半径较大,调整速度较慢
*/
private class Bubble extends Particle{
@Override
public Bubble build() {
setR(mRandomInt.nextInt(10) + 5);
setAlpha(100);
setVelocity(mRandomInt.nextInt(2) + 2);
setSlope(1.0000f*(getY() - height/2) / (getX() - width/2));
setDirection(getX() >= width/2 ? 1 : -1);
return this;
}
@Override
public void change() {
float sqrt = (float) (1.0000f / Math.sqrt(1 + Math.pow(getSlope(), 2)));
setX((int)(getX() + getDirection() * getVelocity() * sqrt));
setY((int)(getY() + getDirection() * getVelocity() * getSlope() * sqrt));
setAlpha(getAlpha() - 10);
ageEnd();
}
@Override
public void remove() {
mBubbleList.remove(this);
}
}
主要逻辑
主要逻辑即是围绕上述的4个核心问题展开
首先是测量,估算计划投放的粒子组数量。然后初始化粒子群和气泡群对象
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
width = getMeasuredWidth();
height = getMeasuredHeight();
//计算流星最外轨道半径
mOutSideStarRadius = Math.min(width, height) / 2 * 9 / 10;
//计算中心原图的半径
mInsideImageRadius = mOutSideStarRadius * 2 / 3;
//计算适合的粒子群个数
mAlbumCircleRadius = (mInsideImageRadius + 20) < mOutSideStarRadius ? (mInsideImageRadius + 20) : (mInsideImageRadius + (int)mCirclePaint.getStrokeWidth()/2);
//平均间隔10个像素的圆环分配一个粒子组
PARTICLE_GROUP_SIZE = (int)(mAlbumCircleRadius * 2 *Math.PI / 10);
//初始化粒子群
initParticleGroup(PARTICLE_GROUP_SIZE);
//初始化气泡群
initBubbleGroup(PARTICLE_GROUP_SIZE / 60);
}
粒子群的初始化,位置更新的逻辑如下
//添加粒子群
private void initParticleGroup(int size){
//每一个起始的粒子占据的角度范围
float angleInterval = 1.0f * 360 / size;
//粒子所处角度
float angle;
for(int i = 0; i < size; i++){
for(int j = 0; j < PARTICLE_SIZE; j++){
angle = (i * angleInterval + mRandomInt.nextInt(10)*angleInterval / 10)*1.0f;
int x = (int)(Math.cos(2 * Math.PI * angle / 360) * mAlbumCircleRadius) + width/2;
int y = (int)(Math.sin(2 * Math.PI * angle / 360) *mAlbumCircleRadius) +height/2;
Particle particle = new Particle().setX(x).setY(y).build();
mParticleList.add(particle);
}
}
}
/**
* 绘制粒子群
* @param canvas 画布
*/
private void drawParticleGroup(Canvas canvas){
for(int i = 0; i < mParticleList.size(); i++){
Particle particle = mParticleList.get(i);
mParticlePaint.setAlpha(particle.getAlpha());
canvas.drawCircle(particle.getX(), particle.getY(), particle.getR(), mParticlePaint);
}
}
/**
* 改变粒子群信息
*/
private void changeParticleGroup(){
for(int i = 0; i < mParticleList.size(); i++){
mParticleList.get(i).change();
}
//调整中心图片的旋转角度,设置进行逆时针旋转
mRotateAngle -= 2;
mRotateAngle = (mRotateAngle + 360) % 360;
invalidate();
}
气泡群的初始化/更新的逻辑如下
/**
* 添加气泡群
*/
private void initBubbleGroup(int size){
//每一个起始的气泡占据的角度范围
float angleInterval = 1.0f * 360 / size;
//气泡所处角度
float angle;
for(int i = 0; i < size; i++){
angle = (i * angleInterval + mRandomInt.nextInt(10)*angleInterval / 10)*1.0f;
int x = (int)(Math.cos(2 * Math.PI * angle / 360) * mAlbumCircleRadius) + width/2;
int y = (int)(Math.sin(2 * Math.PI * angle / 360) *mAlbumCircleRadius) +height/2;
Particle particle = new Bubble().setX(x).setY(y).build();
mBubbleList.add(particle);
}
}
/**
* 改变气泡群信息
*/
private void changeBubbleGroup(){
for(int i = 0; i < mBubbleList.size(); i++){
mBubbleList.get(i).change();
}
}
private void drawBubbleGroup(Canvas canvas){
for(int i = 0; i < mBubbleList.size(); i++){
Particle particle = mBubbleList.get(i);
mBubblePaint.setAlpha(particle.getAlpha());
canvas.drawCircle(particle.getX(), particle.getY(), particle.getR(), mBubblePaint);
}
}
最后一步是绘制的业务实现,需要完成5个工作,(1)绘制中间的圆形专辑图并且实现逆时针旋转;(2)绘制专辑外侧的光晕圆形;(3)绘制粒子群;(4)绘制气泡群;(5)定义刷新View(粒子和气泡变更状态,专辑图旋转)。其中生成圆形专辑图上一篇流星动效的文章已经介绍,本次不再提供。
@Override
protected void onDraw(Canvas canvas) {
//设置画布透明
canvas.drawARGB(0,0,0,0);
//绘制中间的圆形图片
Drawable drawable = getDrawable();
if(null == drawable){
return;
}
//将ImageView的原图裁剪成圆形图片
Bitmap bitmap = ((BitmapDrawable)drawable).getBitmap();
Bitmap roundBitmap = RoundBitmapUtil.createRoundBitmap(bitmap, mInsideImageRadius);
//通过Matrix设置圆形Bitmap旋转
mMatrix.reset();
mMatrix.setRotate(mRotateAngle);
//获取旋转后的Bitmap
Bitmap rotateBitmap = Bitmap.createBitmap(roundBitmap, 0, 0, 2*mInsideImageRadius, 2*mInsideImageRadius, mMatrix, false);
//在画布上绘制旋转后的Bitmap,注意基于Matrix旋转后的Bitmap与原图的大小并不相等,故计算中心位置时应以转换后的Bitmap进行计算
canvas.drawBitmap(rotateBitmap, width / 2 - rotateBitmap.getWidth()/2 , height / 2 - rotateBitmap.getHeight()/2, null);
//提取主题色
int colorTheme = BitmapColorUtil.extractColor(bitmap);
mCirclePaint.setColor(colorTheme);
mParticlePaint.setColor(colorTheme);
mBubblePaint.setColor(colorTheme);
//绘制专辑图外围的圆环
canvas.drawCircle(width/2, height/2, mAlbumCircleRadius, mCirclePaint);
//绘制粒子群
drawParticleGroup(canvas);
//绘制气泡群
drawBubbleGroup(canvas);
//33ms后更新粒子位置和气泡位置
postDelayed(new Runnable() {
@Override
public void run() {
changeParticleGroup();
changeBubbleGroup();
//补充粒子
initParticleGroup(PARTICLE_GROUP_SIZE);
//补充气泡
initBubbleGroup(PARTICLE_GROUP_SIZE / 60);
}
}, 33);
//回收过程中Bitmap
roundBitmap.recycle();
}
学习心得
粒子动效的自定义View核心不在于绘制,而是要理解并处理提到的4个重点课题。由于作者水平有限,可能存在一些纰漏,欢迎交流。