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方法進行繪製。
關於這個塞貝爾曲線以及黏著效果的實現,可以參考一下
以下是程式碼解析(主要的方法):
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溼溼水啊!慢慢積累慢慢進步!