因为项目需求要为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
-
在确定好方案后,在实现上边两个方法前,我需要先做一些初始化工作(加载动画资源,将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来完成的,不影响思路。
初始化工作中,我们主要有以下几步:
Invalid drawable added to LayerDrawable! Drawable already belongs to another owner but does not expose a constant state.
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)方法。
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()实现,很简单就不解析了
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组件(实际在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的实现方式。