Android:会呼吸的悬浮气泡

写在前面

最早看到这个效果是 MIUI6 系统升级界面,有很多五颜六色的气泡悬浮着,觉得很好看。

android 圆形气泡 安卓气泡效果_渐变色

可惜现在找不到动态图了。而且虽然 MIUI8 更新界面也有类似的气泡,但是不过是静态的,不咋好看。

android 圆形气泡 安卓气泡效果_自定义_02

再次见到这个效果是在 Pure 天气这款软件中,可惜开发者不开源。不过万能的 Github 上有类似的实现,于是果断把自定义 View 部分抽出来学习学习。

android 圆形气泡 安卓气泡效果_android 圆形气泡_03

怀着敬意放上原项目地址,很好看的一款天气 APP:

还是那句话,学习自定义 View 没有什么捷径,就是看源码、模仿、动手。

具体实现

先思考

在看源码之前,我自己想了一下该怎样去实现,思路如下:

自定义一个圆形 View ,支持大小、颜色、位置等属性

浮动利用最简单的平移动画来实现

平移的范围通过自定义圆心的移动范围来确定

最后给动画一个循环就行了

虽然看起来比较简单,但是实现起来还是遇到不少坑。首先画圆一点问题都没有,问题出在动画上。动画看起来很迟钝,根本就不是呼吸效果,像哮喘一样。

所以不能用动画,就想到了不断重绘。于是仍然给圆心设置一个小圆,让圆心在小圆上移动,在这个过程中不断重绘,结果直接 Crash 了,看了看 Log ,发现是线程阻塞了,但是这里并没有开启子线程啊,一看,我去,主线程。

那这条路行不通,又想到用贝塞尔去做,结果突然想起来之前绘制阻塞了主线程,那开子线程绘制不就完了,Android View 里面能开子线程绘制的不就是 SurfaceView 。于是看了看作者源码,果然是自定义SurfaceView 。

android 圆形气泡 安卓气泡效果_android 呼吸气泡动画_04

关于 SurfaceView 我只在以前学习的视频案例、撕MM衣服案例、还有手写板案例中遇到过,学的不是很深,加上本文它不是重点,所以就不详细说了,如果不了解这个或者想深入了解一下的话,可以点击文末的相关链接,这里只简单提一下比较重要的一点,也就是 SurfaceView跟 View 的主要区别:

SurfaceView 在一个新起的单独线程中重新绘制画面,而 View必须在 UI 线程中更新画面。

这就决定了 SurfaceView 的一些特定使用场景:

需要界面迅速更新;

对帧率要求较高的情况;

渲染 UI 需要较长的时间。

所以综合来看,SurfaceView 无疑是实现这类效果的最佳选择。

再分析

废话不多说,来分析一下思路。

1、首先光从界面上能看到就是圆,且是能浮动的圆,所以不管能不能动,先得把圆画出来。要是我的话,我直接就拿着 Paint 在 Canvas 上开画了。在源码中开发者单独抽取了绘制圆的类,但这个类的作用不仅仅是绘制圆,后面我们再说。

2、其次就是自定义 SurfaceView ,我们需要把画出来的圆放到SurfaceView 中。而自定义 SurfaceView 需要实现SurfaceHolder.Callback 接口,就是一些回调方法。同时需要开子线程去不断刷新界面,因为这些圆是需要动起来的.

3、另外重要的一点就是,SurfaceView 在渲染过程中需要消耗大量资源,比如内存啊、CPU 啊之类的,所以最好提供一个生命周期相关的方法,让它和 Activity 的生命周期保持一致,尽量保证及时回收资源,减少消耗。

4、最后需要提一点的是,SurfaceView 本身并不需要绘制内容,或者说在这里它的主要作用就是刷新界面就行了。就好像在放视频的时候,只需要刷新视频页面就行,它并不参与视频具体内容的绘制。

所以这样来说的话,我们最好定义一个绘制过程的中间者,主要作用就是把绘制出来的圆放在 SurfaceView 上,同时也能做一些其他的工作,比如绘制背景、设置尺寸等。这样做的好处就是能让 SurfaceView专心的做一件事:不断刷新,这就够了。

OK,总结一下我们到底需要哪些东西:

专门绘制圆的类

刷新过程中的子线程

实现 SurfaceHolder.Callback 接口方法

提供生命周期相关方法

一个绘制过程的中间对象

多提一句,最后的绘制中间者也可以不定义,全部封装到自定义SurfaceView 中,但是从我实践来看,我最后不得不单独抽取出来,因为 SurfaceView 类看起来太乱了,这也是源码中的实现方式。

android 圆形气泡 安卓气泡效果_子线程_05

后动手

Talk is cheap,Show me the code .

1、画圆

既然要画圆,我们肯定要设置一些圆的基本属性:

圆心坐标

圆的半径

圆的颜色

由于需要圆动起来,也就是说它会偏移,所以要确定一个范围。范围确定了,就需要指定它该怎么变化,因为我们要求它缓慢而顺畅的呼吸,不能瞬间大喘气,也就是它不能瞬间移动偏移量那么多,所以最好指定它每一步变化多少,那就需要下面这两样东西:

圆心偏移范围

每一帧的变化量

额外的,因为移动是每次都需要变的,下一次变化时不能重新开始,所以我们要记录当前已经偏移的距离,然后根据一个标志位不断呼气…吐气…呼气…吐气,所以需要:

当前帧变化量

标志位

好了,看构造函数吧:

/**
* @author Mixiaoxiao
* @revision xiarui 16/09/27
* @description 圆形浮动气泡
*/
class CircleBubble {
private final float cx, cy; //圆心坐标
private final float dx, dy; //圆心偏移距离
private final float radius; //半径
private final int color; //画笔颜色
private final float variationOfFrame; //设置每帧变化量
private boolean isGrowing = true; //根据此标志位判断左右移动
private float curVariationOfFrame = 0f; //当前帧变化量
CircleBubble(float cx, float cy, float dx, float dy, float radius, float variationOfFrame, int color) {
this.cx = cx;
this.cy = cy;
this.dx = dx;
this.dy = dy;
this.radius = radius;
this.variationOfFrame = variationOfFrame;
this.color = color;
}
//...画圆方法先省略
}
好了,构造好了圆就要开始绘制圆了。之前说到,这个类的作用不仅仅是绘制圆,还要不断更新圆的位置,也就是不断重绘圆。更直接地说,我们需要绘制出不断偏移的每一帧的圆。
步骤如下:
确定当前帧偏移位置
根据当前帧偏移位置计算圆心坐标
设置圆的颜色透明度等属性
真正的开始绘制圆
代码如下,结合上面的步骤和代码中的注释应该很容易看懂:
/**
* 更新位置并重新绘制
*
* @param canvas 画布
* @param paint 画笔
* @param alpha 透明值
*/
void updateAndDraw(Canvas canvas, Paint paint, float alpha) {
/**
* 每次绘制时都根据标志位(isGrowing)和每帧变化量(variationOfFrame)进行更新
* 说白了其实就是每帧都会变化一段距离 连在一起就产生动画效果
*/
if (isGrowing) {
curVariationOfFrame += variationOfFrame;
if (curVariationOfFrame > 1f) {
curVariationOfFrame = 1f;
isGrowing = false;
}
} else {
curVariationOfFrame -= variationOfFrame;
if (curVariationOfFrame < 0f) {
curVariationOfFrame = 0f;
isGrowing = true;
}
}
//根据当前帧变化量计算圆心偏移后的位置
float curCX = cx + dx * curVariationOfFrame;
float curCY = cy + dy * curVariationOfFrame;
//设置画笔颜色
int curColor = convertAlphaColor(alpha * (Color.alpha(color) / 255f), color);
paint.setColor(curColor);
//这里才真正的开始画圆形气泡
canvas.drawCircle(curCX, curCY, radius, paint);
}
其中的 convertAlphaColor() 方法是个工具方法,作用就是转化一下颜色,不必深究:
/**
* 转成透明颜色
*
* @param percent 百分比
* @param originalColor 初始颜色
* @return 带有透明效果的颜色
*/
private static int convertAlphaColor(float percent, final int originalColor) {
int newAlpha = (int) (percent * 255) & 0xFF;
return (newAlpha << 24) | (originalColor & 0xFFFFFF);
}
到此,画每一帧圆的工作我们就完成了。
2、绘制中间者对象
现在来说这个特殊的中间者对象,前文说了,单独抽取这个类不是必须的。但最好抽取一下,让 SurfaceView 专心做自己的事情。在这个中间者对象中我们做两件事情:
绘制背景
绘制悬浮气泡
先来看绘制背景。为什么需要绘制背景呢,因为 SurfaceView 本身其实是个黑色,从我们日常看视频的软件中也能发现,视频播放时周围都是黑色的。有人问为什么不能直接在布局中设置呢?当然可以直接设置啊,不过要记得添加一句 setZOrderOnTop(true) ,不然会把之后绘制的悬浮气泡遮挡住。
在这里就来绘制一下吧,因为源码中给出了一个渐变色的绘制,我觉得挺好玩,学一学。直接看代码吧,都是模板代码,没啥好解释的,简单的 get/set 再画一下就好了:
/**
* @author Mixiaoxiao
* @revision xiarui 16/09/27
* @description 绘制圆形浮动气泡及设定渐变背景的绘制对象
*/
public class BubbleDrawer {
/*===== 图形相关 =====*/
private GradientDrawable mGradientBg; //渐变背景
private int[] mGradientColors; //渐变颜色数组
/**
* 设置渐变背景色
*
* @param gradientColors 渐变色数组 必须 >= 2 不然没法渐变
*/
public void setBackgroundGradient(int[] gradientColors) {
this.mGradientColors = gradientColors;
}
/**
* 获取渐变色数组
*
* @return 渐变色数组
*/
private int[] getBackgroundGradient() {
return mGradientColors;
}
/**
* 绘制渐变背景色
*
* @param canvas 画布
* @param alpha 透明值
*/
private void drawGradientBackground(Canvas canvas, float alpha) {
if (mGradientBg == null) {
//设置渐变模式和颜色
mGradientBg = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, getBackgroundGradient());
//规定背景宽高 一般都为整屏
mGradientBg.setBounds(0, 0, mWidth, mHeight);
}
//然后开始画
mGradientBg.setAlpha(Math.round(alpha * 255f));
mGradientBg.draw(canvas);
}
//...暂时省略圆的绘制方法
}
上面代码就一点需要注意,渐变最少需要两种颜色,不然没法渐变,这个很好理解吧,不再多解释了。现在我们来画气泡,步骤如下:
设置一下圆的范围,一般都为全屏
根据圆的构造方法添加多个圆
绘制添加的这些圆
直接来看代码,其实也很简单:
/*===== 图形相关 =====*/
private Paint mPaint; //抗锯齿画笔
private int mWidth, mHeight; //上下文对象
private ArrayList mBubbles; //存放气泡的集合
/**
* 构造函数
*
* @param context 上下文对象 可能会用到
*/
public BubbleDrawer(Context context) {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBubbles = new ArrayList<>();
}
/**
* 设置显示悬浮气泡的范围
* @param width 宽度
* @param height 高度
*/
void setViewSize(int width, int height) {
if (this.mWidth != width && this.mHeight != height) {
this.mWidth = width;
this.mHeight = height;
if (this.mGradientBg != null) {
mGradientBg.setBounds(0, 0, width, height);
}
}
//设置一些默认的气泡
initDefaultBubble(width);
}
/**
* 初始化默认的气泡
*
* @param width 宽度
*/
private void initDefaultBubble(int width) {
if (mBubbles.size() == 0) {
mBubbles.add(new CircleBubble(0.20f * width, -0.30f * width, 0.06f * width, 0.022f * width, 0.56f * width,
0.0150f, 0x56ffc7c7));
mBubbles.add(new CircleBubble(0.58f * width, -0.15f * width, -0.15f * width, 0.032f * width, 0.6f * width,
0.00600f, 0x45fffc9e));
//...
}
}
/**
* 用画笔在画布上画气泡
*
* @param canvas 画布
* @param alpha 透明值
*/
private void drawCircleBubble(Canvas canvas, float alpha) {
//循环遍历所有设置的圆形气泡
for (CircleBubble bubble : this.mBubbles) {
bubble.updateAndDraw(canvas, mPaint, alpha);
}
}
从代码中看出,已经将所有添加的圆放到集合里,然后遍历集合去画,这就不用添加一个画一个了,只需统一添加再统一绘制即可。
既然背景绘制好了,气泡也绘制好了,那就到了最后一步,需要提供方法让 SurfaceView 去添加背景和气泡:
/**
* 画背景 画所有的气泡
*
* @param canvas 画布
* @param alpha 透明值
*/
void drawBgAndBubble(Canvas canvas, float alpha) {
drawGradientBackground(canvas, alpha);
drawCircleBubble(canvas, alpha);
}
到此,这个绘制中间者对象就完成了。
3、自定义 SurfaceView
终于到了重要的 SurfaceView 部分了,这部分不太好描述,因为最好的解释方式就是看代码。
首先自定义 FloatBubbleView 继承于 SurfaceView ,看一下简单的变量定义、构造方法:
/**
* @author Mixiaoxiao
* @revision xiarui 16/09/27
* @description 用圆形浮动气泡填充的View
* @remark 因为气泡需要不断绘制 所以防止阻塞UI线程 需要继承 SurfaceView 开启线程更新 并实现回调类
*/
public class FloatBubbleView extends SurfaceView implements SurfaceHolder.Callback {
private DrawThread mDrawThread; //绘制线程
private BubbleDrawer mPreDrawer; //上一次绘制对象
private BubbleDrawer mCurDrawer; //当前绘制对象
private float curDrawerAlpha = 0f; //当前透明度 (范围为0f~1f,因为 CircleBubble 中 convertAlphaColor 方法已经处理过了)
private int mWidth, mHeight; //当前屏幕宽高
public FloatBubbleView(Context context) {
super(context);
initThreadAndHolder(context);
}
//...省略其他构造方法
/**
* 初始化绘制线程和 SurfaceHolder
*
* @param context 上下文对象 可能会用到
*/
private void initThreadAndHolder(Context context) {
mDrawThread = new DrawThread();
SurfaceHolder surfaceHolder = getHolder();
surfaceHolder.addCallback(this); //添加回调
surfaceHolder.setFormat(PixelFormat.RGBA_8888); //渐变效果 就是显示SurfaceView的时候从暗到明
mDrawThread.start(); //开启绘制线程
}
/**
* 当view的大小发生变化时触发
*
* @param w 当前宽度
* @param h 当前高度
* @param oldw 变化前宽度
* @param oldh 变化前高度
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = w;
mHeight = h;
}
//...省略其他方法
}
这里其他的内容都比较好理解,重点提两个变量:
private BubbleDrawer mPreDrawer; //上一次绘制对象
private BubbleDrawer mCurDrawer; //当前绘制对象
这是什么意思呢,开始我也不太理解,那换个思路,大家还记得ListView 中的 ViewHolder 么,这个 ViewHolder 其实就是用来复用的。那 SurfaceView 中也有个 SurfaceHolder ,作用可以看做是相同的,就是用来不断复用不断刷新界面的。
那这里的这两个变量是干什么的呢?就是相当于 当前刷新的中间者对象 和 上一次刷新的中间者对象 。
那获得这两个对象有什么用呢?注意看,还有个 curDrawerAlpha 变量,顾名思义,当前的透明度。
三者结合在一起,再加上一个这样的小循环:
if (curDrawerAlpha < 1f) {
curDrawerAlpha += 0.5f;
if (curDrawerAlpha > 1f) {
curDrawerAlpha = 1f;
mPreDrawer = null;
}
}

那这又有什么作用呢,别急,先看下面两张对比图,分别设置curDrawerAlpha += 0.2f 和 curDrawerAlpha += 0.8f:

模拟器太卡,将就着看

android 圆形气泡 安卓气泡效果_自定义_06

再看 0.8f ,从暗到明显然快了点:

android 圆形气泡 安卓气泡效果_android 呼吸气泡动画_07

现在知道作用了么,就是实现界面从暗到明的效果。那为什么需要这样的效果呢,我尝试过去掉这个,发现绘制的时候会偶尔出现闪黑屏的现象,黑色刚好是 SurfaceView 的本身颜色,加上这个效果就不会出现了。

好,接下来看重中之重的绘制线程方法,为了方便我单独抽取了线程类,并将 run 方法按照不同的功能分成好几个方法,注释写的很清晰:

/**
* 绘制线程 必须开启子线程绘制 防止出现阻塞主线程的情况
*/
private class DrawThread extends Thread {
SurfaceHolder mSurface;
boolean mRunning, mActive, mQuit; //三种状态
Canvas mCanvas;
@Override
public void run() {
//一直循环 不断绘制
while (true) {
synchronized (this) {
//根据返回值 判断是否直接返回 不进行绘制
if (!processDrawThreadState()) {
return;
}
//动画开始时间
final long startTime = AnimationUtils.currentAnimationTimeMillis();
//处理画布并进行绘制
processDrawCanvas(mCanvas);
//绘制时间
final long drawTime = AnimationUtils.currentAnimationTimeMillis() - startTime;
//处理一下线程需要的睡眠时间
processDrawThreadSleep(drawTime);
}
}
}
/**
* 处理绘制线程的状态问题
*
* @return true:不结束继续绘制 false:结束且不绘制
*/
private boolean processDrawThreadState() {
//处理没有运行 或者 Holder 为 null 的情况
while (mSurface == null || !mRunning) {
if (mActive) {
mActive = false;
notify(); //唤醒
}
if (mQuit)
return false;
try {
wait(); //等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//其他情况肯定是活动状态
if (!mActive) {
mActive = true;
notify(); //唤醒
}
return true;
}
/**
* 处理画布与绘制过程 要注意一定要保证是同步锁中才能执行 否则会出现
*
* @param mCanvas 画布
*/
private void processDrawCanvas(Canvas mCanvas) {
try {
mCanvas = mSurface.lockCanvas(); //加锁画布
if (mCanvas != null) { //防空保护
//清屏操作
mCanvas.drawColor(Color.TRANSPARENT, Mode.CLEAR);
drawSurface(mCanvas); //真正开始画 SurfaceView 的地方
}
}catch (Exception ignored){
}finally {
if(mCanvas != null){
mSurface.unlockCanvasAndPost(mCanvas); //释放canvas锁,并显示视图
}
}
}
/**
* 真正的绘制 SurfaceView
*
* @param canvas 画布
*/
private void drawSurface(Canvas canvas) {
//防空保护
if (mWidth == 0 || mHeight == 0) {
return;
}
//如果前一次绘制对象不为空 且 当前绘制者有透明效果的话 绘制前一次的对象即可
if (mPreDrawer != null && curDrawerAlpha < 1f) {
mPreDrawer.setViewSize(mWidth, mHeight);
mPreDrawer.drawBgAndBubble(canvas, 1f - curDrawerAlpha);
}
//直到当前绘制完全不透明时将上一次绘制的置空
if (curDrawerAlpha < 1f) {
curDrawerAlpha += 0.5f;
if (curDrawerAlpha > 1f) {
curDrawerAlpha = 1f;
mPreDrawer = null;
}
}
//如果当前有绘制对象 直接绘制即可 先设置绘制宽高再绘制气泡
if (mCurDrawer != null) {
mCurDrawer.setViewSize(mWidth, mHeight);
mCurDrawer.drawBgAndBubble(canvas, curDrawerAlpha);
}
}
/**
* 处理线程需要的睡眠时间
* View通过刷新来重绘视图,在一些需要频繁刷新或执行大量逻辑操作时,超过16ms就会导致明显卡顿
*
* @param drawTime 绘制时间
*/
private void processDrawThreadSleep(long drawTime) {
//需要睡眠时间
final long needSleepTime = 16 - drawTime;
if (needSleepTime > 0) {
try {
Thread.sleep(needSleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

知道看这种代码很枯燥,但不能急。首先这里有三种状态:正在绘制、活动、退出。其中活动是一种中间状态,指既没有活动又没有被销毁。在回调类中需要根据这种状态进行绘制线程的控制。

那就来看回调方法:

/*========== Surface 回调方法 需要加同步锁 防止阻塞 START==========*/
@Override
public void surfaceCreated(SurfaceHolder holder) {
synchronized (mDrawThread) {
mDrawThread.mSurface = holder;
mDrawThread.notify(); //唤醒
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
synchronized (mDrawThread) {
mDrawThread.mSurface = holder;
mDrawThread.notify(); //唤醒
while (mDrawThread.mActive) {
try {
mDrawThread.wait(); //等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
holder.removeCallback(this);
}
/*========== Surface 回调方法 需要加同步锁 防止阻塞 END==========*/
可以看到,在销毁的时候绘制线程是在等待状态。
然后就是一些生命周期相关方法了,也很简单,就是设置相关状态:
/*========== 处理与 Activity 生命周期相关方法 需要加同步锁 防止阻塞 START==========*/
public void onDrawResume() {
synchronized (mDrawThread) {
mDrawThread.mRunning = true; //运行状态
mDrawThread.notify(); //唤醒线程
}
}
public void onDrawPause() {
synchronized (mDrawThread) {
mDrawThread.mRunning = false; //不运行状态
mDrawThread.notify(); //唤醒线程
}
}
public void onDrawDestroy() {
synchronized (mDrawThread) {
mDrawThread.mQuit = true; //退出状态
mDrawThread.notify(); //唤醒线程
}
}
/*========== 处理与 Activity 生命周期相关方法 需要加同步锁 防止阻塞 END==========*/
最后就是提供方法,给这个自定义的 SurfaceView 设置中间绘制者对象了:
/**
* 设置绘制者
*
* @param bubbleDrawer 气泡绘制
*/
public void setDrawer(BubbleDrawer bubbleDrawer) {
//防空保护
if (bubbleDrawer == null) {
return;
}
curDrawerAlpha = 0f; //完全透明
//如果当前有正在绘制的对象 直接设置为前一次绘制对象
if (this.mCurDrawer != null) {
this.mPreDrawer = mCurDrawer;
}
//当前绘制对象 为设置的对象
this.mCurDrawer = bubbleDrawer;
}
到此,自定义 FloatBubbleView 就完成了,代码很长,建议直接看文末的源码。
看结果
好了, 现在只要在 Activity 中这样:
/**
* 初始化Data
*/
private void initData() {
//设置气泡绘制者
BubbleDrawer bubbleDrawer = new BubbleDrawer(this);
//设置渐变背景 如果不需要渐变 设置相同颜色即可
bubbleDrawer.setBackgroundGradient(new int[]{0xffffffff, 0xffffffff});
//给SurfaceView设置一个绘制者
mDWView.setDrawer(bubbleDrawer);
}

这样就大功告成了!效果图再贴一下吧,颜色大小位置都可以定义:

android 圆形气泡 安卓气泡效果_android 呼吸气泡动画_08

后话

虽然效果实现了,但是我并没有将设置气泡的方法暴露出来,只写死在 BubbleDrawer 中:

if (mBubbles.size() == 0) {
mBubbles.add(new CircleBubble(0.20f * width, -0.30f * width, 0.06f * width, 0.022f * width, 0.56f * width,0.0150f, 0x56ffc7c7));
//...
}

开始我确实抽取了方法,提供给 Activity ,结果发现 Activity 中的代码太难看。另一方面因为 SurfaceView 消耗资源太多,我们应该不会在主要界面大量使用它,所以我觉得写死就够了,必要的时候动一动写死的数据就行了。

还有一点就是,虽然效果很好看,但是确实消耗资源很大,有时候会很卡,不知道还有没有可以优化的地方,建议只在简单的页面,比如关于软件的页面用这样的效果,其他的主页面还是算了吧。

参考资料

项目源码