搜狐技术产品

Android点赞烟花效果实现

Image

本文字数:2910字

预计阅读时间:15分钟


01

前言介绍

App中使用点赞特效来提高用户互动的做法普遍存在。下面选取一种烟花点赞特效,配置上节日素材小图标,可以很好的营造节日气氛,增强用户操作的兴趣。

02

效果截图

Image

03

实现思路

在脑海里迅速过一下烟花效果的实现过程,也可以通过这些问题自己想象下怎么实现。

  • 烟花的产生与消失,主要就是怎么来实现那种从定点开始的抛射效果?
  • 烟花到达最高点后消失,如何产生爆炸后扩散的火花以及火花如何形成扩散的圆环?
  • 如何控制内外层火花圆环的扩散,呈现出有序的层叠辐射效果,以及最后火花的消失?

基本上,想清楚了以上三步,你的烟花效果也就算是做出来了。很简单吧,下面我们看下实现过程。

04

实现过程

根据整个烟花效果,将烟花分为两个主体,一是发射点发出的烟花粒子,二是烟花爆炸后形成的火花粒子组。将每个粒子持有一张对应的bitmap图片,在画布canvs上绘制图形,这样不就组成了烟花图案了嘛。
比如效果图中,烟花粒子为logo小图标,外圈火花粒子组为七彩小圆点,内圈火花粒子组为黄色小五角星。当然素材的选取与搭配,可以根据想法DIY自己设计哦。
粒子有了,自定义一个视图 ParticleDrawingView 承载烟花吧,根据预设的组数N一次点击可以同时打出多组烟花。那么,烟花图形又是怎么动气来的呢,通过属性动画ValueAnimator设置运行时间Duration,利用插值器在每个时间因子不断更新粒子的位置、速度、透明度、旋转角度等属性,通过view.postInvalidate() 渲染更新画布。
最后,将烟花视图mDrawingView添加在一个全屏父容器ViewGrouup中,就可以呈现出烟花满屏绽放的效果了。

1. 定义粒子对象

public class Particle {
    Matrix mMatrix;
    Paint mPaint;
    Bitmap mImage;

        float mInitialX, mInitialY; //初始位置
    float mSpeedX, mSpeedY; //发射粒子的速度(像素/毫秒)
    float mRotation; //旋转角度值

        float mScale = 1f; //缩放
    int mAlpha = 255; //透明度
    float mCurrentX, mCurrentY; //坐标位置

        protected Particle(Bitmap bitmap) {
        mMatrix = new Matrix();
        mPaint = new Paint();
        mImage = bitmap;
    }

        public boolean update(long miliseconds) {
        // 更新粒子属性
        updateLocation(this, miliseconds);
        updateScale(this, miliseconds);
        updateAlpha(this, miliseconds);
        return true;
    }

        public void draw(Canvas c) {
        mMatrix.reset();
        mMatrix.postRotate(mRotation, mBitmapHalfWidth, mBitmapHalfHeight);
        mMatrix.postScale(mScale, mScale, mBitmapHalfWidth, mBitmapHalfHeight);
        mMatrix.postTranslate(mCurrentX, mCurrentY);
        mPaint.setAlpha(mAlpha);
        c.drawBitmap(mImage, mMatrix, mPaint);
    }
}

2. 在画布上绘制图形

public class ParticleLikeFieldView extends View { 
    List<Particle> fireworkList; // 烟花粒子
    List<List<Particle>> sparkOuterList; // 火花外圈粒子组
    List<List<Particle>> sparkInnerList; // 火花内圈粒子组
    boolean isFireworkDead = false; // 烟花是否已经爆炸

    @Override
    protected void onDraw(Canvas canvas) {
        synchronized (this) {
          if (!isFireworkDead) {
            for (Particle p : fireworkList) {
              p.draw(canvas);
            }
          } else {
            for (List<Particle> outerList : sparkOuterList) {
              for (Particle p : outerList) {
                p.draw(canvas);
              }
            }
            for (List<Particle> innerList : sparkInnerList) {
              for (Particle p : innerList) {
                p.draw(canvas);
              }
            }
          }
        }
    }
}

// 点赞触发动画时在容器中添加烟花视图
private void addDrawingView() {
    ParticleLikeFieldView mDrawingView = new ParticleLikeFieldView(context);
    ViewGroup mParentView = (ViewGroup) activity.findViewById(android.R.id.content); //全屏容器
    mParentView.addView(mDrawingView);   
}

// 整个动画结束后在容器中清除烟花视图
private void cleanupDrawingView() {
    mParentView.removeView(mDrawingView);
    mParentView.postInvalidate();
    mDrawingView = null;
}

3. 创建动画

烟花视图有了,开始创建动画来控制视图一点一点动起来吧!
一起回顾下烟花动画过程:首先从发射位置发射烟花粒子,烟花粒子逐步放大,受重力因素做减速运动,运动至目标位置后快速消失并开始爆炸。然后是爆炸后火花的运动,火花匀速向外扩散成圆环,同时火花粒子逐步放大,运动至目标位置后快速消失。火花的扩展分外圈和内圈两组,每组8个粒子组成圆环向外扩散,其中外圈先于内圈执行,内环扩散完成整个烟花过程结束。
下面将动画过程拆分,逐个创建动画对象:

fireworkAnim(发射烟花),时长fireworkDuration 800毫秒
├── Scan:烟花小图标设置为2倍放大。
├── Alpha: 粒子在其运行最后100毫秒快速消失。

sparkOuterAnim(火花外圈扩散),时长sparkDuration 300毫秒
├──  Scan:火花圆环设置为3倍放大。
├──  Alpha: 粒子在其运行最后100毫秒快速消失。

sparkInnerAnim(火花内圈扩散),时长sparkDuration 300毫秒
├── Scan:火花圆环设置为3倍放大。
├── Alpha: 粒子在其运行最后100毫秒快速消失。

nullOuterAnim(火花外圈停顿),时长100毫秒

下面以fireworkAnim举例:

ValueAnimator fireworkAnim = ValueAnimator.ofInt(0, fireworkDuration);
    fireworkAnim.setDuration(fireworkDuration);
    fireworkAnim.setInterpolator(new DecelerateInterpolator());
    // 监听动画帧变化
    fireworkAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            // 插值值?下面会有介绍
            int miliseconds = (Integer) animation.getAnimatedValue();
            // 更新粒子属性
            particle.update(miliseconds);
            // 更新视图显示
            mDrawingView.postInvalidate();
        }
    });
    // 监听动画生命周期
    fireworkAnim.addListener(new Animator.AnimatorListener() {
        @Override
        public void onAnimationEnd(Animator animation) {
            mDrawingView.isFireworkDead = true;
        }
        @Override
        public void onAnimationCancel(Animator animation) {
            cleanupDrawingView();
        }
    });

4. 组合动画

AnimatorSet set = new AnimatorSet();
set.play(fireworkAnim).before(sparkOuterAnim);
set.play(sparkOuterAnim).with(nullOuterAnim);
set.play(nullOuterAnim).before(sparkInnerAnim);
set.start();

5. 速率控制

动画的几个阶段我们搞清楚了,那么每个阶段的动画又是如何控制速率的变化呢?
比如,如何控制粒子匀速放大?如何控制粒子加速消失?又如何控制粒子减速移动?
试想,动画按照时间行走,而时间是匀速增长的,在没有给定速率转换公式的前提下,又该如何模拟速率变化呢?下面就借助于属性动画插值器 Interpolator,就可以轻松做到。

插值器

插值器 Interpolator,实现TimeInterpolator接口,核心函数为 getInterpolation方法,即根据当前动画运行的时间占比来计算当前动画的变化频率。
参数说明:input,范围0~1.0的值,指示当前动画的进度,0表示开始,1.0表示结束;返回值fraction为插值值。

public interface TimeInterpolator {
    float getInterpolation(float input);
}

插值值

Android 内置了种类丰富的插值器,为了说明返回的插值值的取值规律,下面简单介绍几种。

例如,LinearInterpolator,没有对input值进行任何逻辑处理,直接返回,即input值 = fraction值。因为input值是匀速增加的,因此插值值也是匀速增加的,所以叫做匀速插值器。

public float getInterpolation(float input) {
    return input;
}
Image

例如,AccelerateDecelerateInterpolator,通过公式转换,插值值体现出的速率变化是先加速后减速。

public float getInterpolation(float input) {
    return (float)(Math.cos((input + 1) * Math.PI) / 2.0f) + 0.5f;
}
Image

例如,CycleInterpolator 循环插值器

public float getInterpolation(float input) {
    return (float)(Math.sin(2 * mCycles * Math.PI * input));
}
Image

05

粒子属性计算

做了以上准备工作,下面看下粒子一系列属性是怎么计算的。

1. 确定发射位置

怎么精确计算发射点位置?可以是点赞按钮,也可以是一个点击位置。Canvas 2D坐标系,左上角为(0,0)点。先获取发射按钮在当前窗口内的绝对坐标,根据设置的发射重力范围和发射按钮宽高信息,可以计算出发射点的位置范围。这里我们按照发射点为发射按钮的正中心来计算发射位置。

// 获取发射按钮在当前窗口内的绝对坐标
int[] location = new int[2];
emitter.getLocationInWindow(location);

// 发射按钮中心位置坐标
int emitterX = location[0] + emitter.getWidth() / 2;
int emitterY = location[1] + emitter.getHeight() / 2;

// 粒子初始位置
particle.mInitialX = emiterX - mBitmapHalfWidth;
particle.mInitialY = emiterY - mBitmapHalfHeight;
particle.mCurrentX = mInitialX;
particle.mCurrentY = mInitialY;

2. 初始速度、角速度

烟花粒子运动方向,由于一次点击发射多组烟花,根据传入的角度范围 minAngle、maxAngle,既要保证粒子能正常扩展到目标范围,还要尽可能均匀地分布在既定区域的任意方向。

// 烟花粒子发射角度
int angle = random.nextInt(maxAngle - minAngle) + minAngle;
// 火花粒子组发射角度
float angleOuter = 45 * (n - 1);  //外圈粒子
float angleInner = 45 * (n- 1); //内圈粒子

// 角度转换为弧度:180 -> 3.14
double angleInRads = Math.toRadians(angle);
// 分解为X和Y方向上的速度,其中speed为外部控制系数。
particle.mSpeedX = (float) (speed * Math.cos(angleInRads));  
particle.mSpeedY = (float) (speed * Math.sin(angleInRads));  
// 旋转角度值
particle.mRotation = angle + 90;

火花粒子组,每组8个,n从1开始。
Image

3. 缩放

借助LinearInterpolator,动画过程中不断更新粒子的scale属性,达到烟花小图标2倍匀速放大的效果。根据预期效果,烟花小图标设置为2倍缩放,火花小圆点设置为3倍缩放。插值器使用 LinearInterpolator 匀速缩放。

float mInitialValue = 1.0;    float mFinalValue = 2.0;
Interpolator mInterpolator = new LinearInterpolator();

public boolean updateScale(Particle particle, long miliseconds, long startMilis, long endMilis) {
    if (miliseconds < startMilis) {
        particle.mScale = mInitialValue;
    } else if (miliseconds > endMilis) {
        particle.mScale = mFinalValue;
    } else {
        // input为当前动画的进度值,线性插值器插值值fraction=input
        float input = (miliseconds - startMilis) * 1f / (endMilis - startMilis);
        float fraction = mInterpolator.getInterpolation(input);
        float newScale = mInitialValue + (mFinalValue - mInitialValue) * fraction;
        particle.mScale = newScale;
    }
}

4. 透明度

借助AccelerateInterpolator,在粒子运行最后100毫秒内不断更新粒子的alpha属性,达到烟花加速淡出直至消除的效果。

int mInitialValue = 255;    int mFinalValue = 0;
Interpolator mInterpolator = new AccelerateInterpolator();

public boolean updateAlpha(Particle particle, long miliseconds, long startMilis, long endMilis) {
    if (miliseconds < startMilis) {
        particle.mAlpha = mInitialValue;
    } else if (miliseconds > endMilis) {
        particle.mAlpha = mFinalValue;
    } else {
        // input为当前动画的进度值,加速插值器插值值fraction速率变化也是加速的
        float input = (miliseconds - startMilis) * 1f / (endMilis - startMilis);
        float fraction = mInterpolator.getInterpolation(input);
        int newAlpha = (int) (mInitialValue + (mFinalValue - mInitialValue) * fraction);
        particle.mAlpha = newAlpha;
    }
}

5. 位置坐标

ValueAnimator animation = ValueAnimator.ofInt(startValue, endValue);
animation.setDuration(duration);
animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        float fraction = animation.getAnimatedFraction(); // 范围是由0到1
        int value = (Integer) animation.getAnimatedValue(); // 范围是由startValue到endValue
        updateLocation(particle, value);
    }
});

当设置startValue=0, endValue=duration时,监听回调中value的取值范围就和动画的运行时长范围一致了。所以,此时可以将value的取值作为动画运行的某个时间点的值 miliseconds。

public boolean updateLocation(Particle particle, long miliseconds) {
    particle.mCurrentX = mInitialX + mSpeedX * miliseconds;
    particle.mCurrentY = mInitialY + mSpeedY * miliseconds;
}

借助于动画所设置的减速插值器 DecelerateInterpolator,value取值的增速将逐渐减小,即这里miliseconds取值的增速逐渐减小。
虽然速度值mSpeedX、mSpeedY固定,但是计算出的位置轨迹可以是一个减速运动的效果。

06

总结

使用Canvas绘制高帧率的粒子动效,提供了动画的一种实现方案,虽然轨迹的计算有些繁琐。当然,易用性肯定也是Canvas的优势了。并且属性动画提了种类丰富的插值器 Interpolator,除本文提到的还有弹跳插值器、回荡秋千插值器等等,另外还支持自定义插值器,这成为定制复杂动画运动轨迹的关键。实际需求中可以根据需要设计组装成不同的动画效果,粒子喷泉,屏幕气泡、镜头由远及近等等,快来试试吧。