前言##
通过之前的《Android 动画总结》,对常用的Android动画有了一个整体认识。但是,之前的内容都是概念性的,所列的demo也没有实际意义。这里就通过两个实例了解一下如何在 实际开发中运用Android 动画来实现一些良好的用户体验。
这里通过展示两个常见且较为容易实现的动画效果:
仿支付宝支付完成动画
购物车添加商品动画
动画实战##
仿支付宝支付完成动画###
首先看一下效果图。
模拟器截取动画真是醉了
支付成功动画####
关于这个支付成功的动画,通过之前所说的帧动画(Frame Animation)是可以实现的,但前提是需要完善的图片资源。如果UI 没有提供图片资源,那是否就束手无策了呢?其实不然,对于这种构图比较简单的动画,还是可以通过属性动画实现的。
观察一下这个动画,首先绘制一个圆形,圆形完成的同时绘制“对号”,动画完成的瞬间再执行变色和整个view缩放的效果,同时修改button上的文字。
那么我们的动画实现也是按照这个顺序:
public void loadCircle(int mRadius) {
mRadius = mRadius <= 0 ? DEFAULT_RADIUS : mRadius;
this.mRadius = mRadius - PADDING;
if (null != mAnimatorSet && mAnimatorSet.isRunning()) {
return;
}
reset();
reMeasure();
Log.e("left", "R is -------->" + mRadius);
mCircleAnim = ValueAnimator.ofInt(0, 360);
mLineLeftAnimator = ValueAnimator.ofFloat(0, this.mRadius / 2f);
mLineRightAnimator = ValueAnimator.ofFloat(0, this.mRadius / 2f);
Log.i(TAG, "mRadius" + mRadius);
mCircleAnim.setDuration(700);
mLineLeftAnimator.setDuration(350);
mLineRightAnimator.setDuration(350);
mCircleAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mDegree = (Integer) animation.getAnimatedValue();
invalidate();
}
});
mLineLeftAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mLeftValue = (Float) valueAnimator.getAnimatedValue();
Log.e("left", "-------->" + mLeftValue);
invalidate();
}
});
mLineRightAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mRightValue = (Float) animation.getAnimatedValue();
invalidate();
}
});
mAnimatorSet.play(mCircleAnim).before(mLineLeftAnimator);
mAnimatorSet.play(mLineRightAnimator).after(mLineLeftAnimator);
mAnimatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
stop();
if (mEndListner != null) {
mEndListner.onCircleDone();
SuccessAnim();
}
}
});
mAnimatorSet.start();
}
我们定义了mCircleAnim,mLineLeftAnimator和mLineRightAnimator 三个属性动画,并依次播放三个动画,同时在各自的update方法中获取动画当前的变化值,同时调用invalidate() ,这样就会不断执行onDraw 方法,不断绘制新的视图,产生动画效果。而在动画执行结束的时候,可以执行接口中定义的监听动画结束的方法,这里这么做是为了方便在Activity中执行一些动画结束后的操作。同时执行了当前view 大小缩放的动画SuccessAnim()。
这里重点看一下onDraw方法,这个方法可以说是实现整个动画最核心的内容。
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mRectF.left = mCenterX - mRadius;
mRectF.top = mCenterY - mRadius;
mRectF.right = mCenterX + mRadius;
mRectF.bottom = mCenterY + mRadius;
canvas.drawArc(mRectF, 0, mDegree, false, mCirclePanit);
canvas.drawLine(mCenterX - mRadius / 2, mCenterY,
mCenterX - mRadius / 2 + mLeftValue, mCenterY + mLeftValue, mLinePaint);
canvas.drawLine(mCenterX, mCenterY + mRadius / 2,
mCenterX + mRightValue, mCenterY + mRadius / 2 - (3f / 2f) * mRightValue, mLinePaint);
}
1.第7行canvas.drawArc 的实现很容易理解,我们在之前的属性动画中,实现一个初始值为0,结束值为360 的ValueAnimator,同时在其执行的过程中,不断将中间值赋给mDegree,这样mDegree值就从0变化到360,从而实现了一个圆形绘制。
2.第8行中绘制的是”对号“中左边的短线。第10行绘制的是右边向上的长线。这里的思路结合下图很容易理解(这只是一个示意图,实际绘制时右边长线的斜率由圆心、半径多个值所决定)。
两点确定一条直线,就是这么简单。
之前说过,属性动画的运行机制是通过不断地对值进行操作来实现的,而初始值和结束值之间的动画过渡就是由ValueAnimator这个类来负责计算的。
这里我们就是利用这个原理实现了这个动画。
理解了这点,下面支付失败的动画,也是相似的原理,中间绘制的内容不再是一个“对号”,而是一个巨大的X。这个很容易实现,以圆心为坐标轴中点,在四个象限45度方向绘制四个点,分别作为起始点和终点即可,结合代码很容易理解。
int mViewWidth = getWidth();
int mViewHeight = getHeight();
mCenterX = mViewWidth / 2;
mCenterY = mViewHeight / 2;
temp = mRadius / 2.0f * factor;
Path path = new Path();
path.moveTo(mCenterX - temp, mCenterY - temp);
path.lineTo(mCenterX + temp, mCenterY + temp);
pathLeftMeasure = new PathMeasure(path, false);
path = new Path();
path.moveTo(mCenterX + temp, mCenterY - temp);
path.lineTo(mCenterX - temp, mCenterY + temp);
pathRightMeasure = new PathMeasure(path, false);
绘制方法onDraw
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mRectF.left = mCenterX - mRadius;
mRectF.top = mCenterY - mRadius;
mRectF.right = mCenterX + mRadius;
mRectF.bottom = mCenterY + mRadius;
canvas.drawArc(mRectF, 0, mDegree, false, mCirclePanit);
if (mLeftPos[1] > (mCenterY - temp) && mRightPos[1] > (mCenterY - temp)) {
canvas.drawLine(mCenterX - temp, mCenterY - temp, mLeftPos[0], mLeftPos[1], mLinePaint);
canvas.drawLine(mCenterX + temp, mCenterY - temp, mRightPos[0], mRightPos[1], mLinePaint);
}
}
这里的mLeftPos和mRightPos,就是属性动画由初始值过渡到结束值时,中间变化值所对应的位置。具体可结合源码理解。
最后再说一下,使用帧动画的方式实现这个动画,为了适配不同的机型,必然需要多份不同分辨率的图片,适配效果不得而知,同时也会增加应用的大小。但是使用帧动画就不同了,把握好整个view的大小,适配起来应该相对会容易一些。同时应用大小也不会变化,同时可扩展性也更高。
购物车添加商品动画###
购物添加动画可以说是,属性动画最经典的例子;很早以前就有人实现了。这里就从学习属性动画的角度出发加以理解。
这里轨迹的绘制并不完全是靠属性动画完成,很大一部分的功劳要算在贝塞尔曲线的身上。关于贝塞尔曲线的理解,可以看看这里。
private void addToCarAnimation(ImageView goodsImg) {
//获取需要进行动画的ImageView
final ImageView animImg = new ImageView(mContext);
animImg.setImageDrawable(goodsImg.getDrawable());
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(100, 100);
shellLayout.addView(animImg, params);
//
final int shellLocation[] = new int[2];
shellLayout.getLocationInWindow(shellLocation);
int animImgLocation[] = new int[2];
goodsImg.getLocationInWindow(animImgLocation);
int carLocation[] = new int[2];
carImage.getLocationInWindow(carLocation);
//
// 起始点:图片起始点-父布局起始点+该商品图片的一半-图片的marginTop || marginLeft 的值
float startX = animImgLocation[0] - shellLocation[0] + goodsImg.getWidth() / 2 - DpConvert.dip2px(mContext, 10.0f);
float startY = animImgLocation[1] - shellLocation[1] + goodsImg.getHeight() / 2 - DpConvert.dip2px(mContext, 10.0f);
// 商品掉落后的终点坐标:购物车起始点-父布局起始点+购物车图片的1/5
float endX = carLocation[0] - shellLocation[0] + carImage.getWidth() / 5;
float endY = carLocation[1] - shellLocation[1];
//控制点,控制贝塞尔曲线
float ctrlX = (startX + endX) / 2;
float ctrlY = startY - 100;
Log.e("num", "-------->" + ctrlX + " " + startY + " " + ctrlY + " " + endY);
Path path = new Path();
path.moveTo(startX, startY);
// 使用二阶贝塞尔曲线
path.quadTo(ctrlX, ctrlY, endX, endY);
mPathMeasure = new PathMeasure(path, false);
ObjectAnimator scaleXanim = ObjectAnimator.ofFloat(animImg, "scaleX", 1, 0.5f, 0.2f);
ObjectAnimator scaleYanim = ObjectAnimator.ofFloat(animImg, "scaleY", 1, 0.5f, 0.2f);
ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 这里这个值是中间过程中的曲线长度(下面根据这个值来得出中间点的坐标值)
float value = (Float) animation.getAnimatedValue();
// 获取当前点坐标封装到mCurrentPosition
// mCurrentPosition此时就是中间距离点的坐标值
mPathMeasure.getPosTan(value, mCurrentPosition, null);
// 移动的商品图片(动画图片)的坐标设置为该中间点的坐标
animImg.setTranslationX(mCurrentPosition[0]);
animImg.setTranslationY(mCurrentPosition[1]);
}
});
valueAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
goodsCount++;
if (goodsCount < 100) {
carCount.setText(String.valueOf(goodsCount));
} else {
carCount.setText("99+");
}
// 把执行动画的商品图片从父布局中移除
shellLayout.removeView(animImg);
shopCarAnim();
}
});
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.setDuration(500);
animatorSet.setInterpolator(new AccelerateInterpolator());
animatorSet.playTogether(scaleXanim, scaleYanim, valueAnimator);
animatorSet.start();
}
我们分别获取了整个布局在手机屏幕中的位置: shellLocation
所要进行动画的图片在手机屏幕中的位置:animLocation
购物车在整个手机屏幕中的位置:carLocation
并由这三个值及动画图片的大小布局等因素确定了三个点:
起始位置(startX,startY)、结束位置(endX,endY)和控制点(CtrlX,CtrlY)。
并由这三个点确定了一个二阶贝塞尔曲线 path。
同时使用PathMeasure 类测量这条path,同时使用它的长度length 作为属性动画中的终点值。
ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mPathMeasure.getLength());
在动画的update回调方法中,我们获取这个长度过渡变化的中间值,然后我们使用了一个很重要的方法
mPathMeasure.getPosTan(value, mCurrentPosition, null);
可以看一下,这个方法的具体实现
/**
* Pins distance to 0 <= distance <= getLength(), and then computes the
* corresponding position and tangent. Returns false if there is no path,
* or a zero-length path was specified, in which case position and tangent
* are unchanged.
*
* @param distance The distance along the current contour to sample
* @param pos If not null, eturns the sampled position (x==[0], y==[1])
* @param tan If not null, returns the sampled tangent (x==[0], y==[1])
* @return false if there was no path associated with this measure object
*/
public boolean getPosTan(float distance, float pos[], float tan[]) {
if (pos != null && pos.length < 2 ||
tan != null && tan.length < 2) {
throw new ArrayIndexOutOfBoundsException();
}
return native_getPosTan(native_instance, distance, pos, tan);
}
这个方法有三个参数
第一个参数,MeasuePath 所测量的path的长度的当前值,也就是我们动画变化中的过渡值。
第二个参数是个数组,如果不为null,就被赋予当前值所对应位置的坐标。
第三个参数也是数组,如果不为null,就被赋予当前值所对应的切线坐标。(这个没搞懂神马意思)
如果这个MeasurePath所测量的path不存在,就会返回false。
最终这个方法会执行一个native方法,具体实现我们就不得而知了。
回到我们的代码,这里我们第二参数,传入了一个二维的int 数组,这样随着path总长度的流逝,我们就依次获取了这条path线路上的坐标点mCurrentPosition。然后通过设置动画图片animImg 的位置就实现了动画效果。
这里重点说了一下整体的实现思路,实际中还有很多细节值得考虑,尤其是在切换为GridView模式的时候,动画起点在左右两边是有差异的,具体细节可参考源码自己思考。
总结###
看到这里可以发现,ValueAnimator 这个类虽然很简单,但是非常有用。他帮我们实现了一种属性值从开始到结束的自然过渡,而且可以获取到过渡过程的中间值,这样就很方便我们结合这个过渡值做各种各样的动画了。
最后 github 源码欢迎star & fork