1. 程式人生 > >Android具有粘性的小球,跌落反彈形成文字的動畫效果

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效果(專案地址)

這裡寫圖片描述
這裡寫圖片描述

結構分析

這個動畫包括了三個過程:
  1.小球旋轉放大,其中還有震動效果
  2.小球縮小衍生水滴,迅速跌落
  3.文字彈出展現

在結構上是主要是通過controller對三個animator進行一個控制,並作為其中的資訊傳遞媒介連結各個animator,將canvas分發給animator進行繪製。而view通過controller的初始化來達到展示動畫的效果。其中,動畫的效果是由AnimationSet進行順序的控制。

這裡寫圖片描述

下面就通過程式碼的結構來分析一下整個的一個動畫過程,其中分成三個部分,也就是三個animation的一個繪製的過程。

圓點

圓點是由抽象類Circle.java進行衍生的,正在進行運動的是WolfCircle.java,靜止不動的六個小球是RabbitCircle.java。還有之後的水滴BeadCircle.
這裡寫圖片描述
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溼溼水啊!慢慢積累慢慢進步!