自定义控件——弄个甜甜圈吧(3): 动画篇【生长动画】

1,450 阅读13分钟

【注:】本文首发于简书,掘金会同步发送,其余网站皆无授权。

欢迎浏览掘金主页和简书主页,我只是一枚普通的工程师-V-

喜欢自定义控件,也喜欢分享我的思路,希望能得到你的批评和建议,也希望能帮到你

【简书:羽翼君】
github:github.com/razerdp/Ani…

上一篇:《自定义控件——弄个甜甜圈吧(2): 搭建》


卫宫士郎:人被杀,就会死
羽翼君:没有动画,View会死

ps:本篇动画很简单,请随意食用,冷藏后风味更佳

Hello,逗比也乐于分享却常常拖更的羽翼君又回来更新了~

有人反应过我的文章风格不固定,那么我就先说明一下吧,我的文章,除了不喜欢大段大段的贴代码,形式上更像是一个聊天,没有固定风格,读的开心就好-V-

当然,作为技术类文章,还是要在可以顺着文章读懂思路的基础下,乱开槽点的哈哈

Ok,不扯废话,咱们进入猪蹄主题


在前两篇,在连我都不知道在扯啥的文章中,我竟然成功的把枯燥的基础思路与本控件核心点给描述出来了,虽然有点模糊抽象,但至少知道,我们做了两件事:

  • 确定了控件核心
  • 确定了数据承载实体

接下来,我们就基于这两点开始堆砌我们的装饰——动画吧

首先,问一下,啥子是Android的动画捏(嗯,我不知道左边这句说的对不对,但觉得挺好听的。。。ps:我系广州人喵)
  • 迫不及待的回答:在Android中,动画可以分为四种,分别是补间动画、属性动画、帧动画以及之后5.x加入的SVG矢量动画,接下来将对这几种动画的使用做一个详细的介绍:blablabla。。。

  • 羽翼君:来人,拖出去斩了。

  • 小心翼翼的回答:通过插值器、估值器等参数影响动画线性或非线性行进或时间流逝速度,并根据计算得到的结果作用于目标以达到连续的反馈效果。

  • 羽翼君:来人,唔。。。拖出去弹JJ一百下

  • 满不在乎的回答:给丫一个东西,给我一个值,剩下的我来让丫的动起来

  • 羽翼君:来人,收了。

哈哈,首先上面这个小剧场先不提正确与否,但如果要真的去说动画,我们可需要补充好多知识,比如物理动力学,数学什么乱七八糟的

但在实际应用中(特指Android动画),对于动画,我们先不扯基本的透明/旋转/位移什么的,我们用的最多同时也最好理解的,其实就是后面的两个回答:

我传入一个值,根据一系列炫酷的操作,在不同的时间算出连续的值,然后我根据这个值做出不同的效果

没错,就是这么粗暴。。。就是这么不讲道理

回到我们项目,如果要做出我们的甜甜圈生长动画,要怎么做

长大的甜甜圈
长大的甜甜圈

非常简单对吧,几乎每个写过自定义圆形进度条的开发者都一定会写过类似的:

  @Override
   protected void onDraw(Canvas canvas) {
        ...前略
        /**
         * 360乘以百分比得到扫描角度
         */
        canvas.drawArc(rectf,0,360 * progress/max,false,paint);
    }

不费任何一点精力,done~

然而,真的是这样吗?

难点1:分段绘制

1.1:Paint的存放位置

假如真的只有一段,那么上面的方法就已经很满足我们的需求了,但是问题来了,我们的甜甜圈,可不仅仅只有一个味道哦,往往食客们要求很多味道混搭,比如上面的动图,就不止一种味道了。

因此,作为厨师,我们就需要不同的调味料——Paint了。

在制作调味料之前,我们得找个容器放下他们

在上篇,我们已经定义了接口IPieInfo用于获取用户需求,但很显然,我们的调味料可不能从客户手中拿到,所以我们不可能在IPieInfo中约束getPaint()方法的。

对于用户的数据,我们用一个List来保存,如果我们再弄一个List来保存Paint,显然是很占用空间而且维护艰难(比如以后再增加点别的呢。。。事实上,在后面的文章里,我们还真需要增加别的)

幸运的是,在上一篇文章中,我们就给出了思路:

我们需要一个存放乱七八糟的东西的地方时,就给我创造一个吧

于是,我们就有了PieInfoImpl

PieInfoImpl类是一个final且权限修饰只是包内引用的类,它相当于一个盒子,里面除了装着用户的数据(IPieInfo),还装着我们塞进去的各种赠品:角度参数等,该类将会是我们控件所用到的核心数据承载体

既然我们都塞了赠品进去了,那么理所当然的,把我们的调味料也塞进去,因此我们的PieInfoImpl里面就可以放我们各种的Paint

final class PieInfoImpl {

    //上篇文章塞进来的赠品(角度参数)
    private final String id;
    private final IPieInfo mPieInfo;
    private float startAngle;
    private float endAngle;

    //调味料
    private Paint mPaint;

    public static PieInfoImpl create(IPieInfo info) {
        return new PieInfoImpl(info);
    }

     private PieInfoImpl(IPieInfo info) {
        id = UUID.randomUUID().toString();
        this.mPieInfo = info;
        initPaint(info);
    }

    private void initPaint(IPieInfo info) {
        if (mPaint == null) mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        if (mLinePath == null) mLinePath = new Path();
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(60);
        mPaint.setColor(info.getColor());

    }
  //其他get/setter忽略
}

【ps:为了直观描述这次我们加了什么,特意把上一次定义好的东西也写了进来,以后为了防止大段的代码,将会避免这种贴长篇代码的方式,而是只贴修改的部分。】

在代码里,我们定义了我们的调味料,然后在构造器中初始化我们的画笔,完事-V-

到目前为止,还是很简单的嘛~

没事,咱们由浅入深,三浅一深,方的持久(咳咳,我指文章内容够长易读,别想歪了)←_←


1.2:自定义动画

终于来到动画了,在一开始的小剧场里,咱们就说了动画是啥,那么在我们的控件里,有什么是影响到我们每个甜甜圈的大小的呢。

不妨再看看我们的绘制方法(代码来源:上一篇文章)

  @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
       //...略
        canvas.drawArc(mDrawRectf,0,120,false,paint1);
        canvas.drawArc(mDrawRectf,120,120,false,paint2);
        canvas.drawArc(mDrawRectf,240,120,false,paint3);
    }

关于参数,这里就不详说了,我们观察一下这三个曲线有什么是不同的:

  • 起始角度{0120240
  • 扫描角度{120120120
  • 画笔{paint1paint2paint3

别怀疑,扫描角度三个都是120纯属意外哈哈,因为懒,就三个取一样了。

在深思熟虑(1秒)之后,我们得出结论:起始角度和扫描角度决定了每个甜甜圈的大小。

在上一篇文章里,我们在添加数据的时候,config的内部类helper就已经给我们算好了起始角度,所以其实起始角度是已经固定了的,我们可以做文章的,就只有扫描角度

所以我们的动画,是针对扫描角度而定制。

由于我们继承的是View,所以这次我们使用Animation而不需要用Animator,毕竟View自带startAnimation()方法,有现成的当然用现成的对吧,毕竟咱们懒。。。。

接下来,我们来自定义我们的Animation,目标很简单:得到一个可变化的角度

首先咱们弄个类,继承Animation

class PieViewAnimation extends Animation {}

然后,我们在方法applyTransformation(float interpolatedTime, Transformation t)里面得到动画计算出来的数据(时间),然后根据这个时间,去计算出我们在每一帧的动画就可以了

为了方便以后的维护,我们在构造器里直接把我们的config配置类传进去

class PieViewAnimation extends Animation {
    private AnimatedPieViewConfig mViewConfig;

    //构造器略(只是传入config而已)
    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        super.applyTransformation(interpolatedTime, t);
        if (mViewConfig == null) {
            throw new NoViewConfigException("viewConfig为空");
        }
        //动画时间0~1内,算出经过的角度。
        if (interpolatedTime >= 0.0f && interpolatedTime <= 1.0f) {
            float angle = 360 * interpolatedTime + mViewConfig.getStartAngle();
        }
    }

}

上面的代码很简单对吧,我们通过动画时间得到每一帧的角度,得到了角度,我们就可以在View里面改变扫描的角度然后不断的重绘以达到动画效果。

然而,问题来了:

我们的甜甜圈可不仅仅只有一个,我怎么知道当前我的角度是属于哪段甜甜圈的

1.2.1 获取甜甜圈

在回答问题前,我们不妨想一下,我们目前知道的信息:

  • 每一段甜甜圈的开始/结束角度
  • 当前角度

当这两个条件写了出来,聪明的你一定知道该怎么做了吧,那我们就开干吧

首先,我们在PieInfoImpl里面加一个方法:isInAngleRange(float angle),这个方法很简单,就是通过判断传入的角度是否在起始和终止角度范围内返回true or false

然后在动画执行过程中,我们不断的遍历数据源,拿到当前角度所属的甜甜圈就可以了。(ps:此处含有优化点),遍历的代码我们放在config的内部类里面

protected final class AnimatedPieViewHelper {
        //其他略
        public PieInfoImpl findPieinfoWithAngle(float angle) {
            if (ToolUtil.isListEmpty(mDatas)) return null;
            for (PieInfoImpl data : mDatas) {
                if (data.isInAngleRange(angle)) return data;
            }
            return null;
        }
    }

接着在动画回调计算中,调用即可,同时补上我们的回调以提供给View进行操作
PieViewAnimation.java:

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        super.applyTransformation(interpolatedTime, t);
        ...略
        if (interpolatedTime >= 0.0f && interpolatedTime <= 1.0f) {
            float angle = 360 * interpolatedTime + mViewConfig.getStartAngle();
            //得到甜甜圈
            PieInfoImpl info = mViewConfig.getHelper().findPieinfoWithAngle(angle);
            if (mHandler != null && info != null) {
                mHandler.onAnimationProcessing(angle, info);
            }
        }
    }

    //接口的getter/setter略
     public interface AnimationHandler {
        void onAnimationProcessing(float angle, @NonNull PieInfoImpl infoImpl);
    }

题外话:在这里我提出了一个优化点,是我在写文章的时候发现的,因为applyTransformation()回调极为频繁,而我们寻找甜甜圈的方法是遍历数组,虽然数据可能不多,但不可否认的是这里会存在一定的性能问题,所以这里是一个优化点(考虑索引)


1.3:动画绘制

动画有了,数据也有了,剩下来就是奇迹(坑爹)的时刻——验证

在控件中,我们实现我们刚刚写好的接口回调:AnimationHandler,同时在初始化时把动画初始化:

AnimatedPieView.java

//其余成员变量此处略,不想太多代码,具体请查看github源码

 private void init(){
        buildAnima(mConfig);
    }

 private void buildAnima(AnimatedPieViewConfig config) {
        //anim
        if (mPieViewAnimation == null) mPieViewAnimation = new PieViewAnimation(config);
        mPieViewAnimation.setDuration(config.getDuration());
        mPieViewAnimation.setInterpolator(config.getInterpolator());
        mPieViewAnimation.bindAnimationHandler(this);
        mPieViewAnimation.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {

            }

            @Override
            public void onAnimationEnd(Animation animation) {
                isInAnimating = false;

            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        });
    }

 @Override
 public void onAnimationProcessing(float angle, @NonNull PieInfoImpl infoImpl) {
        this.angle = angle;
        this.mCurrentInfo = infoImpl;
        invalidate();
    }

上面三个方法只有最后一个需要留意,我们的回调很简单,把角度和当前的甜甜圈记录下来,然后重绘就可以了。。。

于是,我们就高高兴兴,满怀期待的写我们的onDraw()

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        final float width = getWidth() - getPaddingLeft() - getPaddingRight();
        final float height = getHeight() - getPaddingTop() - getPaddingBottom();

        canvas.translate(width / 2, height / 2);
        //半径
        final float radius = Math.min(width, height) / 2 * mConfig.getPieRadiusScale();
        mDrawRectf.set(-radius, -radius, radius, radius);

        //第三个参数注意角度是相对当前甜甜圈的起始角度,而不是总角度
        canvas.drawArc(mDrawRectf, mCurrentInfo.getStartAngle(), angle - mCurrentInfo.getStartAngle(), false, mCurrentInfo.getPaint());
    }

“绘制圆弧时第三个角度不可以直接使用动画计算出的angle,因为动画计算出的angle是总扫描角度,而不是每一段的扫描角度,因此需要减去当前甜甜圈的起始角度才可以准确表现出每一段的生长”

接着提供一个start()方法给外部,我们似乎看到距离终点只有一步之遥了:

public void start() {
        if (isInAnimating) {
            return;
        }
        isInAnimating = true;
        clearAnimation();
        startAnimation(mPieViewAnimation);
    }

在数据构造完毕,满怀期待的点下了start之后,一件意料之外的事情发生了!

"漂亮甜甜圈为何神秘消失,百十行代码为何无法达到期望,是程序员太笨,还是模拟器太烂?甜甜圈连环失踪案究竟是何人所为,迷之动画效果究竟是人是鬼?"

我们是专业的团队,请跟同《走进科学》的镜头一起揭露这。。。。。。哦不对,别打,疼~~ 诸位客官请往下看:

失踪的甜甜圈
失踪的甜甜圈

OMG!!!我的甜甜圈,被谁吃了!!!

蛋定蛋定。。。作为一枚专业的程序员,我们怎么可以把锅推给自己呢,所以我们不妨再看代码,

onDraw()中,我们做了这么几件事:

  • 把画布中心点移到视图中心
  • 根据View大小计算出甜甜圈半径
  • 根据当前角度和当前甜甜圈,绘制圆弧

看起来一切正常,对吧。。。但事实上,我们绘制时调用的是invalidate(),也就是“重绘”,这个方法会请求一次fullInvalidate,也就是我们上一次绘制的东西是会被清除掉的。

只不过因为绘制太快,所以每一段的动画在我们眼里都很流畅。

因此,我们需要针对已经画完的甜甜圈做一个缓存。

1.3.1:绘制缓存

当我们绘制完一个甜甜圈,我们需要将这个甜甜圈存下来,然后在下一次绘制的时候把存下来的甜甜圈给完整绘制出来,这样子达到一个缓存效果。

不过有一个问题就是我们的缓存时机要确定好,否则的话可能会有意料之外的事情发生。

还是从已知条件出发,我们可以轻易地知道三个角度:起始/终止/当前角度。

因此我们可以得到一个很确凿的缓存时机:当前角度≥当前甜甜圈终止角度时

于是接下来改造我们的甜甜圈代码:

AnimatedPieView#onAnimationProcessing()

    @Override
    public void onAnimationProcessing(float angle, @NonNull PieInfoImpl infoImpl) {
        if (mCurrentInfo != null) {
            //角度切换时就把画过的添加到缓存,因为角度切换只有很少的几次,所以这里允许循环,并不会造成大量的循环
            if (angle >= mCurrentInfo.getEndAngle()) {
                boolean hasAdded = false;
                for (PieInfoImpl pieInfo : mDrawedCachePieInfo) {
                    if (pieInfo.equalsWith(mCurrentInfo)) {
                        hasAdded = true;
                        break;
                    }
                }
                if (!hasAdded) {
                    DebugLogUtil.logAngles("超出角度", mCurrentInfo);
                    mDrawedCachePieInfo.add(mCurrentInfo);
                }
            }
        }
        ...重绘
    }

有一点需要注意的是,我们并不可以判断角度大于当前甜甜圈的角度,而必须要大于等于,因为当动画进行到最后,必定会是当前角度=最后一个甜甜圈的结束角度,这样就会导致无法缓存最后画出来的甜甜圈

然而加上等于判断后,会存在缓存过的甜甜圈被再次添加(当然可以采取set的数据类型,这里用的list),所以每次添加前都需要一次判断。

最后补充上我们的onDraw()缓存绘制:

    @Override
    protected void onDraw(Canvas canvas) {
        ...    前面保持一样,略

        //绘制缓存不空,则绘制
        if (!ToolUtil.isListEmpty(mDrawedCachePieInfo)) {
            for (PieInfoImpl pieInfo : mDrawedCachePieInfo) {
                canvas.drawArc(mDrawRectf, pieInfo.getStartAngle(), pieInfo.getSweepAngle(), !mConfig.isDrawStrokeOnly(), pieInfo.getPaint());
            }
        }
        //第三个参数注意角度是相对当前甜甜圈的起始角度,而不是总角度
        canvas.drawArc(mDrawRectf, mCurrentInfo.getStartAngle(), angle - mCurrentInfo.getStartAngle(), false, mCurrentInfo.getPaint());
    }

最后,我们就得到了一开始的效果:

final效果
final效果

嗯。。。本篇简单的生长的甜甜圈动画结束。。。

下一篇将会进入点击事件的编写

前方更加高能。。。我得组织下语言(组织拖更)

【continue】