打造简易NineoldAndroids动画库,深入理解Android动画原理

原创 2015年11月18日 19:43:17

简介

NineoldAndroids是Github上一个著名的动画库,简单来说,NineOldAndroids是一个向下兼容的动画库,主要是使低于API 11的系统也能够使用View的属性动画
这里写图片描述

网上已经有一些文章,介绍了这个库的设计,包括类结构和思想,例如
NineOldAnimations 源码解析
NineoldAndroids动画库源码分析
上面两篇文章都比较详细的介绍了NineoldAndroids的源码,可以说为大家看源码带来很大的方便。
那为什么我还要写这篇文章呢?
我们来看NineoldAndroids的类结构图:
这里写图片描述
因为NineoldAndroids的类结构比较复杂,即使单纯看上面两篇文章,也可能把人搞糊涂
本篇文章将剥离NineoldAndroids的具体细节,尝试只是显示其核心功能,也就是说写出一个简易的NineoldAndroids,并且在这个过程当中,了解Android实现动画的原理和思想
一理通百理明,与君共勉

开篇说明

1、本动画库以Int类型的属性值为例子,实现了Android库中ValueAnimator的功能,不了解ValueAnimator使用方式的朋友,可以参考这篇文章

2、大部分代码由NineoldAndroids中抽取,剥去一些不必要的实现细节,例如delayStart()方法等

3、ValueAnimator与ObjectAnimator有所区别,本库还没有实现ObjectAnimator。ObjectAnimator是继承自ValueAnimator,本质是通过ValueAnimator计算出的值,去更新View的属性。

现在我们先来看看该动画库的使用:

public class MainActivity extends Activity {
    TextView mTextView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mTextView = (TextView) findViewById(R.id.mtext);        
        //Value动画,设置目标值为3000
        CValueAnimator valueAnimator = CValueAnimator.ofInt(1000,2000,3000);
        //设置动画时间
        valueAnimator.setDuration(4000);
        valueAnimator.addUpdateListener(new CValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(CValueAnimator animation) {
                //将动画值,更新到textView
                mTextView.setText(animation.getAnimatedValue() + "");
                mTextView.setTranslationY((Integer) animation.getAnimatedValue());
                mTextView.invalidate();
            }
        });
        //启动动画
        valueAnimator.start();
    }
}

使用方式和NineoldAndroids完全一样,也和Android原生的方式一样,对于使用过动画效果的朋友来说,应该非常简单。

类设计图

首先来看类设计图,这图相比原来的NineoldAndroids,做了很多精简,只是希望大家更加容易看懂NineoldAndroids的本质。
这里写图片描述

在进行下一步的分析之前,我们先来了解一下一些核心的类以及它们的作用。

  • CValueAnimator : 该类是 Animator 的子类,实现了动画的整个处理逻辑,也是最为核心的类;
  • TimeInterpolator : 时间插值器,它的作用是根据时间流逝的百分比来计算出当前属性值改变的百分比,系统预置的有 LinearInterpolator(线性插值器:匀速动画)、AccelerateDecelerateInterpolator(加速减速插值器:动画两头慢中间快)和 DecelerateInterpolator(减速插值器:动画越来越慢)等;
  • CTypeEvaluator : TypeEvaluator 的中文翻译为类型估值算法,它的作用是根据当前属性改变的百分比来计算改变后的属性值,系统预置的有 IntEvaluator(针对整型属性)、FloatEvaluator(针对浮点型属性);
  • CPropertyValuesHolder : PropertyValuesHolder 是持有目标属性 Property、setter 和 getter 方法、以及 KeyFrameSet 的类;
  • CKeyFrame : 一个 keyframe 对象由一对 time / value 的键值对组成,可以为动画定义某一特定时间的特定状态,Animator 传入的一个个参数映射为一个个 keyframe,存储相应的动画的触发时间和属性值;
  • CKeyFrameSet : 存储一个动画的关键帧集合;

动画流程解析

1、动画初始化

在调用start()方法之前,我们使用

CValueAnimator valueAnimator = CValueAnimator.ofInt(1000,2000,3000);

做了动画的初始化工作,那么我们具体做了上面呢?

1.1、关键帧

简单而言,就是把传入的属性值,例如例子中是1000,2000,3000,封装成关键帧对象CKeyFrame
所谓关键帧,就是在动画过程中一定要出现的帧。
我们知道,所谓动画也不可能是完全连续的,肯定会有一些间隔,只是间隔小于人眼视觉暂留时间,所以看起来就是连续的了。
所以从1000-2000这个过程,也不可能是完全连续的,也许是1000,1100,…1900,2000
其中一些帧就被丢失了,绝对不能丢失的帧,称为关键帧。
关键帧保留两个属性,一个是该帧所在的时间(其实是一个百分比),一个是帧值

public abstract class CKeyframe implements Cloneable {
    /**
     * 时间
     */
    float mFraction;

    /**
     * 属性值类型
     */
    Class mValueType;

    /**
     * 插值器
     */
    private /*Time*/Interpolator mInterpolator = null;

    public static CKeyframe ofInt(float fraction, int value) {
        return new IntCKeyframe(fraction, value);
    }

    public static CKeyframe ofInt(float fraction) {
        return new IntCKeyframe(fraction);
    }

    public abstract Object getValue();

    /**
     * INT类型值得关键帧
     */
    public static class IntCKeyframe extends CKeyframe {

        /**
         * 关键帧的值
         */
        int mValue;

        IntCKeyframe(float fraction, int value) {
            mFraction = fraction;
            mValue = value;
            mValueType = int.class;
        }

        IntCKeyframe(float fraction){
            mFraction = fraction;
            mValueType = int.class;         
        }

        public int getIntValue() {
            return mValue;
        }

        public void setValue(Object value) {
            if (value != null && value.getClass() == Integer.class) {
                mValue = ((Integer)value).intValue();
            }
        }

        @Override
        public Object getValue() {
            return mValue;
        }
    }

    public float getFraction() {
        return mFraction;
    }

    public /*Time*/Interpolator getInterpolator() {
        return mInterpolator;
    }
}

1.2、关键帧集合

我们生成关键帧对象以后,将关键帧存入一个集合,称为关键帧集合,也就是CKeyFrameSet类。
CKeyFrameSet类中有一个CTypeEvaluator成员对象,这对象可以通过当前动画进行的百分比,计算出两个关键帧之间的值

public interface CTypeEvaluator<T> {
    public T evaluate(float fraction, T startValue, T endValue);
}

public class IntCEvaluator implements CTypeEvaluator<Integer> {
    public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
        int startInt = startValue;
        return (int)(startInt + fraction * (endValue - startInt));
    }
}

可以看到,IntCEvaluator其实就是一个线性计算fraction是百分比startValue是起始值endValue是目标值,不同的fraction会产生不同的结果。
显然对于两个关键帧来说,前一个关键帧的值,就是起始值,后一个关键帧的值,就是目标值

在CKeyFrameSet中是这样调用这个方法:

public int getIntValue(float fraction) {
        if (mNumKeyframes == 2) {//只有两个关键帧的情况
            if (firstTime) {
                firstTime = false;
                firstValue = ((CKeyframe.IntCKeyframe) mKeyframes.get(0)).getIntValue();
                lastValue = ((CKeyframe.IntCKeyframe) mKeyframes.get(1)).getIntValue();
                deltaValue = lastValue - firstValue;
            }
            if (mInterpolator != null) {
                fraction = mInterpolator.getInterpolation(fraction);
            }
            if (mEvaluator == null) {
                return firstValue + (int)(fraction * deltaValue);
            } else {
                return ((Number)mEvaluator.evaluate(fraction, firstValue, lastValue)).intValue();
            }
        }

       ....

        CKeyframe.IntCKeyframe prevKeyframe = (CKeyframe.IntCKeyframe) mKeyframes.get(0);
        for (int i = 1; i < mNumKeyframes; ++i) {//多个关键帧
            CKeyframe.IntCKeyframe nextKeyframe = (CKeyframe.IntCKeyframe) mKeyframes.get(i);
            if (fraction < nextKeyframe.getFraction()) {
                final /*Time*/Interpolator interpolator = nextKeyframe.getInterpolator();
                if (interpolator != null) {
                    fraction = interpolator.getInterpolation(fraction);
                }
                float intervalFraction = (fraction - prevKeyframe.getFraction()) /
                        (nextKeyframe.getFraction() - prevKeyframe.getFraction());
                int prevValue = prevKeyframe.getIntValue();
                int nextValue = nextKeyframe.getIntValue();
                return mEvaluator == null ?
                        prevValue + (int)(intervalFraction * (nextValue - prevValue)) :
                        ((Number)mEvaluator.evaluate(intervalFraction, prevValue, nextValue)).
                                intValue();
            }
            prevKeyframe = nextKeyframe;
        }
        // shouldn't get here
        return ((Number)mKeyframes.get(mNumKeyframes - 1).getValue()).intValue();
    }

方法有点长,大家可以看只有两个关键帧的情况是怎么计算的,就比较简单,其实就是线性计算。

1.3、非线性方法

问题是,难道每次我们都希望1000-2000直接是线性增长的吗?如果我希望先快后慢呢?
了解插值器的朋友,应该就明白,这个功能我们可以通过Interpolator去做,但是Interpolator改变的是fraction的增长速度,也就是加速度(勉强可以这样理解)。
从而实现非线性效果,所以显然CKeyframeSet要持有一个Interpolator对象

1.4、CPropertyValuesHolder类,持有属性名称和CKeyFrameSet

顾名思义,CPropertyValuesHolder就是持有属性和值得一个类。
CPropertyValuesHolder是动画库中的一个核心类,但是在本简易库削减了其功能,因为我们只需要实现值得变化,没有针对具体的属性,例如scale,rotate等,所以不需要提供View属性修改的方法。
其实这个类,是为ObjectAnimator做了比较大的准备,但是本篇文章不涉及。
我们关注的是,这个类持有CKeyFrameSet。

public class CPropertyValuesHolder implements Cloneable {
    /**
     * 属性名称
     */
    String mPropertyName;
    /**
     * 关键帧集合
     */
    CKeyframeSet mKeyframeSet = null;
    private static final CTypeEvaluator sIntEvaluator = new IntCEvaluator();
    private static final CTypeEvaluator sFloatEvaluator = new FloatCEvaluator();
    private CTypeEvaluator mEvaluator = sIntEvaluator;
    /**
     * 属性值
     */
    private Object mAnimatedValue;
    /**
     * 属性值类型 
     */
    Class mValueType;

    private CPropertyValuesHolder(String propertyName) {
        mPropertyName = propertyName;
    }

    /**
     * 返回一个属性值类型为int的CPropertyValuesHolder
     * @param propertyName
     * @param values
     * @return
     */
    public static CPropertyValuesHolder ofInt(String propertyName, int... values) {
        return new IntCPropertyValuesHolder(propertyName, values);
    }

    /**
     * 属性值类型为int的CPropertyValuesHolder
     */
    static class IntCPropertyValuesHolder extends CPropertyValuesHolder {
        IntCKeyframeSet mIntKeyframeSet;
        public IntCPropertyValuesHolder(String propertyName, int... values) {
            super(propertyName);
            setIntValues(values);
        }

        @Override
        public void setIntValues(int... values) {
            super.setIntValues(values);
            mIntKeyframeSet = (IntCKeyframeSet) mKeyframeSet;
        }
    }

    /**
     * 返回当前属性值
     * @return
     */
    Object getAnimatedValue() {
        return mAnimatedValue;
    }

    /**
     * 设置属性值类型,这里具体到int类型
     * 对于每个Int类型值,例如100,200,1000
     * 生成一个CKeyFrame关键帧对象,并且将这些对象包装成一个set集合
     * @param values
     */
    public void setIntValues(int... values) {
        mValueType = int.class;
        mKeyframeSet = CKeyframeSet.ofInt(values);
    }

    void init() {
        if (mEvaluator == null) {
            // We already handle int and float automatically, but not their Object
            // equivalents
            mEvaluator = (mValueType == Integer.class) ? sIntEvaluator :
                    (mValueType == Float.class) ? sFloatEvaluator :
                            null;
        }
        if (mEvaluator != null) {
            // KeyframeSet knows how to evaluate the common types - only give it a custom
            // evaluator if one has been set on this class
            mKeyframeSet.setEvaluator(mEvaluator);
        }
    }

    /**
     * 让CKeyframeSet通过关键帧计算属性值
     * @param fraction
     */
    void calculateValue(float fraction) {
        mAnimatedValue = mKeyframeSet.getValue(fraction);
    }
}

1.5、CValueAnimator.ofInt(1000,2000,3000);到底做了什么?

    /**
     * CPropertyValuesHolder是一个包装类
     * 可以看做是,需要动画的属性或者值的对象实例
     */
    CPropertyValuesHolder[] mValues;

    /**
     * 属性值类型为int的动画
     * @param values
     * @return
     */
    public static CValueAnimator ofInt(int... values) {
        CValueAnimator anim = new CValueAnimator();
        anim.setIntValues(values);
        return anim;
    }

    /**
     * 根据一系列属性值,生成CPropertyValuesHolder
     * CPropertyValuesHolder这个类的意义就是,持有某个属性的一系列值,
     * 例如"scale(缩放属性)",其若干个值为100,200,1000等。
     * 也就是说在规定时间内,"scale"的值会从100增长到1000
     * @param values
     */
    public void setIntValues(int... values) {
        if (values == null || values.length == 0) {
            return;
        }
        if (mValues == null || mValues.length == 0) {
            //属性名称为"",说明只是数值改变,和具体属性无关
            setValues(new CPropertyValuesHolder[]{CPropertyValuesHolder.ofInt("", values)});
        } else {
            CPropertyValuesHolder valuesHolder = mValues[0];
            valuesHolder.setIntValues(values);
        }
        // New property/values/target should cause re-initialization prior to starting
        mInitialized = false;
    }

    public void setValues(CPropertyValuesHolder... values) {
        mValues = values;
        mInitialized = false;
    }

原理就是根据1000,2000,3000,生成了一个CPropertyValuesHolder对象,并且将它保存了起来。

2、调用start(),开始动画!

目前万事俱备,只等调用start()方法开始动画了。
直接来看简化过后的start()方法

public void  start() {
        if (Looper.myLooper() == null) {//当前线程必须调用了Looper.loop()方法
            throw new AndroidRuntimeException("Animators may only be run on Looper threads");
        }

        mPlayingState = STOPPED;

        sPendingAnimations.get().add(this);
        /**
         * 初始化动画时间,也就是设置起始运行时间为0
         * 并且计算起始属性值值
         */
        setCurrentPlayTime(getCurrentPlayTime());
        mPlayingState = STOPPED;
        mRunning = true;
        //通知监听器,动画开始
        if (mListeners != null) {
            ArrayList<AnimatorListener> tmpListeners =
                    (ArrayList<AnimatorListener>) mListeners.clone();
            int numListeners = tmpListeners.size();
            for (int i = 0; i < numListeners; ++i) {
                tmpListeners.get(i).onAnimationStart(this);
            }
        }
        //Handler,通过自己给自己发送消息,实现不断进行动画
        AnimationHandler animationHandler = sAnimationHandler.get();
        if (animationHandler == null) {
            animationHandler = new AnimationHandler();
            sAnimationHandler.set(animationHandler);
        }
        animationHandler.sendEmptyMessage(ANIMATION_START);
    }

动画过程我们可以这样想:
1、获取当前时间为startTime,即动画起始时间,并且初始化动画状态,例如1000,2000,3000,那么setCurrentPlayTime()方法的其中一个工作就是初始化状态为1000
2、通知动画开始监听器,动画开始
3、使用AnimationHandler实现循环,首先给AnimationHandler发送了一条ANIMATION_START信息

显然,主要工作就是在AnimationHandler里面进行的

/**
     * 该handler用于处理两个消息
     * ANIMATION_START也就是动画开始
     * ANIMATION_FRAME也就是运行某一帧
     */
    private static class AnimationHandler extends Handler {        
        @Override
        public void handleMessage(Message msg) {
            boolean callAgain = true;
            //当前运行动画队列
            ArrayList<CValueAnimator> animations = sAnimations.get();
            switch (msg.what) {
                case ANIMATION_START:
                    //当前等候动画队列
                    ArrayList<CValueAnimator> pendingAnimations = sPendingAnimations.get();
                    if (animations.size() > 0) {
                        callAgain = false;
                    }
                    while (pendingAnimations.size() > 0) {//如果等候的动画大于0
                        ArrayList<CValueAnimator> pendingCopy =
                                (ArrayList<CValueAnimator>) pendingAnimations.clone();
                        pendingAnimations.clear();
                        int count = pendingCopy.size();
                        for (int i = 0; i < count; ++i) {
                            CValueAnimator anim = pendingCopy.get(i);
                            // If the animation has a startDelay, place it on the delayed list
                            anim.startAnimation();//启动这些动画
                        }
                    }
                case ANIMATION_FRAME:
                    //当前时间
                    long currentTime = AnimationUtils.currentAnimationTimeMillis();
                    //当前已经结束的动画队列
                    ArrayList<CValueAnimator> endingAnims = sEndingAnims.get();
                    //正在运行的对话数量
                    int numAnims = animations.size();
                    int i = 0;
                    while (i < numAnims) {
                        CValueAnimator anim = animations.get(i);
                        if (anim.animationFrame(currentTime)) {//更新每个运行动画的数值,如果已经结束,加入endingAnims对象
                            endingAnims.add(anim);
                        }
                        if (animations.size() == numAnims) {
                            ++i;
                        } else {
                            //在动画运行过程中,可能有些动画被取消
                            --numAnims;
                            endingAnims.remove(anim);
                        }
                    }
                    if (endingAnims.size() > 0) {
                        for (i = 0; i < endingAnims.size(); ++i) {
                            endingAnims.get(i).endAnimation();
                        }
                        endingAnims.clear();
                    }

                    // If there are still active or delayed animations, call the handler again
                    // after the frameDelay
                    //如果还有活动的动画,在默认每帧间隔时间以后,再次调用,更新属性值
                    if (callAgain && (!animations.isEmpty())) {
                        sendEmptyMessageDelayed(ANIMATION_FRAME, Math.max(0, sFrameDelay -
                                (AnimationUtils.currentAnimationTimeMillis() - currentTime)));
                    }
                    break;
            }
        }
    }

这个类做了这些工作:

1、ANIMATION_START状态:

其实就是调用了等待队列pendingAnimations中CValueAnimator对象的startAnimation()方法

/**
     * 启动动画
     */
    private void startAnimation() {
        //初始化动画
        initAnimation();
        //将等候队列中的动画,加入运行对象
        sAnimations.get().add(this);
    }

在该方法中,初始化了动画(其实这里调用initAnimation()没有实际作用,因为之前已经初始化过了);
然后就是将动画放入运行队列。

2、ANIMATION_FRAME状态:

我们注意到ANIMATION_START状态以后,并没有使用break,所以会接着执行ANIMATION_FRAME
对每个运行动画,调用其了animationFrame()方法

/**
     * 根据当前时间,计算运行百分比,然后调用animateValue更新当前属性值
     * @param currentTime
     * @return
     */
    boolean animationFrame(long currentTime) {
        boolean done = false;

        if (mPlayingState == STOPPED) {
            mPlayingState = RUNNING;
            if (mSeekTime < 0) {
                mStartTime = currentTime;
            } else {
                mStartTime = currentTime - mSeekTime;
                // Now that we're playing, reset the seek time
                mSeekTime = -1;
            }
        }
        switch (mPlayingState) {
            case RUNNING:
            case SEEKED:
                float fraction = mDuration > 0 ? (float)(currentTime - mStartTime) / mDuration : 1f;
                if (fraction >= 1f) {//百分比大于1,结束动画
                    done = true;
                    fraction = Math.min(fraction, 1.0f);
                }
                animateValue(fraction);
                break;
        }

        return done;
    }

这个方法会根据当前时间,判断动画是否已经结束,如果是,返回true,这些动画就会进入sEndingAnims队列,做最后的结束通知工作
否则,其实就是调用了自己的CPropertyValuesHolder计算当前属性值

/**
     * 根据百分比,更新属性值
     * @param fraction
     */
    void animateValue(float fraction) {
        fraction = mInterpolator.getInterpolation(fraction);
        mCurrentFraction = fraction;
        int numValues = mValues.length;
        for (int i = 0; i < numValues; ++i) {
            mValues[i].calculateValue(fraction);//让CPropertyValuesHolder计算属性值
        }
        //通知监听器,属性值更新
        if (mUpdateListeners != null) {
            int numListeners = mUpdateListeners.size();
            for (int i = 0; i < numListeners; ++i) {
                mUpdateListeners.get(i).onAnimationUpdate(this);
            }
        }
    }

3、新的属性值计算结束

走完两个case,新的属性值就计算出来了,我们通过getAnimatedValue()就可以拿到

/**
     * 获取当前属性值
     * @return
     */
    public Object getAnimatedValue() {
        if (mValues != null && mValues.length > 0) {
            return mValues[0].getAnimatedValue();
        }
        // Shouldn't get here; should always have values unless ValueAnimator was set up wrong
        return null;
    }

那么怎么让动画继续计算下一个属性值呢?
注意最后

// If there are still active or delayed animations, call the handler again
                    // after the frameDelay
                    //如果还有活动的动画,在默认每帧间隔时间以后,再次调用,更新属性值
                    if (callAgain && (!animations.isEmpty())) {
                        sendEmptyMessageDelayed(ANIMATION_FRAME, Math.max(0, sFrameDelay -
                                (AnimationUtils.currentAnimationTimeMillis() - currentTime)));
                    }

也就是在规定的帧间隔以后,AnimationHandler给自己再次发送一个ANIMATION_FRAME消息,进行下一次属性值的计算
最后,如上面所说,animationFrame()返回true,才真正结束动画

写在最后

到此为止,我要介绍的内容就说完了。
大家只要了解关键帧CKeyframe,CPropertyValuesHolder,估值算法CTypeEvaluator,以及使用handler循环计算动画值的原理
就应该可以了解到NineoldAndroids动画库设计的核心。
文章以及将NineoldAndroids的代码做了很多精简,就是希望大家可以明白本质,如果还有疑问的地方,可以下载我的测试代码,运行感受一下下。
也可以给我留言,一起探讨。
源码下载地址(manimation包是我精简过的代码,animation,util,view三个包中的,是NineoldAndroids的代码,大家可以对比看看)。

版权声明:本文为博主原创文章,转载请注明出处。

相关文章推荐

Android 之nineoldandroids ViewHelper实现动画效果

这里讲到nineoldandroids(动画兼容库) ,其中ViewHelper,这个类是为了兼容以前的api,因为像setAlpha,setTranslationX等方法在低版本中是没有的,所以Ni...

Android动画进阶—使用开源动画库nineoldandroids

转载请注明出处:http://blog.csdn.net/singwhatiwanna/article/details/17639987 前言 Android系统支持原生动画,这为应用开发者开发绚...

关于github上开源nineoldandroids兼容动画的笔记

github上面有个例子很好的列举了NineOldAndroids兼容动画的用法。下载了下来然后运行测试了下: 因为要引用nineoldandroids的项目,我这里直接把它的源代码拷贝进去...

出场、入场动画大全,基于NineOldAndroids轻松实现动画效果

一、前言 相信做过动画集的人都知道,用github上的NineOldAndroids,使用起来非常方便。(链接地址:https://github.com/JakeWharton/NineOldAnd...
  • FJeKin
  • FJeKin
  • 2016年07月13日 17:18
  • 1609

Delphi7高级应用开发随书源码

  • 2003年04月30日 00:00
  • 676KB
  • 下载

Android 使用NineOldAndroids实现绚丽的ListView左右滑动删除Item效果

转载请注明本文出自xiaanming的博客(http://blog.csdn.net/xiaanming/article/details/18311877),请尊重他人的辛勤劳动成果,谢谢! 今...

Android之NineOldAndroids实现绚丽的ListView左右滑动删除Item效果

今天还是给大家带来自定义控件的编写,自定义一个ListView的左右滑动删除Item的效果,这个效果之前已经实现过了,有兴趣的可以看下Android 使用Scroller实现绚丽的ListView左右...

Android -- NineOldAndroids animation 封装

1.概述   说起空间动态、微博的点赞效果,网上也是很泛滥,各种实现与效果一大堆。而详细实现的部分,讲述的也是参差不齐,另一方面估计也有很多大侠也不屑一顾,觉得完全没必要单独开篇来写和讲解吧。毕竟,...

Delphi7高级应用开发随书源码

  • 2003年04月30日 00:00
  • 676KB
  • 下载

Android 属性动画(Property Animation) 完全解析 (上)

转载请标明出处:http://blog.csdn.net/lmj623565791/article/details/380674751、概述Android提供了几种动画类型:View Animatio...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:打造简易NineoldAndroids动画库,深入理解Android动画原理
举报原因:
原因补充:

(最多只允许输入30个字)