本篇文章已授权微信公众号 guolin_blog(郭霖)独家发布
效果预览
因GIF图压缩的原因动画看起来有些不流畅。
应用为加载框的效果:
使用方法
XML:
<com.example.ccy.bounceballview.BounceBallView
android:id="@+id/bbv1"
android:layout_width="match_parent"
android:layout_height="140dp"
android:background="#ffffff"
app:anim_duration="3300"
app:ball_count="15"
app:ball_delay="220"
app:bounce_count="3"/>
可用的属性:
属性名称 | 作用 |
---|---|
bounce_count | 小球弹跳次数 |
ball_color | 小球颜色 |
ball_count | 小球数量 |
ball_radius | 小球半径 |
ball_delay | 小球出现时间间隔(当小球数大于1时) |
anim_duration | 小球一次动画时长 |
physic_mode | 开启物理效果(下落加速上升减速) |
random_color | 开启小球颜色随机 |
random_radius | 开启小球大小随机(在基础大小上下浮动) |
random_path | 开启小球路径随机(在基础路径坐标上下浮动) |
也可以在代码中进行配置:
bbv1 = (BounceBallView) findViewById(R.id.bbv1);
bbv.config()
.ballCount(15)
.bounceCount(3)
.ballDelay(220)
.duration(3300)
.radius(15)
.isPhysicMode(true)
.isRamdomPath(true)
.isRandomColor(true)
.isRandomRadius(true)
.apply();
最后开启动画:
bbv1.start();
实现思路
源码地址:https://github.com/CCY0122/bounceballview
概况
该自定义控件主要是使用了Path和属性动画来完成的,比如小球的弹跳路径,是用Path和二次贝塞尔曲线来完成的,再比如这个小球“下落加速、上弹减速”的仿物理效果是利用了Path插值器(PathInterpolator)和三次贝塞尔曲线完成的,另外诸如颜色随机、小球大小随机这些都是通过监听属性动画来实现的。接下来会讲解下这些主要的实现思路
对于自定义View的其他基本流程,如属性的获取与设置、onMeasure的重写等本文不会多讲,想了解完整流程的话可以查看源码
小球路径的实现
通过效果图可以看到,只要确定了控件大小和小球弹跳次数(bounce_count),那么就能确定小球总体的弹跳路径。因此第一个核心点就是实现小球路径的Path。假设bounce_count值被设置为3,即弹跳3次,那么路径Path的效果图应该如下图所示:
要绘制出上述这样的路径,可以利用多个二次贝塞尔曲线拼接的方式实现。关于贝塞尔曲线,若想课后系统的学习,我很推荐徐医生的这篇文章:贝塞尔曲线开发的艺术
对于二次贝塞尔曲线,这里引用一张经典图:
一张图真是胜过千言万语啊~
在path中二次贝塞尔对应的方法是 path.quadTo (float x1, float y1, float x2, float y2)
,其中,x1/y1对应上图P1点,称做控制点,x2/y2对应P2点,就叫他终点把,P0点么就是当前path所在的起点。
由此可见,上图小球弹跳路径就是通过拼接4个二次贝塞尔曲线完成的,对于代码中对起点、终点、控制点的确定,为了方便理解,在看代码前,先附上一张图:
一张图真是胜过千言万语啊~
path的实现代码如下:
/**
* 初始化球体弹跳的路径
*/
private void initPath() {
path.reset();
float intervalX = (viewWidth - 2 * defaultPadding) / (bounceCount + 1); //每次弹跳的间距
PointF start = new PointF();//起点位置
PointF control = new PointF(); //贝塞尔控制点
PointF end = new PointF(); //贝塞尔结束点
start.x = defaultPadding;
start.y = viewHeight - defaultPaddingBottom;
float controlOffsetY = viewHeight * 0.6f; //控制点向上偏移量,0.6为调试值
float deltaY = (1.2f * viewHeight + controlOffsetY) / (bounceCount + 1); //控制点高度递减值,1.2为调试值
PathMeasure tempPathMeasure = new PathMeasure();
segmentLength = new float[bounceCount + 1];
for (int i = 0; i <= bounceCount; i++) {
control.x = start.x + intervalX * (i + 0.5f);
control.y = -controlOffsetY + deltaY * i;
end.x = start.x + intervalX * (i + 1);
end.y = start.y;
if (i == 0) {
path.moveTo(start.x, start.y);
}
if (i == bounceCount) {
end.y = viewHeight;
}
path.quadTo(control.x, control.y, end.x, end.y);
tempPathMeasure.setPath(path, false);
if (i == 0) { //第一次弹跳的上升阶段不画,记录弹跳一半长度(为效果更好,实际取值0.45)
skipLength = tempPathMeasure.getLength() * 0.45f;
}
segmentLength[i] = tempPathMeasure.getLength();
}
pathMeasure.setPath(path, false);
//以下是仿物理效果的动画插值器的创建。
if (interCreater == null) {
interCreater = new MultiDecelerateAccelerateInterpolator();
}
physicInterpolator = interCreater.createInterpolator(segmentLength);
}
配合图片看代码,其中PathMeasure是一个用于测量path各种数据的帮助类,通过它我们可以获取一段path的长度、path上某一点的坐标等等,非常有用。关于它的详细使用,可参考PathMeasure之迷径追踪
在代码中我们还记录了一个变量 skipLength
即对于上图中的AB段,因为我们的小球是直接从最高点开始下落的,所以AB段我们是要省略掉的。另外关键的一点是还记录了segmentLength[]数组,它的作用是用来后面创建插值器时用到的。对于上图来讲,segmentLength中分别存储着AC、AD、AE、AF四段路径长度。
有了弹跳路径,在onDraw中我们就可以利用PathMeasure来取出路径上某一个点的坐标,作为小球的绘制坐标了,而具体要取哪个点呢?我们可以通过属性动画来控制一个数值在一定时间内从0.0开始变化到1.0结束,将这个值作为比例值乘上路径总长度,就能得到当前时间要取的坐标点了,代码如下:
@Override
protected void onDraw(Canvas canvas) {
drawBounceBall(canvas);
}
private void drawBounceBall(Canvas canvas) {
for (int i = 0; i < ballCount; i++) {
canvas.save();
if (translateFraction[i] < (skipLength / pathMeasure.getLength())) {
continue;
}
//根据当前动画进度获取path上对应点的坐标和正切
pathMeasure.getPosTan(pathMeasure.getLength() * translateFraction[i],
pos,
tan);
//路径随机
if (isRandomBallPath) {
pos[0] *= randomTransRatioX[i];
pos[1] *= randomTransRatioY[i];
}
//颜色随机已在makeRandom里被应用
canvas.drawCircle(pos[0],
pos[1],
isRandomRadius ? randomRadius[i] : radius,
paint[i]);
canvas.restore();
}
}
在onDraw中,translateFraction[i]
记录着第i个小球当前的属性动画值。通过pathMeasure.getPosTan(pathMeasure.getLength()*translateFraction[i],pos,tan);
方法获取了当前比例值下路径上对应点的坐标值(存放在pos[2]数组里)和正切值(存放在tan[2]数组里),获取到了这个坐标点后,再经过一步路径随机的判断处理后,我们就可以通过canvas.drawCircle
来画出小球了。
那么接下来我们的重点就是属性动画了。
小球动画的实现
通过上述分析可知,小球的动画其实是对一个比例值做动画,让这个值在一定时间内从0.0开始变化到1.0,并且无限循环。同时通过监听动画,在动画过程中可以对小球的透明度、颜色、路径、大小做相应的改变,当然还有最重要的就是在动画监听过程中不断的调用invalidate()
来重绘小球的坐标位置。创建动画的代码如下:
private void createAnim(int duration) {
for (int i = 0; i < ballCount; i++) {
createTranslateAnim(i, duration, i * ballDelay);
}
}
private void createTranslateAnim(final int index, int duration, final int delay) {
if (translateAnim[index] == null) {
translateAnim[index] = ValueAnimator.ofFloat(0.0f, 1.0f);
translateAnim[index].setDuration(duration);
translateAnim[index].setRepeatCount(ValueAnimator.INFINITE);
translateAnim[index].setStartDelay(delay);
if (isPhysicsMode) {
translateAnim[index].setInterpolator(physicInterpolator);
} else {
translateAnim[index].setInterpolator(defaultInterpolator);
}
translateAnim[index].addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
super.onAnimationStart(animation);
makeRandom(index);
}
@Override
public void onAnimationRepeat(Animator animation) {
super.onAnimationRepeat(animation);
makeRandom(index);
}
});
translateAnim[index].addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//获取当前动画进度的比例值
translateFraction[index] = animation.getAnimatedFraction();
//以下是对小球透明度的处理
if (dealFromAlphaAnim(translateFraction[index]) != -1) {
paint[index].setAlpha(dealFromAlphaAnim(translateFraction[index]));
} else if (dealToAlphaAnim(translateFraction[index]) != -1) {
paint[index].setAlpha(dealToAlphaAnim(translateFraction[index]));
} else {
paint[index].setAlpha(255);
}
//实时重绘
invalidate();
}
});
}
}
有多少个小球,就创建了多少个动画,并存储在translateAnim[]数组里。如果开启了仿物理效果,就会给动画设置一个插值器setInterpolator(physicInterpolator)
,后面会讲解这个插值器的实现。另外监听了动画开始和动画循环时的监听回调,在里面调用了makeRandom(index)
方法,即对一些数据做随机处理,代码如下:
/**
* 数据随机化
*
* @param index
*/
private void makeRandom(int index) {
if (isRandomBallPath) { //坐标是在ondraw里才获得的,故在ondraw里再去应用
randomTransRatioX[index] = (float) (0.9f + (0.2f * Math.random())); //[0.9,1.1)
randomTransRatioY[index] = (float) (0.8f + (0.4f * Math.random())); //[0.8,1.2)
}
if (isRandomColor) { //不要在ondraw里再去应用,会同时覆盖掉画笔的透明度通道,透明动画会失效
randomBallColors[index] = getRandomColor();
paint[index].setColor(randomBallColors[index]);
} else {
paint[index].setColor(ballColor);
}
if (isRandomRadius) {
randomRadius[index] = (float) (radius * (0.7 + (0.6 * Math.random()))); //[0.7,1.3]
} else {
randomRadius[index] = radius;
}
}
之后是监听了动画过程中的实时回调,在该回调中获取的当前动画进度比例animation.getAnimatedFraction()
并赋值给了translateFraction[index]
,然后还对小球做了透明度处理(效果图中可见:小球出现时由透明渐变到不透明,快结束时再渐变到透明)。最后就是调用了invalidate()去重绘小球。到这里,我们的控件已经成型了
仿物理效果插值器的实现
我们的目标是要根据小球的弹跳次数来实现一个多次“减速-加速”的插值器,怎么实现呢?难道要继承基础插值器BaseInterpolator然后自己设计算法来写一个插值器吗?也太难了吧。
我们这里要介绍一个神奇的插值器,叫PathInterpolator。它可以将一个path路径映射成对应的插值器。
举个例子,系统内置的加速插值器和减速插值器对应的图形如下:
那么我们想要一个先减速后加速的插值器就应该大致长这样:
然后将多个这样的路径拼接,就能实现多次先减速后加速的插值器了
但是这个路径怎么实现呢?从上图其实就可以看出来,当然是使用三次贝塞尔曲线啦,看到图中有红蓝两个点没有,他们就是两个控制点。若有不理解了,打开这个网站玩一玩就明白了:贝塞尔曲线可视化
path中三次贝塞尔曲线对应的方法是path.cubicTo (float x1, float y1, float x2, float y2, float x3, float y3)
其中x1/y1为第一个控制点坐标, x2/y2为第二个控制点坐标, x3/y3为终点坐标。
最后还有一个问题就是要确定每一次“减速-加速”路径的起点和终点,这主要是通过之前在initPath里记录好的segmentLength来确定的。
完整代码如下:
public class MultiDecelerateAccelerateInterpolator {
private PointF originStart; //起点,用于构造PathInterpolator时须为[0,0]
private PointF originEnd; //终点,用于构造PathInterpolator时须为[1,1]
private float intervalX;
private float intervalY;
private float bezierControlRatioX;
private float bezierControlRatioY;
/**
* ratiox = 0.2, ratioy = 0.55 为调试值
* 单次路径效果图: http://cubic-bezier.com/#.2,.55,.8,.45
* 可自行调整这两个值,配合动画的整体时长,调出比较接近自由落体的效果
*/
public MultiDecelerateAccelerateInterpolator() {
this(new PointF(0,0),
new PointF(1,1),
0.2f ,
0.55f );
}
/**
* 用于构造PathInterpolator时,起点必须为[0,0]终点必须为[1,1]
* 用于构造“先减速后加速”效果时,建议ratiox取值[0,0.5];ratioy取值范围[0,1]且ratiox < ratioy,
* @param start 起点
* @param end 终点
* @param ratiox x比例值,用于控制贝塞尔控制点位置
* @param ratioy y比例值,用于控制贝塞尔控制点位置
*/
public MultiDecelerateAccelerateInterpolator(PointF start,PointF end,float ratiox,float ratioy){
originStart = start;
originEnd = end;
intervalX = Math.abs(originEnd.x - originStart.x);
intervalY = Math.abs(originEnd.y - originStart.y);
bezierControlRatioX = ratiox;
bezierControlRatioY = ratioy;
}
/**
* 利用三次贝塞尔构造减速加速函数
* @param segmentLength 从起点到每一段终点的长度集合
* @return
*/
public Path createPath(float[] segmentLength){
Path path = new Path();
float ratio;
PointF start = new PointF();
PointF con1 = new PointF();
PointF con2 = new PointF();
PointF end = new PointF();
float totalLength = segmentLength[segmentLength.length - 1];
for (int i = 0; i < segmentLength.length; i++) {
ratio = segmentLength[i] / totalLength;
if(i == 0){
start.x = originStart.x;
start.y = originStart.y;
path.moveTo(originStart.x,originStart.y);
}
end.x = intervalX * ratio;
end.y = intervalY * ratio;
con1.x = start.x + (end.x - start.x) * bezierControlRatioX;
con1.y = start.y + (end.y - start.y) * bezierControlRatioY;
con2.x = end.x - (end.x - start.x) * (bezierControlRatioX );
con2.y = end.y - (end.y - start.y) * (bezierControlRatioY );
path.cubicTo(con1.x,con1.y,
con2.x,con2.y,
end.x,end.y);
start.x = end.x;
start.y = end.y;
}
return path;
}
/**
* 构造PathInterpolator
* @param segmentLength
* @return
*/
public Interpolator createInterpolator(float[] segmentLength){
Path p = createPath(segmentLength);
Interpolator inter =PathInterpolatorCompat.create(p);
return inter;
}
好了,到这里本控件主要的实现点都讲完了。想了解完整流程的可阅阅读源码:https://github.com/CCY0122/bounceballview
谢谢阅读。欢迎star