1.前言

前天的浏览 GitHub 时发现一个模仿 Gif 的 Loading 特效的项目,感觉效果很不错,也比较有创意,如下:



GitHub 上好几个做这个效果的项目,但是很少有完全实现的,有的还有 Bug,于是花了 2 天实现了一下。

效果如下:



 

2. 分析

实现要求:

  • 叶子
  • 随机产生
  • 飘动轨迹为正弦函数,并且随机振幅
  • 飘动时伴随自旋转,更符合物理规律
  • 遇到进度条似乎是融入的
  • 风扇
  • 可旋转
  • Loading == 100% 时显示一个动画
  • 细节
  • 风扇和叶子自适应 View 大小
  • 叶子在视觉上不能飘出 RountRect 边界

3. 核心实现

3.1 随机产生叶子

本质是事先产生一定数量叶子,这些叶子的漂动时的振幅、相位、旋转方向等等都是随机的,并且飘动是周期性地即叶子飘动到最左边时,又重新回到最右边。

Leaf 类:

private class Leaf{
        float x,y;//坐标
        AmplitudeType type;//叶子飘动振幅
        int rotateAngle;//旋转角度
        RotateDir rotateDir;//旋转方向
        long startTime;//起始时间
        int n;//初始相位
    }
复制代码

Leaf 生成方法:

Leaf generateLeaf(){
        Leaf leaf = new Leaf();
        //随机振幅
        int randomType = mRandom.nextInt(3);
        switch (randomType){
          case 0:
            //小振幅
            leaf.type = AmplitudeType.LITTLE;
            break;
          case 1:
            //中等振幅
            leaf.type = AmplitudeType.MIDDLE;
            break;
          default:
            //大振幅
            leaf.type = AmplitudeType.BIG;
            break;
        }
        //随机旋转方向
        int dir = mRandom.nextInt(2);
        switch (dir){
          case 0:
            //逆时针
            leaf.rotateDir = RotateDir.ANTICLOCKWISE;
            break;
          default:
            //顺时针
            leaf.rotateDir = RotateDir.CLOCKWISE;
            break;
        }
        //随机起始角度
        leaf.rotateAngle = mRandom.nextInt(360);
        leaf.n = mRandom.nextInt(20);
        mAddTime += mRandom.nextInt((int)mLeafFloatTime);
        leaf.startTime = System.currentTimeMillis() + mAddTime;
        return leaf;
    }
复制代码

3.2 叶子飘动轨迹为正弦函数

确定 Leaf 在某个时刻的坐标 ( x , y ):

/**
      * 获取叶子的(x,y)位置
      * @param leaf 叶子
      * @param currentTime 当前时间
      */
    private void getLeafLocation(Leaf leaf,long currentTime){
        long intervalTime = currentTime - leaf.startTime;//飘动时长
        if (intervalTime <= 0){
          // 此 Leaf 还没到飘动时间
          return;
        }else if (intervalTime > mLeafFloatTime){
          // Leaf 的飘动时间大于指定的飘动时间,即叶子飘动到了最左边,应回到最右边
          leaf.startTime = currentTime + new Random().nextInt((int)mLeafFloatTime);
        }
        // 计算移动因子
        float fraction = (float) intervalTime / mLeafFloatTime;
        leaf.x = (1-fraction)*mProgressLen;
        leaf.y = getLeafLocationY(leaf);

        if (leaf.x <= mYellowOvalHeight / 4){
          //叶子飘到最左边,有可能会超出 RoundRect 边界,所以提前特殊处理
          leaf.startTime = currentTime + new Random().nextInt((int)mLeafFloatTime);
          leaf.x = mProgressLen;
          leaf.y = getLeafLocationY(leaf);
        }
    }
复制代码

要想让 Leaf 飘动轨迹为正弦函数,关键在于确定 Leaf 的 Y 轴坐标:

/**
     * 获取叶子的Y轴坐标
     * @param leaf 叶子
     * @return 经过计算的叶子Y轴坐标
     */
    private float getLeafLocationY(Leaf leaf){
        float w = (float) (Math.PI * 2 / mProgressLen);//角频率
        float A;//计算振幅值
        switch (leaf.type){
            case LITTLE:
                A = mLeafLen/3;
                break;
            case MIDDLE:
                A = mLeafLen*2/3;
                break;
            default:
                A = mLeafLen;
                break;
        }
        // (mHeight-mLeafLen)/2 是为了让 Leaf 的Y轴起始位置居中
        return (float) (A * Math.sin(w * leaf.x + leaf.n)+(mHeight-mLeafLen)/2);
    }
复制代码

3.3 叶子飘动时自旋转

这里就涉及到了 Leaf 的绘制,其实 Gif 中的叶子和风扇都可以使用 Canves 直接绘制图案,但是这样就会有两个问题:

  1. 难画:想要画出满意图形,并且还要旋转、缩放、平移可要下一番功夫。
  2. 灵活性低:如果想换其他样式又得重新设计绘制过程。

因此这里采用 Canves.drawBitmap() 的方式绘制,直接使用已有的图片作为叶子和风扇,同时利用 Canves.drawBitmap() 的一个重载的方法可以很方便的实现旋转、缩放、平移:

void drawBitmap(Bitmap bitmap,  Matrix matrix, Paint paint) ;
复制代码

就是通过这里的 Matrix 矩阵,它内部封装了 postScale()postTranslatepostRotate() 等方法,可以帮助我们快速的对 Bitmap 进行旋转、缩放、平移还有其他操作。使用时要记得配合 Canves 的 save()restore() 使用,否则达不到想要的效果。

对这方面不熟的朋友可以看看 HenCoder 的自定义 View 教学 1-4

绘制 Leaf 的方法:

private void drawLeaves(Canvas canvas){
      long currentTime = System.currentTimeMillis();
      for (Leaf leaf : mLeafList) {
          if (currentTime > leaf.startTime && leaf.startTime != 0){
            // 获取 leaf 当前的坐标
            getLeafLocation(leaf,currentTime);
            canvas.save();
            Matrix matrix = new Matrix();
            // 缩放 自适应 View 的大小
            float scaleX = (float) mLeafLen / mLeafBitmapWidth;
            float scaleY = (float) mLeafLen / mLeafBitmapHeight;
            matrix.postScale(scaleX,scaleY);
            // 位移
            float transX = leaf.x;
            float transY = leaf.y;
            matrix.postTranslate(transX,transY);
            // 旋转
            // 计算旋转因子
            float rotateFraction = ((currentTime - leaf.startTime) % mLeafRotateTime)
              /(float)mLeafRotateTime;
            float rotate;
            switch (leaf.rotateDir){
              case CLOCKWISE:
                //顺时针
                rotate = rotateFraction * 360 + leaf.rotateAngle;
                break;
              default:
                //逆时针
                rotate = -rotateFraction * 360 + leaf.rotateAngle;
                break;
            }
            // 旋转中心选择 Leaf 的中心坐标
            matrix.postRotate(rotate,transX + mLeafLen / 2,transY + mLeafLen / 2);
            canvas.drawBitmap(mLeafBitmap,matrix,mBitmapPaint);
            canvas.restore();
          }
    }
复制代码

3.4 Loading == 100% 出现动画

增加一个判断字段 isLoadingCompleted ,在 onDraw() 中选择对应绘制策略。

isLoadingCompleted 在 setProgress() 中根据 progress 设置:

/**
     * 设置进度(自动刷新)
     * @param progress 0-100
     */
    public void setProgress(int progress){
        if (progress < 0){
            mProgress = 0;
        }else if (progress > 100){
            mProgress = 100;
        }else {
            mProgress = progress;
        }
        if (progress == 100){
            isLoadingCompleted = true;
        }else {
            isLoadingCompleted = false;
        }
        // 255 不透明
        mCompletedFanPaint.setAlpha(255);
        postInvalidate();
    }
复制代码

LeavesLoading.onDraw() 部分实现:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        ......
        if (isLoadingCompleted){
            //绘制加载完成特效
            drawCompleted(canvas);
        }else {
            //绘制扇叶
            drawFan(canvas,mFanLen,mBitmapPaint);
        }
        //刷新
        postInvalidate();
    }
复制代码

drawCompleted() 实现:

private void drawCompleted(Canvas canvas) {
        // 每次绘制风扇透明度递减10
        int alpha = mCompletedFanPaint.getAlpha() - 10;
        if (alpha <= 0){
            alpha = 0;
        }
        mCompletedFanPaint.setAlpha(alpha);
        // 文字透明度刚好与风扇相反
        mCompletedTextPaint.setAlpha(255-alpha);
        // 计算透明因子
        float fraction = alpha / 255f;
        // 叶片大小 和 文字大小 也是相反变化的
        float fanLen = fraction * mFanLen;
        float textSize = (1 - fraction) * mCompletedTextSize;
        mCompletedTextPaint.setTextSize(textSize);
         //测量文字占用空间
        Rect bounds = new Rect();
        mCompletedTextPaint.getTextBounds(
                LOADING_COMPLETED,
                0,
                LOADING_COMPLETED.length(),
                bounds);
      	// 与 drawLeaf() 相似,不再赘述
        drawFan(canvas, (int) fanLen, mCompletedFanPaint);
        //画文字
        canvas.drawText(
                LOADING_COMPLETED,
                0,
                LOADING_COMPLETED.length(),
                mFanCx-bounds.width()/2f,
                mFanCy+bounds.height()/2f,
                mCompletedTextPaint);
    }
复制代码

流程:计算风扇和文字透明度 -> 计算风扇和文字大小以及文字占用空间 -> 绘制 ,注释写得比较清楚就不赘述了。

4. 结束