作为一个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
这个动画包括了三个过程:
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方法,这个是用来控制小球下跌的动作。
这个动画负责圆点的旋转,利用度数来绘制六个圆点,同时通过度数来绘制运动的圆点,利用塞贝尔曲线来绘制其中粘性的效果,所以这里主要是利用度数来进行圆点之间的一个距离的判定。
这里主要的难点是,由于位置都是根据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;
}
这个动画主要将运动的小球缩小,形成水滴下落的效果,这里的难点主要是一个水滴下落后的一个压缩的过程,让用户看起来的感觉是这个水滴下落后确实压扁了。这里就是通过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);
}
});
}
这个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;
}
}
}
最后就是用一个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);
}
最后通过一个实现view来装载controller。这个可以看源码部分。这个项目就到此为止了
其实,自定义view离不开canvas,path,paint,value animator,animation等等,只要将这些api熟练运用,加上由好的代码规范和逻辑,自定义view湿湿水啊!慢慢积累慢慢进步!