Android动画深入分析——属性动画
属性动画是在
API11中引入的特性,和View动画不同,它对作用对象进行了扩展,属性动画可以对任何对象做动画,甚至还可以没有对象。除了作用对象进行扩展以外,属性动画的效果也得到了加强,不再像View动画那样只能支持四种简单的变换。属性动画中有ValueAnimator、ObjectAnimator和AnimatorSet等概念,通过它们可以实现绚丽的动画。
1.使用属性动画
属性动画可以对任意对象进行动画而不仅仅是View,动画默认时间间隔300ms,默认帧率10ms/帧。其可以达到的效果是,在一个时间间隔内完成对象从一个属性值到另一个属性值的改变。因此属性动画几乎是无所不能的,只要对象有这个属性,它都能实现动画效果。但是属性动画从API11才有,这就严重制约了属性动画的使用。可以采用开源动画库nineoldandroids来兼容以前的版本,采用
nineoldandroids,可以在API11以前的系统上使用属性动画,
nineoldandroids的网址是:http://
nineoldandroids.com。 nineoldandroids对属性动画做了兼容,在API 11以前的版厄本那其内部是通过代理View动画来实现的,因此在低Android版本上,它的本质是View动画,尽管使用方法看起来是属性动画。nineoldandroids的功能和系统原始对android.animation.*中类的功能完全一致,使用方法也完全一样,只要我们用nineoldandroids来编写动画,就可以在所有的Android系统上运行。比较常用的几个动画类是:ValueAnimator、ObjectAnimator和AnimatorSet,其中ObjectAnimator继承自ValueAnimator,AnimatorSet是动画集合,可以定义一组动画,它们使用起来也是及其简单的。如何使用属性动画呢?下面举几个小李子,读者以看就明白了。 (1)改变一个对象(myObject)的translationY属性,让其沿着Y轴向上平移一段距离:它的高度,改动画在默认时间内完成,动画完成时间是可以自定义的。想要灵活的效果我们还可以定义插值器和估值算法,但是一般来说我们不需要自定义,系统已经预置了一些,能够满足常用的动画。
ObjectAnimator.ofFloat(myObject, "translationY", -myObject.getHeight()).start();
(2)改变一个对象的背景色属性,典型的情形是改变View的背景色,下面的动画可以让背景色在3秒内实现从0xFFFF8080到0xFF8080FF的渐变,动画会无线循环而且会有反转的效果。
ValueAnimator colorAnim = ObjectAnimator.ofInt(button, "backgroundColor",0xFFFF8080,0xFF8080FF);
colorAnim.setDuration(3000);
colorAnim.setEvaluator(new ArgbEvaluator());
colorAnim.setRepeatCount(ValueAnimator.INFINITE);
colorAnim.setRepeatMode(ValueAnimator.REVERSE);
colorAnim.start();
(3)动画集合,5秒内对View的旋转、平移、缩放和透明度都进行了改变。
AnimatorSet set = new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(button,"rotationX", 0,360),
ObjectAnimator.ofFloat(button,"rotationY", 0,180),
ObjectAnimator.ofFloat(button,"rotation", 0,-90),
ObjectAnimator.ofFloat(button,"translationX", 0,90),
ObjectAnimator.ofFloat(button,"translationY", 0,90),
ObjectAnimator.ofFloat(button,"scaleX", 1,1.5f),
ObjectAnimator.ofFloat(button,"scaleY", 1,0.5f),
ObjectAnimator.ofFloat(button,"alpha", 1,0.25f,1));
set.setDuration(5000).start();
set.start();
属性动画除了通过代码实现以外,还可以通过XML来定义。属性动画需要定义在res/animator/目录下,它的语法如下所示。
...
属性动画的各种参数都比较好理解,在XML中可以定义ValueAnimator、ObjectAnimator以及AnimatorSet,其中标签对于 AnimatorSet,
android:valueFrom——表示属性的起始值; android:valueTo——表示属性的结束值;
android:startOffset——表示动画的延迟时间,当动画开始后,需要延迟多说毫秒才会真正播放此动画; android:repeatCount——表示动画的重复次数;
android:repeatMode——表示动画的重复模式; android:valueType——表示 android:propertyName所指定的属性的类型,有intType和floatType两个可选项,分别表示属性的类型为整型和浮点型。另外,如果 android:propertyName所指定的属性表示的是颜色,那么不需要指定 android:valueType,系统会自动对颜色类型的属性做处理。 对于一个动画来说,有两个属性这里要特殊说明一下,一个是 android:repeatCount,它表示动画循环的次数,默认值为0,其中-1表示无线循环;另外一个是 android:repeatMode,它表示动画循环的模式,有两个选项:repeat和reverse,分别表示连续重复和逆向重复。连续重复比较好理解,就是动画每次都重写开始播放,而逆向重复是指第一次播放完以后,第二次会倒着播放,第三次再重投开始播放动画,第四次再到这播放动画,如此反复。 下面是一个具体的例子,我们通过XML定义一个属性动画并将其作用再View上,如下所示。
如何使用上面的属性动画呢?也很简单,如下所示。
AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(this,R.animator.property_animator);
set.setTarget(button);
set.start();
在实际的开发中建议采用代码来实现属性动画,这时因为通过代码来实现比较简单。更重要的是,很多时候一个属性的起始值是无法提前确定的,比如让一个Button从屏幕左边移动到屏幕的右边,由于我们无法提前知道屏幕的宽度,因此无法将属性动画定义在XML中,在这种情况下就必须通过代码来动态的创建属性动画。
2.理解插值器和估值器 TimeInterpolator中文翻译为时间插值器,它的作用是根据时间流逝的百分比来计算出当前属性值改变的百分比,系统预知的有LinearInterpolator(线性插值器:匀速动画)、AccelerateDecelerate Interpolator(加速减速插值器:动画两头慢中间快)和Decelerate Interpolator(减速插值器:动画越来越慢)等。TypeEvaluator的中文翻译为类型估值算法,也叫估值器,它的作用是根据当前属性改变的百分比来计算改 变后的属性值,系统预知的有Int Evaluator(针对整型属性)、Float Evaluator(针对浮点型属性)和Argb Evaluator(针对Color属性)。属性动画中的插值器( Interpolator)和估值器( Evaluator)很重要,它们是实现非匀速动画的重要手段。可能这么说还有点晦涩,没关系,下面各处一个示例就很好理解了。 如下图所示,他是一个匀速动画,采用了线性插值器和整型估值算法,在40ms内,View的x属性从0到40的变换。

文章图片
由于动画的默认属性率为10ms/帧,所有改动画将分为5帧进行,我们来考虑第三帧(x=20, t=20ms),当t=20ms的时候,时间的流逝的百分比是0.5(20/40=0.5),意味着现在时间过了一般,那x应该改变多说呢?这个就是由插值器和估值算法来确定。拿线性插值器来说,当时间流逝一半的时候,x的变换应该是一般,即x的改变是0.5,为什么呢?因为它是线性插值器,是实现匀速动画的,下面看它的源码:
public class LinearInterpolator extends BaseInterpolator implements NativeInterpolatorFactory {public LinearInterpolator() {
}public LinearInterpolator(Context context, AttributeSet attrs) {
}public float getInterpolation(float input) {
return input;
}/** @hide */
@Override
public long createNativeInterpolator() {
return NativeInterpolatorFactoryHelper.createLinearInterpolator();
}
}
很显然,线性插值器的返回值和输入值一样,因此插值器返回的值是0.5,这意味着x改变的是0.5,这个时候插值器的工作就完成了。具体x变成了什么值,这个需要估值算法来确定,我们来看看整型估值算法的源码。
public class IntEvaluator implements TypeEvaluator {/**
* This function returns the result of linearly interpolating the start and end values, with
* fraction
representing the proportion between the start and end values. The
* calculation is a simple parametric calculation: result = x0 + t * (v1 - v0)
,
* where x0
is startValue
, x1
is endValue
,
* and t
is fraction
.
*
* @param fractionThe fraction from the starting to the ending values
* @param startValue The start value;
should be of type int
or
*Integer
* @param endValueThe end value;
should be of type int
or Integer
* @return A linear interpolation between the start and end values, given the
*fraction
parameter.
*/
public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
int startInt = startValue;
return (int)(startInt + fraction * (endValue - startInt));
}
}
上述算法很简答,evaluate的三个参数分别表示估值小数、开始值和结束值,对应于我们的例子就分别是0.5、0、40。根据上述算法,整型估值返回给我的结果就是20,这就是(x=20,t=20ms)的由来。 属性动画要求对象的该属性由set方法和get方法(可选)。插值器和估值算法除了系统提供的外,我们还可以自定义。实现方式也很简单,因为插值器和估值算法都是一个接口,且内部只有一个方法,我们只要派生一个类实现接口就可以了,然后就可以做出千奇百怪的动画效果了。具体一点就是:自定义插值器需要实现Interpolator或者Time Interpolator,自定义估值算法需要实现TypeEvaluator。另外就是如果要对其他类型(非int、float、Color)做动画,那么必须要自定义类型估值算法。
3.属性动画的监听器
属性动画提供了监听器用于监听动画的播放过程,主要有如下两个接口:AnimatorUpdateListener和AnimatorListener。
AnimatorListener的定义如下:
public static interface AnimationListener { void onAnimationStart(Animator animation);
void onAnimationEnd(Animator animation);
void onAnimationRepeat(Animator animation);
void onAnimationCancel(Animator animation);
}
从AnimatorListen可以看出,它可以监听动画的开始、结束以及重复播放。同时为了方便开发,系统还提供了AnimatorListenerAdapter这个类,他是AnimatorListener的适配器,这样我们就可以有选择地实现上面的4个方法了,毕竟不是所有方法都是我们感兴趣的。
下面再看一下AnimatorUpdateListener的定义,如下所示。
public static interface AnimatorUpdateListener {
void onAnimationUpdate(ValueAnimator animation);
}
AnimatorUpdateListener比较特殊,它会监听整个动画过程,动画是由许多帧组成的,没播放一帧,onAnimationUpdate就会被调用一次,利用这个特性,我们可以做一些特殊的事情。
4. 对任意属性做动画 这里先提出一个问题:给Button加一个动画,让这个Button的宽度从当前宽度增加到500px。也许你会说,这很简单,用View动画就可以搞定,我们可以来试试,你能写出来吗? 很快你就会恍然大悟,原来View动画根本就不支持对宽度进行动画。没错,View动画只支持四种类型:平移、旋转、缩放、不透明度。当x方向的缩放可以让Buttonx方向方法,看起来好像是宽度增加了,实际上不是,只是Button被放大了而已,而且由于只是x方向被放大,这个时候Button的背景以及上面的文本都被拉伸了,甚至由可能Button会超出屏幕。这样的的效果显然是很差的,而且也不是真正地对宽度做动画。不过索性我们还有属性动画,我们用属性动画试试,如下所示。
private void performAnimate() {
ObjectAnimator.ofInt(mButton,"width",500).setDuration(5000).start();
}@Override
public void onClick(View v) {
if (v == mButton){
performAnimate();
}
}
上述代码运行后发现没效果,其实没效果是对的,如果随便传递一个属性过去,轻则没有动画效果,重则程序直接Crash。 下面分析属性动画的原理:属性动画要求动画作用的对象提供该属性的set和get方法,属性动画根据外界传递的该属性的初始值和最终值,以动画的效果多次去调用set方法,每次传递给set方法的值都不一样,确切来说是随着时间的推移,所传递的值越来越接近最终值。总结一下,我们对object的属性abc做动画,如果想让动画生效,需要同时满足两个条件:
(1)object必须要提供setAbc方法,如果动画的时候没有传递初始值,那么还要提供getAbc方法,因为系统要去取abc属性的初始值(如果这条不满足,程序直接Crash); (2)object的setAbc对属性abc所做的改变必须能够通过某种方法反映出来,比如会带来UI的改变之类的(如果这条不满足,动画无效果但不会Crash)。
以上条件缺一不可。那么为什么我们对Button的width属性做动画会没有效果?这时因为Button内部虽然提供了getWidth和setWidth,但是这个setWidth方法并不是改变视图的大小,他是TextView新添加的方法,View是没有这个setWidth方法的,由于Button继承了TextView,所以Button也就由了setWidth方法。下面看一下这个getWidth和setWidth方法的源码。
@android.view.RemotableViewMethod
public void setWidth(int pixels) {
mMaxWidth = mMinWidth = pixels;
mMaxWidthMode = mMinWidthMode = PIXELS;
requestLayout();
invalidate();
}@ViewDebug.ExportedProperty(category = "layout")
public final int getWidth() {
return mRight - mLeft;
}
从上述源码可以看出, getWidth的确是获取View的宽度的,而setWidth是TextView和其子类的专属方法,它的作用不是设置View的宽度,而是设置TextView的最大宽度和最小宽度的,这个和TextView的宽度不是一个东西。具体来说,TextView的宽度对于XML中的android:layout_width属性,而TextView还有一个属性android:width,这个 android:width属性就对应了TextView的setWidth。总之,TextView和Button的setWidth、getWidth干的不是同一件事情,通过 setWidth无法改变空间的宽度,所以对width做属性动画没有效果。对应于动画的两个条件来说,本例中动画不生效的原因是满足了条件1而未满足条件2.
针对上述问题,官方文档告诉我们有三种解决方法:
- 给你对象加上get和set方法,如果你有权限的话;
- 用一个类来包装原始对象,间接为其提供get个set方法;
- 采用ValueAnimator,监听动画过程,自己实现属性的改变。
针对上面提出的三种解决办法,下面给出具体的介绍。
1.给你的对象加上get和set方法,如果你有权限的话
这个意思很好理解,如果你有权限的话,加上get和set就搞定了。但是很多时候我们没权限去这么做。比如文本开头所提到的问题,你无法给Button加上一个合乎要求的setWidth方法,因为这时Android SDK内部实现的。这个方法最简单,但是往往不可行的,这里就不对其进行更多的分析了。
2.用一个类来包装原始对象,间接为其提供get和set方法 这是一个很有用的解决办法,是笔者最喜欢用的,因为用起来很方便,也很好理解,下面将通过一个具体的例子来介绍它。
private void performAnimate() {
ViewWrapper wrapper = new ViewWrapper(mButton);
ObjectAnimator.ofInt(wrapper,"width",500).setDuration(5000).start();
}@Override
public void onClick(View v) {
if (v == mButton){
performAnimate();
}
}private static class ViewWrapper {
private View mTarget;
public ViewWrapper(View target) {
this.mTarget = target;
}
public int getWidth() {
return mTarget.getLayoutParams().width;
}
public void setWidth(int width){
mTarget.getLayoutParams().width = width;
mTarget.requestLayout();
}
}
上述代码再5s内让Button的宽度增加到了500px,为了达到这个效果,我们提供了ViewWrapper类专门用于包装View,具体到本例是包装Button。然后我们对ViewWrapper的width属性做动画,并且setWidth方法中修改器内部的target的宽度,而target实际上就是我们包装的Button。这样一个间接属性动画就搞定了,上述代码同样适用于一个对象的其他属性。
3.采用ValueAnimator,监听动画过程,自己实现属性的改变 首先说说什么是 ValueAnimator, ValueAnimator本身不做用于任何对象,也就是说直接使用它没有任何动画效果。它可以对一个值做动画,然后我们可以监听其动画过程,再动画过程中修改我们的对象的属性值,这样也就相当于我们的对象做了动画。下面用例子来说明:
private void performAnimate(final View target, final int start, final int end) {
ValueAnimator valueAnimator = ValueAnimator.ofInt(1,100);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
//持有一个IntEvaluator对象,下面方便估值的时候使用
private IntEvaluator mEvaluator = new IntEvaluator();
@Override
public void onAnimationUpdate(ValueAnimator animator) {
//获得当前动画的进度值,整数,1~100之间
int currentValue = https://www.it610.com/article/(Integer)animator.getAnimatedValue();
Log.d(TAG,"current value: " + currentValue);
//获得当前进度栈整个动画过程的比例,浮点型,0~1之间
float fraction = animator.getAnimatedFraction();
//直接用用整型估值器,通过比例计算出宽度,然后再设给Button
target.getLayoutParams().width = mEvaluator.evaluate(fraction, start, end);
target.requestLayout();
}
});
valueAnimator.setDuration(5000).start();
}@Override
public void onClick(View v) {
if (v == mButton){
performAnimate(mButton, mButton.getWidth(), 500);
}
}
上述代码的效果图和采用ViewWrapper是一样的。关于这个ValueAnimator要再说一下,拿上面的例子来说,它会再5000ms内将一个数从1变到100,然后动画的每一帧会回调onAnimationUpdate方法。再这个方法里,我们可以获取当前的值(1~100)和当前值所占的比例,我们可以计算出Button现在的宽度应该是多少。比如时间过了一般,当前值是50,比例为0.5,假设Button的起始宽度是100px,最终宽度是500px,那么Button增加的宽度也应该占总增加宽度的一半,总增加宽度是500-100=400,所以这个时候Button应该增加的宽度是400*0.5=200,那么当前Button的宽度应该为初始宽度+增加宽度(100+200=300)。上述计算过程很简单,其实他就是整型估值器IntEvaluator,所以我们不用自己写了,直接用吧。
5.属性动画的工作原理 属性动画要求动画作用的对象提供该属性的set方法,属性动画根据你传递的该属性的初始值和最终值,以动画的效果多次去调用set方法。每次传递set方法的值都不一样,确切来说是随着时间的推荐,所以传递的值越来越接近最终值。如果动画的时候没有传递初始值,那么还要提供get方法,因为系统要去获取属性的初始值。对于属性动画来说,启动话过程中所做的就是这么多,下面看源码分析。
首先我们要找一个入口,就从ObjectAnimator.ofInt(mButton,"width",500).setDuration(5000).start(),其他动画都是类似的。先看ObjectAnimator的start方法:
@Override
public void start() {
AnimationHandler.getInstance().autoCancelBasedOn(this);
if (DBG) {
Log.d(LOG_TAG, "Anim target, duration: " + getTarget() + ", " + getDuration());
for (int i = 0;
i < mValues.length;
++i) {
PropertyValuesHolder pvh = mValues[i];
Log.d(LOG_TAG, "Values[" + i + "]: " +
pvh.getPropertyName() + ", " + pvh.mKeyframes.getValue(0) + ", " +
pvh.mKeyframes.getValue(1));
}
}
super.start();
}
上面代码其实做的事情很简单,首先会判断如果当前动画、等待的动画(Pending)和延迟的动画(Delay)中有当前动画相同的动画,那么就把相同的动画给取消掉,接下那一段是log,再接着就是调用了弗雷的super.star()方法。因为ObjectAnimator继承了ValueAnimator,所以接下来我们看一下ValueAnimator的Start方法:
private void start(boolean playBackwards) {
if (Looper.myLooper() == null) {
throw new AndroidRuntimeException("Animators may only be run on Looper threads");
}
mReversing = playBackwards;
mSelfPulse = !mSuppressSelfPulseRequested;
// Special case: reversing from seek-to-0 should act as if not seeked at all.
if (playBackwards && mSeekFraction != -1 && mSeekFraction != 0) {
if (mRepeatCount == INFINITE) {
// Calculate the fraction of the current iteration.
float fraction = (float) (mSeekFraction - Math.floor(mSeekFraction));
mSeekFraction = 1 - fraction;
} else {
mSeekFraction = 1 + mRepeatCount - mSeekFraction;
}
}
mStarted = true;
mPaused = false;
mRunning = false;
mAnimationEndRequested = false;
// Resets mLastFrameTime when start() is called, so that if the animation was running,
// calling start() would put the animation in the
// started-but-not-yet-reached-the-first-frame phase.
mLastFrameTime = -1;
mFirstFrameTime = -1;
mStartTime = -1;
addAnimationCallback(0);
if (mStartDelay == 0 || mSeekFraction >= 0 || mReversing) {
// If there's no start delay, init the animation and notify start listeners right away
// to be consistent with the previous behavior. Otherwise, postpone this until the first
// frame after the start delay.
startAnimation();
if (mSeekFraction == -1) {
// No seek, start at play time 0. Note that the reason we are not using fraction 0
// is because for animations with 0 duration, we want to be consistent with pre-N
// behavior: skip to the final value immediately.
setCurrentPlayTime(0);
} else {
setCurrentFraction(mSeekFraction);
}
}
}
可以看出属性动画需要运行再Lopper的线程中。上述代码最终会调用AnimationHandler的start方法,这个AnimationHandler并不是Handler,他是一个Runnable。看一下他的代码,通过代码我们发现,很快就调到JNI层,不过JNI层最终还是要调回来的。它的run方法会被调用,这个Runnable涉及和底层的交互,这么就忽略这部分,直接看重点:ValueAnimator中的doAnimationonFrame方法,如下所示。
public final boolean doAnimationFrame(long frameTime) {
if (mStartTime < 0) {
// First frame. If there is start delay, start delay count down will happen *after* this
// frame.
mStartTime = mReversing ? frameTime : frameTime + (long) (mStartDelay * sDurationScale);
}// Handle pause/resume
if (mPaused) {
mPauseTime = frameTime;
removeAnimationCallback();
return false;
} else if (mResumed) {
mResumed = false;
if (mPauseTime > 0) {
// Offset by the duration that the animation was paused
mStartTime += (frameTime - mPauseTime);
}
}if (!mRunning) {
// If not running, that means the animation is in the start delay phase of a forward
// running animation. In the case of reversing, we want to run start delay in the end.
if (mStartTime > frameTime && mSeekFraction == -1) {
// This is when no seek fraction is set during start delay. If developers change the
// seek fraction during the delay, animation will start from the seeked position
// right away.
return false;
} else {
// If mRunning is not set by now, that means non-zero start delay,
// no seeking, not reversing. At this point, start delay has passed.
mRunning = true;
startAnimation();
}
}if (mLastFrameTime < 0) {
if (mSeekFraction >= 0) {
long seekTime = (long) (getScaledDuration() * mSeekFraction);
mStartTime = frameTime - seekTime;
mSeekFraction = -1;
}
mStartTimeCommitted = false;
// allow start time to be compensated for jank
}
mLastFrameTime = frameTime;
// The frame time might be before the start time during the first frame of
// an animation.The "current time" must always be on or after the start
// time to avoid animating frames at negative time intervals.In practice, this
// is very rare and only happens when seeking backwards.
final long currentTime = Math.max(frameTime, mStartTime);
boolean finished = animateBasedOnTime(currentTime);
if (finished) {
endAnimation();
}
return finished;
}
注意上述代码,末尾调用了animateBaseOnTime方法,而 animateBaseOnTime内部调用了animateValue,下面看 animateValue的代码:
@CallSuper
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);
}
if (mUpdateListeners != null) {
int numListeners = mUpdateListeners.size();
for (int i = 0;
i < numListeners;
++i) {
mUpdateListeners.get(i).onAnimationUpdate(this);
}
}
}
上述代码中的calculateValue方法就是计算每帧动画所应用的属性的值,下面着重看一下到底是再哪里调用属性的get和set方法的,毕竟这个才是 我们最关心的。 在初始化的时候,如果属性的初始值没有提供,则get方法会被调用,情况PropertyValuesHolder的setupValue方法,可以发现get方法是通过反射来调用的,如下所示。
private void setupValue(Object target, Keyframe kf) {
if (mProperty != null) {
kf.setValue(mProperty.get(target));
}
try {
if (mGetter == null) {
Class targetClass = target.getClass();
setupGetter(targetClass);
if (mGetter == null) {
// Already logged the error - just return to avoid NPE
return;
}
}
kf.setValue(mGetter.invoke(target));
} catch (InvocationTargetException e) {
Log.e("PropertyValuesHolder", e.toString());
} catch (IllegalAccessException e) {
Log.e("PropertyValuesHolder", e.toString());
}
}
当动画的下一帧到来的时候, PropertyValuesHolder 中的setAnimatedValue方法会将新的属性值设置给对象,调用其set方法。从下面的源码可以看出,set方法也是通过反射来调用的:
void setAnimatedValue(Object target) {
if (mProperty != null) {
mProperty.set(target, getAnimatedValue());
}
if (mSetter != null) {
try {
mTmpValueArray[0] = getAnimatedValue();
mSetter.invoke(target, mTmpValueArray);
} catch (InvocationTargetException e) {
Log.e("PropertyValuesHolder", e.toString());
} catch (IllegalAccessException e) {
Log.e("PropertyValuesHolder", e.toString());
}
}
}
【Android动画深入分析——属性动画】
推荐阅读
- android第三方框架(五)ButterKnife
- Android中的AES加密-下
- 带有Hilt的Android上的依赖注入
- android|android studio中ndk的使用
- Android事件传递源码分析
- RxJava|RxJava 在Android项目中的使用(一)
- Android7.0|Android7.0 第三方应用无法访问私有库
- 深入理解|深入理解 Android 9.0 Crash 机制(二)
- android防止连续点击的简单实现(kotlin)
- Android|Android install 多个设备时指定设备