自定义View之小球自由落体弹跳加载控件

本篇文章已授权微信公众号 guolin_blog(郭霖)独家发布

效果预览

因GIF图压缩的原因动画看起来有些不流畅。
img1
应用为加载框的效果:
img2

使用方法

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

没有更多推荐了,返回首页