Android具有粘性的小球,跌落反弹形成文字的动画效果

  作为一个android开发者,看到一个好的ui无疑是赏心悦目的。对于用户来,给予一个美观的ui也是非常重要的。此篇文章分析主要学习如何自定义view,同时也逐步探求一个好的loading的设计,以及animation的一种代码的设计。

  android自定义view是一个android开发者进阶的一个重要的里程碑,其实这也是离不开Animation,Animator,canvas,path,paint等等这几个类和API,所以当遇到感觉困难的地方,android官网api一定相当有帮助。掌握了这几个类,基本上酷炫的view只要有些灵感,相信是难不倒了。

  下面这一个AdhesiveLodingView工程是由一个小球循环绕圈,与周边小球形成粘性效果,放大后过重形成水珠跌落,水珠反弹形成文字的动画效果。涉及了如下几个类:
  1.Path和Canvas、Paint
  2.ValueAnimator、AnimationSet

AdhesiveLoadingView效果(项目地址)

Android具有粘性的小球,跌落反弹形成文字的动画效果_第1张图片
Android具有粘性的小球,跌落反弹形成文字的动画效果_第2张图片

结构分析

这个动画包括了三个过程:
  1.小球旋转放大,其中还有震动效果
  2.小球缩小衍生水滴,迅速跌落
  3.文字弹出展现

在结构上是主要是通过controller对三个animator进行一个控制,并作为其中的信息传递媒介链接各个animator,将canvas分发给animator进行绘制。而view通过controller的初始化来达到展示动画的效果。其中,动画的效果是由AnimationSet进行顺序的控制。

Android具有粘性的小球,跌落反弹形成文字的动画效果_第3张图片

代码分析(项目地址)

下面就通过代码的结构来分析一下整个的一个动画过程,其中分成三个部分,也就是三个animation的一个绘制的过程。

圆点

圆点是由抽象类Circle.java进行衍生的,正在进行运动的是WolfCircle.java,静止不动的六个小球是RabbitCircle.java。还有之后的水滴BeadCircle.
Android具有粘性的小球,跌落反弹形成文字的动画效果_第4张图片
WolfCircle具有runTo()方法,这个是更改绘制角度,实现圆点运动
RabbitCircle具有state状态,这个是用来控制当状态改变,作出不同的绘制效果。
BeadCircle具有drop方法,这个是用来控制小球下跌的动作。

1. LoopCircleAnimator

  这个动画负责圆点的旋转,利用度数来绘制六个圆点,同时通过度数来绘制运动的圆点,利用塞贝尔曲线来绘制其中粘性的效果,所以这里主要是利用度数来进行圆点之间的一个距离的判定。
  这里主要的难点是,由于位置都是根据canvas的rotate进行旋转绘制,而塞贝尔曲线绘制的是在两个圆点之间,所以在旋转的时候如果位置计算不正取就会偏移。而这里通过了调整小偏移来弥补这个问题,后期进行处理。
  绘制圆点通过Canvas的drawCircle方法进行绘制。
  关于这个塞贝尔曲线以及黏着效果的实现,可以参考一下这个博客贝塞尔曲线应用及QQ气泡拖动原理实践。主要用到的方法也就是Path的quadTo,lineTo的方法。
以下是代码解析(主要的方法):

public LoopCircleAnimator(View view) {
    mView = view;

    initComponent();
    initAnimator();

    mPath = new Path();
}

/**
 * 设置六个圆点以及运动的圆点
 */
private void initComponent() {

    startX = Config.START_X;
    startY = Config.START_Y;
    centerX = Config.CENTER_X;
    centerY = Config.CENTER_Y;
    bigR = Config.BIG_CIRCLE_RADIUS;

    // create 6 rabbits
    int r = Math.min(mView.getWidth(), mView.getHeight()) / 20;
    int degree = 0;
    for (int i = 0; i < Config.RABBIT_NUM; i++) {
        mRabbits.add(new RabbitCircle(startX, startY, r, degree));
        degree += Config.DEGREE_GAP;
    }

    // create wolf
    if (mWolf == null) {
        mWolf = new WolfCircle(startX, startY, (int)(rate * r), 0);
    }

}

/**
 * 设置animator的参数
 */
private void initAnimator() {

    this.setIntValues(0, 360);
    this.setDuration(DURATION);
    this.setInterpolator(new AccelerateDecelerateInterpolator());
    this.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            int degree = (int) animation.getAnimatedValue();
            startActivities(degree);

            mView.invalidate();
        }
    });
}

/**
 * 开始运动,目的是旋转一圈,所以是从0-360度
 * @param degree
 */
private void startActivities(int degree) {
    mWolf.runTo(degree);
    // 运动小球增大
    mWolf.bigger(degree / Config.DEGREE_GAP * 2);
    // 这里有一个细节动作,当运动小球靠近时,静止的圆点会进行一个震动.震动是通过设置其状态来完成的
    for (RabbitCircle rabbit : mRabbits) {
        if (mAliveRabbits < 6 && rabbit.getState() == RabbitCircle.DIED
                && rabbit.getDegree() < degree) {
            rabbit.setState(RabbitCircle.ALIVE);
            mAliveRabbits++;
        }
        if (mWolf.getDegree() - rabbit.getDegree() > 0 && mWolf.getDegree() - rabbit.getDegree() <= 40) {
            float deg = (mWolf.getDegree() - rabbit.getDegree()) / 2f;
            mPathDegree = (int) (deg + rabbit.getDegree());
            int distance = (int) (Math.sin(Math.PI * deg / 180) * bigR);
            updatePath(distance);
        }

        if (rabbit.getDegree() - mWolf.getDegree() > 0 && rabbit.getDegree() - mWolf.getDegree() < 60) {
            rabbit.setState(RabbitCircle.DANGER);
        } else if (rabbit.getState() == RabbitCircle.DANGER) {
            rabbit.setState(RabbitCircle.ALIVE);
        }
    }
}

/**
 * 黏着效果实现,这里的黏着效果是由4个点完成的,外加两个控制点.
 *
 * @param distance
 */
private void updatePath(int distance){
    // TODO 塞贝尔曲线还有一些问题,由于是通过旋转角度实现两个圆点之间的链接,所以会有偏差,现在暂且通过微调解决
    mPath.reset();
    int x1 = startX - distance;
    int y1 = startY - mRabbits.get(0).getRadius() + 2;

    int x2 = startX - distance;
    int y2 = startY + mRabbits.get(0).getRadius() + 1;

    int x3 = startX + distance;
    int y3 = startY + mWolf.getRadius() + 1;

    int x4 = startX + distance;
    int y4 = startY - mWolf.getRadius() + 2;

    int controlX1T4 = startX;
    int controlY1T4 = y1 + distance;
    int controlX2T3 = startX;
    int controlY2T3 = y2 - distance;

    mPath.moveTo(x1, y1);
    mPath.lineTo(x2, y2);
    mPath.quadTo(controlX2T3, controlY2T3, x3, y3);
    mPath.lineTo(x4, y4);
    mPath.quadTo(controlX1T4, controlY1T4, x1, y1);
    mPath.close();
}

/**
 * 绘制圆点
 * @param canvas
 * @param paint
 */
public void draw(Canvas canvas, Paint paint) {

    for (Circle rabbit : mRabbits) {
        rabbit.draw(canvas, paint, centerX, centerY);
    }

    mWolf.draw(canvas, paint, centerX, centerY);

    if (mPathDegree > 0) {
        drawPath(canvas, paint);
    }

}

/**
 * 绘制黏着部分
 * @param canvas
 * @param paint
 */
public void drawPath(Canvas canvas, Paint paint) {
    paint.setColor(Color.BLACK);
    canvas.save();
    canvas.rotate(mPathDegree, centerX, centerY);
    canvas.drawPath(mPath, paint);
    canvas.restore();
    mPathDegree = -1;
}

2. SmallAndDropAnimator

  这个动画主要将运动的小球缩小,形成水滴下落的效果,这里的难点主要是一个水滴下落后的一个压缩的过程,让用户看起来的感觉是这个水滴下落后确实压扁了。这里就是通过ValueAnimator的一个参数来进行一个压扁的一个控制。还是从代码上看一下。主要看一下这个动画的设置。

/**
 * 初始化动画的配置
 */
public void initAnim() {
    this.setDuration(DURATION);
    // flatten distance 水滴放大的距离
    final int flattenDis = mixDis / 4;
    final int preFlattenDis = mixDis - flattenDis;
    this.setInterpolator(new AccelerateInterpolator(1.5f));
    // 由于要形成一个下落压缩的效果,所以在VALUE的设置上通过参数高->低->高->恢复这样的效果来实现
    this.setIntValues(Config.START_Y, Config.START_Y + mixDis, mDis - preFlattenDis, mDis + flattenDis);
    this.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            int curY = (int) animation.getAnimatedValue();
            if (curY < mDis  - preFlattenDis) {
                if (curY <= Config.START_Y + mixDis) {
                    // 缩小
                    mCircle.bigger(mixDis - curY + Config.START_Y);
                    // 放大
                    mBead.bigger(curY - Config.START_Y);
                }
                // 下落
                mBead.drop(curY);
            } else if (curY < mDis){
                // 压缩
                mBead.flatten(mDis + flattenDis - curY);
                // 下落
                mBead.drop(curY);
            } else {
                // 压缩
                mBead.flatten(mDis + flattenDis - curY);
            }
            mView.invalidate();
        }
    });
    this.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            mBead.reset(Config.START_X, Config.START_Y);
        }
    });

}

3. TextAnimator

  这个Animator主要是实现了字体弹出,向外移动的效果,这里的一个弹出的效果也是通过ValueAnimator的参数去设置的。
  绘制文字及他们向外移动的效果,主要是通过paint.measureText进行文字宽度的一个测量,从而可以进行移动。
  而向外围移动的这个是比较坑爹的,原因是canvas.drawText本身是根据左下点为起点进行绘制,将其移动到中心点进行绘制的时候字体原本的距离就会产生变化,也就是缩小了。这样就使得字体变得难看。
  解决方法:通过设置绘制文字的一个align来进行文字的绘制,同时测量文字的宽度,进行相应的移动。达到一个向外围移动的效果。
下面看一下关键的代码块:

/**
 * 初始化参数
 */
private void initConfig() {
    initWord();
    curIndex = 0;
    mTextSize = mView.getWidth() / (STR.length() - 2);
    mBaseLine = Config.BASELINE;
    mScaleSize = 30;
    mPaint = new Paint();
    mPaint.setTextSize(mTextSize);
    texts = new Text[word.length];
    // 初始化各个字母运动的方向
    boolean toRight = false;
    for (int i = 0; i < word.length; i++) {
        // 向左运动
        if (!toRight) {
            texts[i] = new Text(word[i], 0, Config.CENTER_X, Text.DIRECTION_LEFT);
            toRight = true;
        } else {
            if (i + 1 == word.length) {
                // 居中不动
                texts[i] = new Text(word[i], 0, Config.CENTER_X, Text.DIRECTION_CENTER);
            } else {
                // 向右运动
                texts[i] = new Text(word[i], 0, Config.CENTER_X, Text.DIRECTION_RIGHT);
            }
            toRight = false;
        }

    }
}

/**
 * 初始化word的顺序,例如loading->lgonaid,方便进行动画
 */
protected void initWord() {
    word = new String[STR.length()];
    int a = 0;
    int b = word.length - 1;
    int i = 0;
    while (a <= b) {
        if (a == b) {
            word[i] = String.valueOf(STR.charAt(a));
        } else {
            word[i] = String.valueOf(STR.charAt(a));
            word[i + 1] = String.valueOf(STR.charAt(b));
        }
        a++;
        b--;
        i += 2;
    }
}

protected void initAnim() {
    // 通过设置参数来实现字体的大小变化,从而实现弹出放大的效果.
    this.setIntValues(0, mTextSize, mTextSize + mScaleSize, 0, mTextSize + mScaleSize / 2, mTextSize);
    this.setDuration(DURATION);
    this.setInterpolator(new AccelerateInterpolator());
    this.addUpdateListener(new AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            curTextSize = (int) animation.getAnimatedValue();
            if (curIndex > 0) {
                texts[curIndex - 1].setSize(curTextSize);
            }
            mView.invalidate();
        }
    });
    this.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationStart(Animator animation) {
            // 定格最后字母的大小
            if (curIndex > 0 && curIndex <= word.length) {
                texts[curIndex - 1].setSize(mTextSize);
            }
            int tmpIndex = curIndex;
            while (tmpIndex - 3 >= 0) {
                texts[tmpIndex - 3].setExtraX(mPaint.measureText(texts[curIndex - 1].content));
                tmpIndex -= 2;
            }
            curIndex++;
            if (curIndex > word.length) {
                curIndex = 1;
                resetText();
            }
        }
    });
}

public void draw(Canvas canvas, Paint paint) {
    if (curIndex < 1 || curIndex > word.length) {
        return;
    }
    for (int i = 0; i < curIndex; i++) {
        paint.setTextSize(texts[i].size);
        if (i == curIndex - 1) {
            paint.setTextAlign(Paint.Align.CENTER);
            // 绘制中间的字母
            canvas.drawText(texts[i].content, texts[i].x, mBaseLine, paint);
        } else {
            // 由于文字绘制的原点影响了文字的间距,因为文字的宽度都是通过align.right进行一个间距的计算的,所以
            // 当以中心为绘制原点的时候,相同的间距会变成原来的一半,这样就会导致间距缩小,尤其是小字体像i等,所以通过
            // 设置不同的绘制原点,加上不同的位移来解决这个问题.
            paint.setTextAlign(Paint.Align.LEFT);
                if (texts[i].direction == Text.DIRECTION_RIGHT) {
                    canvas.drawText(texts[i].content,
                            texts[i].x + paint.measureText(word[curIndex - 1]) / 2 + texts[i].extraX, mBaseLine, paint);
                } else if (texts[i].direction == Text.DIRECTION_LEFT) {
                    canvas.drawText(texts[i].content,
                            texts[i].x - paint.measureText(word[i]) - paint.measureText(word[curIndex - 1]) / 2 + texts[i].extraX, mBaseLine, paint);
                }
        }
    }
}

/**
 * 重置文字的距离
 */
private void resetText() {
    if (texts != null) {
        for (Text text : texts) {
            text.extraX = 0;
        }
    }
}

/**
 * 字母状态
 */
private class Text{
    public final static int DIRECTION_RIGHT = 1;
    public final static int DIRECTION_LEFT = 2;
    public final static int DIRECTION_CENTER = 3;

    public String content;
    public int size;
    public float x;
    public int direction;
    public float extraX;

    public Text(String content, int size , float x, int direction) {
        this.content = content;
        this.size = size;
        this.x = x;
        this.direction = direction;
        this.extraX = 0;
    }

    public void setSize(int size) {
        this.size = size;
    }

    public void setExtraX(float extra) {
        if (direction == DIRECTION_LEFT) {
            this.extraX -= extra;
        } else if (direction == DIRECTION_RIGHT) {
            this.extraX += extra;
        }
    }
}

4. controller

最后就是用一个animationset来将各个animator顺序调用了,同时将canvas分发出去。上Controller的关键代码

public Controller(View view) {

    initConfig(view);

    mAnimSet = new AnimatorSet();
    mLoopCircleAnim = new LoopCircleAnimator(view);
    mSapAnim = new SmallAndDropAnimator(view, mLoopCircleAnim.getWolf());
    mTextAnim = new TextAnimator(view);
    // 顺序播放
    mAnimSet.playSequentially(mLoopCircleAnim, mSapAnim, mTextAnim);
    mAnimSet.start();
    mAnimSet.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            mAnimSet.start();
        }
    });
}
public void draw(Canvas canvas, Paint paint) {
    mLoopCircleAnim.draw(canvas, paint);
    mSapAnim.draw(canvas, paint);
    mTextAnim.draw(canvas, paint);
}

5. 实现view

最后通过一个实现view来装载controller。这个可以看源码部分。这个项目就到此为止了

总结

其实,自定义view离不开canvas,path,paint,value animator,animation等等,只要将这些api熟练运用,加上由好的代码规范和逻辑,自定义view湿湿水啊!慢慢积累慢慢进步!

(项目地址)

你可能感兴趣的:(Android具有粘性的小球,跌落反弹形成文字的动画效果)