Android:会呼吸的悬浮气泡


写在前面

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

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

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

Pure

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

Weather - Mixiaoxiao

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

具体实现

先思考

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

  • 自定义一个圆形 View ,支持大小、颜色、位置等属性
  • 浮动利用最简单的平移动画来实现
  • 平移的范围通过自定义圆心的移动范围来确定
  • 最后给动画一个循环就行了

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

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

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

早已看穿一切

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

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

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

  • 需要界面迅速更新;
  • 对帧率要求较高的情况;
  • 渲染 UI 需要较长的时间。

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

再分析

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

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

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

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

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

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

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

  • 专门绘制圆的类
  • 刷新过程中的子线程
  • 实现 SurfaceHolder.Callback 接口方法
  • 提供生命周期相关方法
  • 一个绘制过程的中间对象

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

23333

后动手

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<CircleBubble> 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.2fcurDrawerAlpha += 0.8f

模拟器太卡,将就着看

0.2f

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

0.8f

现在知道作用了么,就是实现界面从暗到明的效果。那为什么需要这样的效果呢,我尝试过去掉这个,发现绘制的时候会偶尔出现闪黑屏的现象,黑色刚好是 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);
}

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

悬浮气泡演示图

后话

虽然效果实现了,但是我并没有将设置气泡的方法暴露出来,只写死在 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 消耗资源太多,我们应该不会在主要界面大量使用它,所以我觉得写死就够了,必要的时候动一动写死的数据就行了。

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

参考资料

Weather - Mixiaoxiao

Android之SurfaceView简介(一)

Android SurfaceView入门学习 - 英勇青铜5

项目源码

FloatBubbleView - IamXiaRui

声明:XiaRui's Zone|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - Android:会呼吸的悬浮气泡


在能驾驭的领域 做个自由的行者