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中定义

    @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();
        }
    }

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

  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



    
        

            

            

        
    

初始化工作

在确定好方案后,在实现上边两个方法前,我需要先做一些初始化工作(加载动画资源,将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来完成的,不影响思路。

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

  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. 准备动画资源,这一步单独下边介绍

初始化-准备动画资源

    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的实现方式。

你可能感兴趣的:(ui,动画,android)