因为项目需求要为CheckBox和RadioButton添加切换动画,以达到个性化的UI组件效果,具体来说项目需要的切换动画为复杂动画,即无法通过简单的平移,旋转,缩放等基本图形变换来模拟。经过查找资料,发现有如下几种实现动效的方式:
首先上边的介绍都是关于CheckBox如何进行动画的,但对于RadioButton其实没提到。这是因为CheckBox和RadioButton本质上是同一类切换按钮,他们实现动效的思路也基本一致。另外从Android实现的角度来说他们也都是继承自CompoundButton的,该组件的特点是有选中和未选中两种状态,会根据点击事件切换状态。后边我们也将仅介绍基于CheckBox的动效实现,RadioButton基本可以复用该实现。
首先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();
- }
- }
剩下的工作就是填充下面两个方法,整体来说就是处理具体动画该怎么播
经过前边的分析,我们已经选定了Lottie动画作为播放动画的方案。并且预期的CheckBox切换流程为(以uncheck -> checked为例):
在这里我选择了LayerDrawable + StateListDrawable + LottieDrawable来完成功能。
因为需要在CheckBox的icon中播动画,icon是Drawable类型,所以直接使用LottieDrawable,并且动画要盖在静态icon上,于是使用LayerDrawable来组合多个Drawable
- "1.0" encoding="utf-8"?>
"http://schemas.android.com/apk/res/android"> -
-
-
-
- "true"
- android:drawable="@drawable/checkbox_bg_checked_normal" />
-
-
- android:drawable="@drawable/checkbox_bg_unchecked_normal" />
-
-
-
初始化工作
在确定好方案后,在实现上边两个方法前,我需要先做一些初始化工作(加载动画资源,将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
(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
THUMB_POS = - new Property
(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
(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的实现方式。