背景
因为项目需求要为CheckBox和RadioButton添加切换动画,以达到个性化的UI组件效果,具体来说项目需要的切换动画为复杂动画,即无法通过简单的平移,旋转,缩放等基本图形变换来模拟。经过查找资料,发现有如下几种实现动效的方式:
- ObjectAnimator,用来实现如平移,旋转,缩放等最基本的动效,无法满足项目要求。
- 自定义View专门来实现动画,这种成本很高,另外完全自定义也无法直接使用CheckBox的各项功能。
- VectorAnimatorDrawable,看起来很靠谱,能够实现很复杂的动画。但最大的难题是UI无法直接输出对应的资源,UI同学一般提供的动效资源是动效json,GIF,视频等资源,想要将其转化为VectorAnimatorDrawable资源非常困难,复杂一点的动效,要想完全还原设计稿基本不可能。
- Lottie动画,这也是Android开发领域较为主流的实现复杂动画的手段,这也是我最终采用的方案。但一般Lottie动画都是直接通过的LottieAnimationView(继承自ImageView)来使用的,如何将其与CheckBox进行结合是需要考虑的问题,下边也将详细描述Lottie动画如何结合CheckBox来完成切换动效功能。
分析&实现
首先上边的介绍都是关于CheckBox如何进行动画的,但对于RadioButton其实没提到。这是因为CheckBox和RadioButton本质上是同一类切换按钮,他们实现动效的思路也基本一致。另外从Android实现的角度来说他们也都是继承自CompoundButton的,该组件的特点是有选中和未选中两种状态,会根据点击事件切换状态。后边我们也将仅介绍基于CheckBox的动效实现,RadioButton基本可以复用该实现。
问题1. CheckBox动画该如何做,切状态的时机是在播动画前还是后。(借鉴Switch组件实现)
首先CheckBox切换动画为setChecked(),该方法直接在其父类CompundButton中定义
@Override
public void setChecked(boolean checked) {
if (mChecked != checked) {
mCheckedFromResource = false;
mChecked = checked;
refreshDrawableState();
// ... 省略部分无关代码
}
}
该方法直接修改了mChecked状态,并没有提供任何关于动画播放的hook点。
一个小插曲,由于此次需求中还有Switch组件动效的开发,于是通过对Switch组件的研究,找到了播放动画的切入点。Switch组件通过重写setChecked()实现了动画播放(文章最后有展开介绍,实际是在SwitchCompat类中)。
@Override
public void setChecked(boolean checked) {
super.setChecked(checked);
// Calling the super method may result in setChecked() getting called
// recursively with a different value, so load the REAL value...
checked = isChecked();
if (checked) {
setOnStateDescriptionOnRAndAbove();
} else {
setOffStateDescriptionOnRAndAbove();
}
// 如果View仍然在View树上,则播动画;否则不播,直接切换状态
if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
animateThumbToCheckedState(checked);
} else {
// Immediately move the thumb to the new position.
cancelPositionAnimator();
setThumbPosition(checked ? 1 : 0);
}
}
在Switch组件中我们找到了动画播放的方式:先切换check状态,再播动画(animateThumbToCheckedState),于是现在我们可以得出CheckBox的动画播放框架(重写setChecked())
@Override
public void setChecked(boolean checked) {
super.setChecked(checked);
checked = isChecked();
// 如果View仍然在View树上,则播动画;否则不播,直接切换状态
if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
animateThumbToCheckedState(checked);
} else {
cancelAnimator();
}
}
剩下的工作就是填充下面两个方法,整体来说就是处理具体动画该怎么播
- animateThumbToCheckedState(checked)
- cancelAnimator()
问题2:动画该怎么播
经过前边的分析,我们已经选定了Lottie动画作为播放动画的方案。并且预期的CheckBox切换流程为(以uncheck -> checked为例):
- mChecked状态先变化,但不希望看到icon的突变
- 播放Lottie动画,动画播放盖在原来的icon之上
- 动画播放结束,icon变为状态切换后checked状态
在这里我选择了LayerDrawable + StateListDrawable + LottieDrawable来完成功能。
因为需要在CheckBox的icon中播动画,icon是Drawable类型,所以直接使用LottieDrawable,并且动画要盖在静态icon上,于是使用LayerDrawable来组合多个Drawable
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<selector>
<item android:state_checked="true"
android:drawable="@drawable/checkbox_bg_checked_normal" />
<item
android:drawable="@drawable/checkbox_bg_unchecked_normal" />
</selector>
</item>
</layer-list>
初始化工作
在确定好方案后,在实现上边两个方法前,我需要先做一些初始化工作(加载动画资源,将LottieDrawable动态加入到LayerDrawable中)。
private fun initAnimation() {
// btnDrawable为Checkbox的icon
if (btnDrawable is LayerDrawable) {
val layerDrawable = btnDrawable
if (layerDrawable.numberOfLayers < 1) return
// 创建LottieDrawable
checkStateChangeDrawable = LottieDrawable()
// drawable有复用机制(详见DrawableCache类),需要判断顶层drawable是否是LottieDrawable,如果是则替换相应drawable
if (layerDrawable.getDrawable(layerDrawable.numberOfLayers - 1) is LottieDrawable) {
layerDrawable.setDrawable(
layerDrawable.numberOfLayers - 1,
checkStateChangeDrawable
)
} else {
layerDrawable.addLayer(checkStateChangeDrawable)
}
// innerDrawable为Checkbox切换后的静态icon资源,LottieDrawable要和innerDrawable宽高对齐
val innerDrawable = layerDrawable.getDrawable(0)
val innerDrawableBounds: Rect = innerDrawable.bounds
checkStateChangeDrawable.alpha = 0
checkStateChangeDrawable.bounds = innerDrawableBounds
// 设置动画播放监听,开始动画时动画drawable可见,静态drawable不可见,结束时则相反,来实现动画播放的无缝切换
checkStateChangeDrawable.addAnimatorListener(object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
innerDrawable.alpha = 0
checkStateChangeDrawable.alpha = 255
}
override fun onAnimationEnd(animation: Animator) {
innerDrawable.alpha = 255
checkStateChangeDrawable.alpha = 0
}
})
// 准备动画资源
prepareAnimationResource()
} else if (btnDrawable != null) {
Log.e(TAG, "Only support LayerDrawable for CompoundButton!")
}
}
注:这里代码采用了kotlin,主要因为封装的工具类是用kotlin来完成的,不影响思路。
初始化工作中,我们主要有以下几步:
-
创建LottieDrawable
-
注:下边解析不影响整体流程阅读
-
由于drawable的复用机制(详见DrawableCache类),在退出当前页面后重新进入的场景下,重新加载btnDrawable时则会触发drawable的复用机制,导致拿到的仍然是同一个LayerDrawable(对象不同,但资源相同,也意味着,lottileDrawable已经在之前被加入到了LayerDrawable中了),这个时候不做特殊处理则会导致重复添加,也会有如下错误日志。
Invalid drawable added to LayerDrawable! Drawable already belongs to another owner but does not expose a constant state.
-
-
将LottieDrawable与静态Drawable(实际类型为StateListDrawable)宽高对齐
-
设置动画播放监听,以实现动画播放的无缝切换
-
准备动画资源,这一步单独下边介绍
初始化-准备动画资源
private fun prepareAnimationResource() {
if (btnDrawable == null) return
// 加载取消选中动画
LottieCompositionFactory.fromAsset(context, uncheckAnimAsset).apply {
addListener { result: LottieComposition ->
lottieCompositions[0] = result
// 初始化动画size
checkStateChangeDrawable.composition = result
checkStateChangeDrawable.scale = btnDrawable.bounds.width().toFloat() / checkStateChangeDrawable.bounds.width()
}
addFailureListener {
Log.e(TAG, "load lottie resource: $uncheckAnimAsset fail", it)
}
}
// 加载选中动画
LottieCompositionFactory.fromAsset(context, checkedAnimAsset).apply {
addListener { result: LottieComposition ->
lottieCompositions[1] = result
}
addFailureListener {
Log.e(TAG, "load lottie resource: $checkedAnimAsset fail", it)
}
}
}
加载选中动画和取消选中动画,需要注意的是,我们在加载取消选中动画时初始化了checkStateChangeDrawable的scale。这主要是由于LottieDrawable的动画大小只能由scale控制(简单的Drawable.setBounds()无法修改大小),无法直接设置宽高,而设置scale时必须先设置好动画资源,scale的设置才会生效。于是我们选择在动画资源加载后来设置scale。
animateThumbToCheckedState(checked)
初始化工作完成后,接下来该实现动画播放了,即实现animateThumbToCheckedState(checked)方法。
private fun animateCheckedStateChange(newState: Boolean) {
cancelAnimator()
val animIndex = if (newState) 1 else 0
val lottieComposition = lottieCompositions[animIndex] ?: return
checkStateChangeDrawable.composition = lottieComposition
checkStateChangeDrawable.start()
// 实践中software_layer动画效果最好
if (View.LAYER_TYPE_SOFTWARE != compoundButton.layerType) {
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
}
}
初始化工作完成后,动画播放就很简单了,先取消上次动画播放,然后选择要播的动画直接播放。
注:对于选择LAYER_TYPE_DRAWABLE,即软件渲染的方式来播放动画,主要是因为这种方式在我需要播放的动画素材中效果最流畅。如果你觉得动画播放的不流畅,可尝试切换渲染方式试试。(思路参考自LottieAnimationView.playAnimation()方法)
cancelAnimator()
cancelAnimator()实现,很简单就不解析了
fun cancelAnimator() {
if (checkStateChangeDrawable.isAnimating) {
checkStateChangeDrawable.stop()
}
}
总结
至此基本实现完毕。回顾一下,可以看到我们的实现基本与CheckBox无直接关联,是对CompoundButton的改造,这也意味着对于RadioButton仍能完全采用该思路来实现。下边附上代码实现中的类字段清单,以方便理解上述代码。
代码实现中用到的类字段清单
// CheckBox的icon对应的drawable
private val btnDrawable: Drawable?,
// 选中对应的动画资源文件路径(assets目录下)
private val checkedAnimAsset: String,
// 取消选中对应的动画资源文件路径
private val uncheckAnimAsset: String,
// 播放动画的LottieDrawable
private var checkStateChangeDrawable: LottieDrawable = LottieDrawable()
// 动画资源加载后lottie资源实体列表
private val lottieCompositions = arrayOf<LottieComposition?>(null, null)
Switch组件动画实现(可忽略)
至此已不属于本文标题描述内容,读者可选择不读。设置此节主要是由于Switch组件的实现难点不多,并不想多开一篇,也就在此一并记录下,以便后续自查。
默认动画分析
上面分析中我们已经提到了Switch组件(实际在SwitchCompat类中)重写了CompoundButton的setChecked()方法,并基于此来实现动画。
@Override
public void setChecked(boolean checked) {
super.setChecked(checked);
// Calling the super method may result in setChecked() getting called
// recursively with a different value, so load the REAL value...
checked = isChecked();
if (checked) {
setOnStateDescriptionOnRAndAbove();
} else {
setOffStateDescriptionOnRAndAbove();
}
// 如果View仍然在View树上,则播动画;否则不播,直接切换状态
if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
animateThumbToCheckedState(checked);
} else {
// Immediately move the thumb to the new position.
cancelPositionAnimator();
setThumbPosition(checked ? 1 : 0);
}
}
其中,animateThumbToCheckState(checked)方法中即实现了动画的播放
private static final Property<SwitchCompat, Float> THUMB_POS =
new Property<SwitchCompat, Float>(Float.class, "thumbPos") {
@Override
public Float get(SwitchCompat object) {
return object.mThumbPosition;
}
@Override
public void set(SwitchCompat object, Float value) {
object.setThumbPosition(value);
}
};
private void animateThumbToCheckedState(final boolean newCheckedState) {
final float targetPosition = newCheckedState ? 1 : 0;
mPositionAnimator = ObjectAnimator.ofFloat(this, THUMB_POS, targetPosition);
mPositionAnimator.setDuration(THUMB_ANIMATION_DURATION);
if (Build.VERSION.SDK_INT >= 18) {
mPositionAnimator.setAutoCancel(true);
}
mPositionAnimator.start();
}
动画播放实现中简单的实现了Switch的滑块移动动画。基于此分析,我们想要自定义Switch切换动画,那首先就得先取消默认动画,然后再播放我们自己实现的动画。因为本次需求中我们自己实现的Switch动画不具备通用性,所以下边介绍中,我们将重点介绍如何取消默认动画,并简单的实现一个自定义动画。
取消默认动画
首先基于上述分析,我们应该重写setChecked()方法,另外因为SwitchCompat在实现setChecked()方法时还做了一些额外工作,另外我们还想复用CompoundButton中的setChecked()实现,所以我们想仅仅取消掉默认动画,并仍然需要调用super.setChecked()。现在遇到一个问题,我们调用不到SwitchCompat的cancelAnimator()方法,该方法并不对子类开放。但是这难不倒我们,调不到我们就反射调!
@Override
public void setChecked(boolean checked) {
super.setChecked(checked);
// 取消SwitchCompat动画,采用自己实现
if (reflectManager != null && reflectManager.cancelSwitchCompatAnimate()) {
checked = isChecked();
if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
animateThumbToCheckedState(checked);
} else {
// Immediately move the thumb to the new position.
cancelPositionAnimator();
reflectManager.setThumbPosition(checked ? 1 : 0);
}
}
}
// 整体处理反射调用
private static class ReflectManager {
private final SwitchCompat switchView;
private boolean canReflect = true;
private Method cancelPositionAnimatorMethod = null;
private Field mThumbPositionField = null;
public ReflectManager(SwitchCompat switchView) {
this.switchView = switchView;
init();
}
private void init() {
try {
cancelPositionAnimatorMethod = SwitchCompat.class.getDeclaredMethod("cancelPositionAnimator");
cancelPositionAnimatorMethod.setAccessible(true);
mThumbPositionField = SwitchCompat.class.getDeclaredField("mThumbPosition");
mThumbPositionField.setAccessible(true);
} catch (Exception e) {
canReflect = false;
}
}
// 反射调用SwitchCompat组件的cancelPositionAnimator()方法
public boolean cancelSwitchCompatAnimate() {
init();
if (!canReflect || cancelPositionAnimatorMethod == null) return false;
try {
cancelPositionAnimatorMethod.invoke(switchView);
} catch (Exception e) {
canReflect = false;
}
return canReflect;
}
}
看setChecked(state)的整体框架,我们仍然采用SwitchCompat中的动画实现思路,但在播放自定义动画前,反射取消了默认动画。
实现自定义动画
这里介绍下我们需求中需要实现的自定义动画,需要在切换时滑块自定义滑动速度,并进行滑块颜色渐变。于是我们的实现可以基本按照默认实现进行,仅需要调整动画插值器,并监听动画进度调整滑块颜色。很简单就不再分析了。
private void animateThumbToCheckedState(final boolean newCheckedState) {
final float targetPosition = newCheckedState ? 1 : 0;
mPositionAnimator = ObjectAnimator.ofFloat(this, new Property<SwitchCompat, Float>(Float.class, "thumbPos") {
@Override
public Float get(SwitchCompat object) {
return reflectManager.getThumbPosition();
}
@Override
public void set(SwitchCompat object, Float value) {
int color = (int) argbEvaluator.evaluate(value, 0xFF797980, 0xFF8C32FF);
getThumbDrawable().setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN));
reflectManager.setThumbPosition(value);
}
}, targetPosition);
mPositionAnimator.setInterpolator(PathInterpolatorCompat.create(0.55f, 0f, 0.35f, 1f));
mPositionAnimator.setDuration(250);
mPositionAnimator.setAutoCancel(true);
mPositionAnimator.start();
}
// ------- 下边方法在ReflectManager类中 -------
public float getThumbPosition() {
try {
return (float) mThumbPositionField.get(switchView);
} catch (Exception e) {
canReflect = false;
}
return 0f;
}
public boolean setThumbPosition(float f) {
try {
mThumbPositionField.set(switchView, f);
switchView.invalidate();
} catch (Exception e) {
canReflect = false;
}
return canReflect;
}
总结
Switch动画实现的重点在取消默认动画,通过反射即可实现。如果滑块动画很复杂,理论上我们仍然可以使用CheckBox实现时采用的LottileDrawable+LayerDrawable的实现方式。