• CheckBox/RadioButton切换动效实现


    背景

    因为项目需求要为CheckBox和RadioButton添加切换动画,以达到个性化的UI组件效果,具体来说项目需要的切换动画为复杂动画,即无法通过简单的平移,旋转,缩放等基本图形变换来模拟。经过查找资料,发现有如下几种实现动效的方式:

    1. ObjectAnimator,用来实现如平移,旋转,缩放等最基本的动效,无法满足项目要求。
    2. 自定义View专门来实现动画,这种成本很高,另外完全自定义也无法直接使用CheckBox的各项功能。
    3. VectorAnimatorDrawable,看起来很靠谱,能够实现很复杂的动画。但最大的难题是UI无法直接输出对应的资源,UI同学一般提供的动效资源是动效json,GIF,视频等资源,想要将其转化为VectorAnimatorDrawable资源非常困难,复杂一点的动效,要想完全还原设计稿基本不可能。
    4. Lottie动画,这也是Android开发领域较为主流的实现复杂动画的手段,这也是我最终采用的方案。但一般Lottie动画都是直接通过的LottieAnimationView(继承自ImageView)来使用的,如何将其与CheckBox进行结合是需要考虑的问题,下边也将详细描述Lottie动画如何结合CheckBox来完成切换动效功能。

    分析&实现

    首先上边的介绍都是关于CheckBox如何进行动画的,但对于RadioButton其实没提到。这是因为CheckBox和RadioButton本质上是同一类切换按钮,他们实现动效的思路也基本一致。另外从Android实现的角度来说他们也都是继承自CompoundButton的,该组件的特点是有选中和未选中两种状态,会根据点击事件切换状态。后边我们也将仅介绍基于CheckBox的动效实现,RadioButton基本可以复用该实现。

    问题1. CheckBox动画该如何做,切状态的时机是在播动画前还是后。(借鉴Switch组件实现)

    首先CheckBox切换动画为setChecked(),该方法直接在其父类CompundButton中定义

    1. @Override
    2. public void setChecked(boolean checked) {
    3. if (mChecked != checked) {
    4. mCheckedFromResource = false;
    5. mChecked = checked;
    6. refreshDrawableState();
    7. // ... 省略部分无关代码
    8. }
    9. }

    该方法直接修改了mChecked状态,并没有提供任何关于动画播放的hook点。

    一个小插曲,由于此次需求中还有Switch组件动效的开发,于是通过对Switch组件的研究,找到了播放动画的切入点。Switch组件通过重写setChecked()实现了动画播放(文章最后有展开介绍,实际是在SwitchCompat类中)。

    1. @Override
    2. public void setChecked(boolean checked) {
    3. super.setChecked(checked);
    4. // Calling the super method may result in setChecked() getting called
    5. // recursively with a different value, so load the REAL value...
    6. checked = isChecked();
    7. if (checked) {
    8. setOnStateDescriptionOnRAndAbove();
    9. } else {
    10. setOffStateDescriptionOnRAndAbove();
    11. }
    12. // 如果View仍然在View树上,则播动画;否则不播,直接切换状态
    13. if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
    14. animateThumbToCheckedState(checked);
    15. } else {
    16. // Immediately move the thumb to the new position.
    17. cancelPositionAnimator();
    18. setThumbPosition(checked ? 1 : 0);
    19. }
    20. }

    在Switch组件中我们找到了动画播放的方式:先切换check状态,再播动画(animateThumbToCheckedState),于是现在我们可以得出CheckBox的动画播放框架(重写setChecked())

    1. @Override
    2. public void setChecked(boolean checked) {
    3. super.setChecked(checked);
    4. checked = isChecked();
    5. // 如果View仍然在View树上,则播动画;否则不播,直接切换状态
    6. if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
    7. animateThumbToCheckedState(checked);
    8. } else {
    9. cancelAnimator();
    10. }
    11. }

    剩下的工作就是填充下面两个方法,整体来说就是处理具体动画该怎么播

    1. animateThumbToCheckedState(checked)
    2. cancelAnimator()

    问题2:动画该怎么播

    经过前边的分析,我们已经选定了Lottie动画作为播放动画的方案。并且预期的CheckBox切换流程为(以uncheck -> checked为例): 

    1. mChecked状态先变化,但不希望看到icon的突变
    2. 播放Lottie动画,动画播放盖在原来的icon之上
    3. 动画播放结束,icon变为状态切换后checked状态

    在这里我选择了LayerDrawable + StateListDrawable + LottieDrawable来完成功能。

    因为需要在CheckBox的icon中播动画,icon是Drawable类型,所以直接使用LottieDrawable,并且动画要盖在静态icon上,于是使用LayerDrawable来组合多个Drawable

    1. "1.0" encoding="utf-8"?>
    2. "http://schemas.android.com/apk/res/android">
    3. "true"
    4. android:drawable="@drawable/checkbox_bg_checked_normal" />
    5. android:drawable="@drawable/checkbox_bg_unchecked_normal" />

    初始化工作

    在确定好方案后,在实现上边两个方法前,我需要先做一些初始化工作(加载动画资源,将LottieDrawable动态加入到LayerDrawable中)。

    1. private fun initAnimation() {
    2. // btnDrawable为Checkbox的icon
    3. if (btnDrawable is LayerDrawable) {
    4. val layerDrawable = btnDrawable
    5. if (layerDrawable.numberOfLayers < 1) return
    6. // 创建LottieDrawable
    7. checkStateChangeDrawable = LottieDrawable()
    8. // drawable有复用机制(详见DrawableCache类),需要判断顶层drawable是否是LottieDrawable,如果是则替换相应drawable
    9. if (layerDrawable.getDrawable(layerDrawable.numberOfLayers - 1) is LottieDrawable) {
    10. layerDrawable.setDrawable(
    11. layerDrawable.numberOfLayers - 1,
    12. checkStateChangeDrawable
    13. )
    14. } else {
    15. layerDrawable.addLayer(checkStateChangeDrawable)
    16. }
    17. // innerDrawable为Checkbox切换后的静态icon资源,LottieDrawable要和innerDrawable宽高对齐
    18. val innerDrawable = layerDrawable.getDrawable(0)
    19. val innerDrawableBounds: Rect = innerDrawable.bounds
    20. checkStateChangeDrawable.alpha = 0
    21. checkStateChangeDrawable.bounds = innerDrawableBounds
    22. // 设置动画播放监听,开始动画时动画drawable可见,静态drawable不可见,结束时则相反,来实现动画播放的无缝切换
    23. checkStateChangeDrawable.addAnimatorListener(object : AnimatorListenerAdapter() {
    24. override fun onAnimationStart(animation: Animator) {
    25. innerDrawable.alpha = 0
    26. checkStateChangeDrawable.alpha = 255
    27. }
    28. override fun onAnimationEnd(animation: Animator) {
    29. innerDrawable.alpha = 255
    30. checkStateChangeDrawable.alpha = 0
    31. }
    32. })
    33. // 准备动画资源
    34. prepareAnimationResource()
    35. } else if (btnDrawable != null) {
    36. Log.e(TAG, "Only support LayerDrawable for CompoundButton!")
    37. }
    38. }

    注:这里代码采用了kotlin,主要因为封装的工具类是用kotlin来完成的,不影响思路。

    初始化工作中,我们主要有以下几步:

    1. 创建LottieDrawable
      1. 注:下边解析不影响整体流程阅读
      2. 由于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.
    2. 将LottieDrawable与静态Drawable(实际类型为StateListDrawable)宽高对齐
    3. 设置动画播放监听,以实现动画播放的无缝切换
    4. 准备动画资源,这一步单独下边介绍

    初始化-准备动画资源

    1. private fun prepareAnimationResource() {
    2. if (btnDrawable == null) return
    3. // 加载取消选中动画
    4. LottieCompositionFactory.fromAsset(context, uncheckAnimAsset).apply {
    5. addListener { result: LottieComposition ->
    6. lottieCompositions[0] = result
    7. // 初始化动画size
    8. checkStateChangeDrawable.composition = result
    9. checkStateChangeDrawable.scale = btnDrawable.bounds.width().toFloat() / checkStateChangeDrawable.bounds.width()
    10. }
    11. addFailureListener {
    12. Log.e(TAG, "load lottie resource: $uncheckAnimAsset fail", it)
    13. }
    14. }
    15. // 加载选中动画
    16. LottieCompositionFactory.fromAsset(context, checkedAnimAsset).apply {
    17. addListener { result: LottieComposition ->
    18. lottieCompositions[1] = result
    19. }
    20. addFailureListener {
    21. Log.e(TAG, "load lottie resource: $checkedAnimAsset fail", it)
    22. }
    23. }
    24. }

    加载选中动画和取消选中动画,需要注意的是,我们在加载取消选中动画时初始化了checkStateChangeDrawable的scale。这主要是由于LottieDrawable的动画大小只能由scale控制(简单的Drawable.setBounds()无法修改大小),无法直接设置宽高,而设置scale时必须先设置好动画资源,scale的设置才会生效。于是我们选择在动画资源加载后来设置scale。

    animateThumbToCheckedState(checked)

    初始化工作完成后,接下来该实现动画播放了,即实现animateThumbToCheckedState(checked)方法。

    1. private fun animateCheckedStateChange(newState: Boolean) {
    2. cancelAnimator()
    3. val animIndex = if (newState) 1 else 0
    4. val lottieComposition = lottieCompositions[animIndex] ?: return
    5. checkStateChangeDrawable.composition = lottieComposition
    6. checkStateChangeDrawable.start()
    7. // 实践中software_layer动画效果最好
    8. if (View.LAYER_TYPE_SOFTWARE != compoundButton.layerType) {
    9. setLayerType(View.LAYER_TYPE_SOFTWARE, null)
    10. }
    11. }

    初始化工作完成后,动画播放就很简单了,先取消上次动画播放,然后选择要播的动画直接播放。

    注:对于选择LAYER_TYPE_DRAWABLE,即软件渲染的方式来播放动画,主要是因为这种方式在我需要播放的动画素材中效果最流畅。如果你觉得动画播放的不流畅,可尝试切换渲染方式试试。(思路参考自LottieAnimationView.playAnimation()方法)

    cancelAnimator()

    cancelAnimator()实现,很简单就不解析了

    1. fun cancelAnimator() {
    2. if (checkStateChangeDrawable.isAnimating) {
    3. checkStateChangeDrawable.stop()
    4. }
    5. }

    总结

    至此基本实现完毕。回顾一下,可以看到我们的实现基本与CheckBox无直接关联,是对CompoundButton的改造,这也意味着对于RadioButton仍能完全采用该思路来实现。下边附上代码实现中的类字段清单,以方便理解上述代码。

    代码实现中用到的类字段清单

    1. // CheckBox的icon对应的drawable
    2. private val btnDrawable: Drawable?,
    3. // 选中对应的动画资源文件路径(assets目录下)
    4. private val checkedAnimAsset: String,
    5. // 取消选中对应的动画资源文件路径
    6. private val uncheckAnimAsset: String,
    7. // 播放动画的LottieDrawable
    8. private var checkStateChangeDrawable: LottieDrawable = LottieDrawable()
    9. // 动画资源加载后lottie资源实体列表
    10. private val lottieCompositions = arrayOf(null, null)

    Switch组件动画实现(可忽略)

    至此已不属于本文标题描述内容,读者可选择不读。设置此节主要是由于Switch组件的实现难点不多,并不想多开一篇,也就在此一并记录下,以便后续自查。

    默认动画分析

    上面分析中我们已经提到了Switch组件(实际在SwitchCompat类中)重写了CompoundButton的setChecked()方法,并基于此来实现动画。

    1. @Override
    2. public void setChecked(boolean checked) {
    3. super.setChecked(checked);
    4. // Calling the super method may result in setChecked() getting called
    5. // recursively with a different value, so load the REAL value...
    6. checked = isChecked();
    7. if (checked) {
    8. setOnStateDescriptionOnRAndAbove();
    9. } else {
    10. setOffStateDescriptionOnRAndAbove();
    11. }
    12. // 如果View仍然在View树上,则播动画;否则不播,直接切换状态
    13. if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
    14. animateThumbToCheckedState(checked);
    15. } else {
    16. // Immediately move the thumb to the new position.
    17. cancelPositionAnimator();
    18. setThumbPosition(checked ? 1 : 0);
    19. }
    20. }

    其中,animateThumbToCheckState(checked)方法中即实现了动画的播放

    1. private static final Property THUMB_POS =
    2. new Property(Float.class, "thumbPos") {
    3. @Override
    4. public Float get(SwitchCompat object) {
    5. return object.mThumbPosition;
    6. }
    7. @Override
    8. public void set(SwitchCompat object, Float value) {
    9. object.setThumbPosition(value);
    10. }
    11. };
    12. private void animateThumbToCheckedState(final boolean newCheckedState) {
    13. final float targetPosition = newCheckedState ? 1 : 0;
    14. mPositionAnimator = ObjectAnimator.ofFloat(this, THUMB_POS, targetPosition);
    15. mPositionAnimator.setDuration(THUMB_ANIMATION_DURATION);
    16. if (Build.VERSION.SDK_INT >= 18) {
    17. mPositionAnimator.setAutoCancel(true);
    18. }
    19. mPositionAnimator.start();
    20. }

    动画播放实现中简单的实现了Switch的滑块移动动画。基于此分析,我们想要自定义Switch切换动画,那首先就得先取消默认动画,然后再播放我们自己实现的动画。因为本次需求中我们自己实现的Switch动画不具备通用性,所以下边介绍中,我们将重点介绍如何取消默认动画,并简单的实现一个自定义动画。

    取消默认动画

    首先基于上述分析,我们应该重写setChecked()方法,另外因为SwitchCompat在实现setChecked()方法时还做了一些额外工作,另外我们还想复用CompoundButton中的setChecked()实现,所以我们想仅仅取消掉默认动画,并仍然需要调用super.setChecked()。现在遇到一个问题,我们调用不到SwitchCompat的cancelAnimator()方法,该方法并不对子类开放。但是这难不倒我们,调不到我们就反射调!

    1. @Override
    2. public void setChecked(boolean checked) {
    3. super.setChecked(checked);
    4. // 取消SwitchCompat动画,采用自己实现
    5. if (reflectManager != null && reflectManager.cancelSwitchCompatAnimate()) {
    6. checked = isChecked();
    7. if (getWindowToken() != null && ViewCompat.isLaidOut(this)) {
    8. animateThumbToCheckedState(checked);
    9. } else {
    10. // Immediately move the thumb to the new position.
    11. cancelPositionAnimator();
    12. reflectManager.setThumbPosition(checked ? 1 : 0);
    13. }
    14. }
    15. }
    16. // 整体处理反射调用
    17. private static class ReflectManager {
    18. private final SwitchCompat switchView;
    19. private boolean canReflect = true;
    20. private Method cancelPositionAnimatorMethod = null;
    21. private Field mThumbPositionField = null;
    22. public ReflectManager(SwitchCompat switchView) {
    23. this.switchView = switchView;
    24. init();
    25. }
    26. private void init() {
    27. try {
    28. cancelPositionAnimatorMethod = SwitchCompat.class.getDeclaredMethod("cancelPositionAnimator");
    29. cancelPositionAnimatorMethod.setAccessible(true);
    30. mThumbPositionField = SwitchCompat.class.getDeclaredField("mThumbPosition");
    31. mThumbPositionField.setAccessible(true);
    32. } catch (Exception e) {
    33. canReflect = false;
    34. }
    35. }
    36. // 反射调用SwitchCompat组件的cancelPositionAnimator()方法
    37. public boolean cancelSwitchCompatAnimate() {
    38. init();
    39. if (!canReflect || cancelPositionAnimatorMethod == null) return false;
    40. try {
    41. cancelPositionAnimatorMethod.invoke(switchView);
    42. } catch (Exception e) {
    43. canReflect = false;
    44. }
    45. return canReflect;
    46. }
    47. }

    看setChecked(state)的整体框架,我们仍然采用SwitchCompat中的动画实现思路,但在播放自定义动画前,反射取消了默认动画。

    实现自定义动画

    这里介绍下我们需求中需要实现的自定义动画,需要在切换时滑块自定义滑动速度,并进行滑块颜色渐变。于是我们的实现可以基本按照默认实现进行,仅需要调整动画插值器,并监听动画进度调整滑块颜色。很简单就不再分析了。

    1. private void animateThumbToCheckedState(final boolean newCheckedState) {
    2. final float targetPosition = newCheckedState ? 1 : 0;
    3. mPositionAnimator = ObjectAnimator.ofFloat(this, new Property(Float.class, "thumbPos") {
    4. @Override
    5. public Float get(SwitchCompat object) {
    6. return reflectManager.getThumbPosition();
    7. }
    8. @Override
    9. public void set(SwitchCompat object, Float value) {
    10. int color = (int) argbEvaluator.evaluate(value, 0xFF797980, 0xFF8C32FF);
    11. getThumbDrawable().setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN));
    12. reflectManager.setThumbPosition(value);
    13. }
    14. }, targetPosition);
    15. mPositionAnimator.setInterpolator(PathInterpolatorCompat.create(0.55f, 0f, 0.35f, 1f));
    16. mPositionAnimator.setDuration(250);
    17. mPositionAnimator.setAutoCancel(true);
    18. mPositionAnimator.start();
    19. }
    20. // ------- 下边方法在ReflectManager类中 -------
    21. public float getThumbPosition() {
    22. try {
    23. return (float) mThumbPositionField.get(switchView);
    24. } catch (Exception e) {
    25. canReflect = false;
    26. }
    27. return 0f;
    28. }
    29. public boolean setThumbPosition(float f) {
    30. try {
    31. mThumbPositionField.set(switchView, f);
    32. switchView.invalidate();
    33. } catch (Exception e) {
    34. canReflect = false;
    35. }
    36. return canReflect;
    37. }

    总结

    Switch动画实现的重点在取消默认动画,通过反射即可实现。如果滑块动画很复杂,理论上我们仍然可以使用CheckBox实现时采用的LottileDrawable+LayerDrawable的实现方式。

  • 相关阅读:
    深度剖析集成学习GBDT
    SpringBoot 关于异步与事务一起使用的问题
    搜索与图论 ---- 匈牙利算法
    小米汽车上市进入倒计时,已开启内部试驾
    游戏盾SDK是如何实现智能加速的?
    黑马JVM总结(十二)
    Java基础篇 数组
    Web测试有哪些基本要点?软件测试找第三方软件检测机构靠谱吗?
    55、美国德克萨斯大学奥斯汀分校、钱德拉家族电气与计算机工程系:通过迁移学习解决BCI个体差异性[不得不说,看技术还得是老美]
    设计模式选择题答案
  • 原文地址:https://blog.csdn.net/qq_39620460/article/details/131094082