昨天UI大佬让在Button上加上动效,效果大概如下图:
以上是最终的实现效果。
简单来说,需求是给一个圆形的按钮添加上一个点击时类似泛起涟漪的动效(好吧这个说法很牵强,但是UI大佬给的需求就是这个样子的orz)。
既然大佬提了要求,作为码农的我只能好好想想实现方式啦。
比较囧的问题是项目View层相关的代码和布局文件都已经基本实现了(天知道UI大哥为什么突然让做动效了)。最优的解决方案当然是用AOP的方式来给那些对应的Button加上动效啦,这样就不用手动修改一堆文件了。不过仔细想想,首先这个动效不是要应用到所有Button上的,其次以我的知识水平确实没想到有什么能尽量不动原来的代码的方法,看起来大规模的改动无可避免。
注意到动效的出现时机是点击时,像这种场景(状态改变时控件背景的改变)的处理方式一般都是用一个StateListDrawable(对应<selector>为根标签的drawable文件,即提供多个drawable资源,由系统根据控件状态做选择)作为控件的background。和以往的情况不同的是,这回需要提供给控件在pressed状态下显示的drawable应该是可以显示动画的drawable。查阅资料发现,TransitionDrawable可能可以实现这种效果。根据官方文档,使用TransitionDrawable,除了在res/drawable/文件路径下放置合适的xml文件外,还需要在代码中设置动画的转换时间。对应到当前的应用场景,则是在每一个(需要实现该动效的)Button对应的onClick方法中都需要进行动画转换时间的设置,实现上十分繁琐。
既然前两种可能的方案似乎都不可行,那不如自己动手自定义View。以自定义View方式来实现的话,那么使用上的简单与否就是可控的了(取决于自定义的View给出的使用方式,当然还是不可避免需要在多个文件中完成控件的替换)。考虑到这个自定义View之后可能还有其他队友会使用到,那么让它的使用方式尽量贴近于原生的Button应该是个不错的选择。也就是说,它应该支持使用xml布局文件来定制属性,在代码中使用时如同原生的Button一样简单的在OnClickListener的onClick方法中写上相关的逻辑即可。
使用
1. 在布局文件中配置相关属性
<com.zefengsysu.lib.ripplecirclebutton.RippleCircleButton
android:id="@+id/btn1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
/>
<com.zefengsysu.lib.ripplecirclebutton.RippleCircleButton
android:id="@+id/btn2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/btn1"
android:layout_marginTop="18dp"
app:text="呵呵"
app:textSize="16sp"
app:textColor="@android:color/white"
/>
<com.zefengsysu.lib.ripplecirclebutton.RippleCircleButton
android:id="@+id/btn3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/btn2"
android:layout_marginTop="18dp"
app:src="@drawable/help"
app:scaleType="fit"
/>
<com.zefengsysu.lib.ripplecirclebutton.RippleCircleButton
android:id="@+id/btn4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/btn3"
android:layout_marginTop="18dp"
app:text="呵呵"
app:textSize="16sp"
app:textColor="@android:color/white"
app:src="@drawable/help"
/>
<com.zefengsysu.lib.ripplecirclebutton.RippleCircleButton
android:id="@+id/btn5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/btn4"
android:layout_marginTop="18dp"
app:radius="32dp"
app:backgroundColor="@color/colorAccent"
app:src="@drawable/help"
/>
以上的属性配置对应于上文中给出的gif图。可以看到,RippleCircleButton可以在不额外配置属性的情况下使用,也可以像使用Button一样给控件加上文字(文本默认居中),此外,还可以像ImageButton一样添加图片资源(同样默认居中),当然,也可以使用radius和backgroundColor属性定制RippleCircleButton的大小和背景色。
2. 在代码文件中使用
RippleCircleButton rippleCircleButton = (RippleCircleButton) findViewById(R.id.btn1);
rippleCircleButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(MainActivity.this, "onClick", Toast.LENGTH_SHORT).show();
}
});
和一般原生控件设置点击监听完全一致。
实现思路
1. 动效实现
这个动效本身很简单,是一个圆缓慢放大到一定大小后快速缩小为原大小的效果,使用Property Animation就可以实现。这个动效可以分解为这个圆形的View在一定时间内在x方向和y方向做同样的放缩,实现上使用ObjectAnimator对这个View的ScaleX和ScaleY属性做变换即可,同时注意到实现这个动效用到了多个Animator,需要使用AnimatorSet来让多个动画同时触发,此外,缩小View的动画需要延时触发。
实现如下:
private void initRippleAnimation(View rippleView) {
float amplifyRate = 1.0f + RIPPLE_AMPLIFY_RATE;
List<Animator> animatorList = new ArrayList<>();
ObjectAnimator amplifyXAnimator = ObjectAnimator.ofFloat(rippleView, "ScaleX", 1.0f, amplifyRate);
amplifyXAnimator.setDuration(RIPPLE_AMPLIFY_DURATION);
animatorList.add(amplifyXAnimator);
ObjectAnimator amplifyYAnimator = ObjectAnimator.ofFloat(rippleView, "ScaleY", 1.0f, amplifyRate);
amplifyYAnimator.setDuration(RIPPLE_AMPLIFY_DURATION);
animatorList.add(amplifyYAnimator);
ObjectAnimator shrinkXAnimator = ObjectAnimator.ofFloat(rippleView, "ScaleX", amplifyRate, 1.0f);
shrinkXAnimator.setStartDelay(RIPPLE_AMPLIFY_DURATION);
amplifyXAnimator.setDuration(RIPPLE_SHRINK_DURATION);
animatorList.add(shrinkXAnimator);
ObjectAnimator shrinkYAnimator = ObjectAnimator.ofFloat(rippleView, "ScaleY", amplifyRate, 1.0f);
shrinkYAnimator.setStartDelay(RIPPLE_AMPLIFY_DURATION);
amplifyYAnimator.setDuration(RIPPLE_SHRINK_DURATION);
animatorList.add(shrinkYAnimator);
mAnimatorSet.playTogether(animatorList);
}
开始时这个圆形的View(最终实现为RippleCircleButton的内部类RippleView)应该被接收点击事件的那个View(最终实现为RippleCircleButton的内部类Button)完全覆盖遮挡,这样就完全符合我们在gif图上看到的效果了。
横截面示意图如下:
为了实现这种两个View初始位置重叠的效果(同时需要在四周留出足够的空间绘制动效,也即两个View需要居中放置于它们的parentView中),显然以RelativeLayout作为布局容器比较简单,因此RippleCircleButton直接继承了RelativeLayout,通过继承来复用RelativeLayout的Measure过程和Layout过程来确定其中的childernView的大小和位置。
最终的截面示意图如下(RippleView为放大到最大状态,从图上也可以看出实际上RippleView是半透明的):
2. 动效触发
当前的需求是在点击动作触发的时候触发动效,开始播放动画,很容易想到可以在onClick方法中去start先前实现的AnimatorSet。但同时,我希望能够将onClick方法留给用户去使用,将动效的实现细节隐藏起来,也就不能使用RippleCircleButton的onClick方法来触发动画。
得益于RippleCircleButton的实现不是一个单独的View,我可以在自定义View时为Button(此处指RippleCircleButton的内部类Button)设置点击事件的监听,在其中的onClick方法中开始播放动画,再将具体的业务逻辑委托给RippleCircleButton的事件监听器去处理。对于用户来说,他们能见到的只有RippleCircleButton(两个内部类都是私有的,不对外暴露),需要实现按钮(对用户来说是RippleCircleButton,实际上应该是RippleCircleButton的内部类Button)点击的相关逻辑时为RippleCircleButton设置事件监听器是自然而然的,因此也只需要将RippleCircleButton的事件监听器留给用户去实现,这种实现方式完全可行。而且有一个额外的好处,通过这种实现方式,用户能触发点击事件的实际点击区域为内部类Button在布局中占有的区域,而RippleCircleButton内,内部类Button外的区域的点击操作则无法触发点击事件(理由下面会阐述),具体的业务逻辑处理代码也不会执行,这也更符合用户的直观感受。
示意图如下(实际上与圆相切的那个矩形为内部类Button的占有区域):
实现如下:
@Override
public void setOnClickListener(OnClickListener onClickListener) {
mOnClickListener = onClickListener;
}
@Override
public boolean hasOnClickListeners() {
return mOnClickListener != null;
}
...
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mAnimatorSet.start();
if (hasOnClickListeners()) {
mOnClickListener.onClick(RippleCircleButton.this);
}
}
});
由于View(这里的View指的是RippleCircleButton的父类View,见下图)
的onClickListener是私有的,因此为了能够获取到用户设置的onClickListener,我重写了setOnClickListener,另外用一个当前RippleCircleButton的私有的变量mOnClickListener拦截下了用户设置的onClickListener。同时这样做也带来了一个额外的好处,由于用户设置的onClickListener未被View持有,系统在进行点击事件分发时,无法觉察到RippleCircleButton是否已经设置了onClickListener(因为在点击事件分发时,从系统角度看每一个控件要么是View要么是ViewGroup,系统不会关注控件的具体实现,最终系统将认为RippleCircleButton没有设置onClickListener),因此在RippleCircleButton上的点击不会触发onClick方法,也就保证了只有在内部类Button上的点击会触发点击事件。
3. 文本、图片资源的包含
为了方便之后的使用,我决定让RippleCircleButton既能支持以文本作为控件内容,也支持图片资源作为控件内容。实现的思路也是很直接的,即我需要在内部类Button的onDraw方法中添加相应的逻辑,将drawable或者文本画到画布上。
具体实现如下:
private class Button extends View {
public Button(Context context) {
super(context);
}
@Override
protected void onDraw(Canvas canvas) {
drawCircle(canvas);
if (mSrc != null) {
drawDrawable(canvas);
} else if (mText != null) {
drawText(canvas);
}
}
private void drawCircle(Canvas canvas) {
Paint paint = new Paint();
paint.setColor(mBackgroundColor);
paint.setStyle(Paint.Style.FILL);
paint.setAntiAlias(true);
canvas.drawCircle(mRadius, mRadius, mRadius, paint);
}
private void drawText(Canvas canvas) {
Paint paint = new Paint();
Typeface font = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL);
Rect textBounds = new Rect();
paint.setColor(mTextColor);
paint.setTextSize(mTextSize);
paint.setTextAlign(Paint.Align.CENTER);
paint.setAntiAlias(true);
paint.setTypeface(font);
paint.getTextBounds(mText, 0, mText.length(), textBounds);
canvas.drawText(mText, (float) mRadius, mRadius + textBounds.height() / 2.0f, paint);
}
private void drawDrawable(Canvas canvas) {
int left;
int top;
int width = mSrc.getIntrinsicWidth();
int height = mSrc.getIntrinsicHeight();
if (mScaleType == SCALE_TYPE_FIT) {
if (mSrc.getIntrinsicWidth() > mSrc.getIntrinsicHeight()) {
width = (int)(mRadius * Math.sqrt(2));
height = width * mSrc.getIntrinsicHeight() / mSrc.getIntrinsicWidth();
} else {
height = (int)(mRadius * Math.sqrt(2));
width = height * mSrc.getIntrinsicWidth() / mSrc.getIntrinsicHeight();
}
}
left = (getWidth() - width) / 2;
top = (getHeight() - height) / 2;
mSrc.setBounds(left, top, left + width, top + height);
mSrc.draw(canvas);
}
}
简单解释一下。
从onDraw方法的实现可以看出,在用户既添加了文本内容又添加了图片资源的情况下,只显示图片资源。
drawText方法比较简单,实现了将文本内容“写”到控件中居中位置的功能。
drawDrawable方法逻辑的则比较复杂。mScaleType值为SCALE_TYPE_FIT时,需先将图片资源进行缩放以适配radius属性的值。借助下图来说明计算过程:
如图所示,为了保证Button(RippleCircleButton内部类)的背景区域能够完全容纳最终呈现的drawable,先构造一个能容纳最终呈现的drawable的正方形(以drawable较长一边长度为边长,即上图白色虚线围起的区域),则Button的圆形背景区域最小为该正方形的外切圆。可以直观得到圆形区域的两条半径和该正方形的一条边构成一个等腰直角三角形,借助半径(radius属性值)可以计算最终缩放得到的图片资源的宽高。等腰直角三角形的关系同样应用于计算mRadius值上(当添加了图片资源且mScaleType值为SCALE_TYPE_ORIGINAL或者添加了文本内容时,需要重新计算mRadius的值以容纳控件的内容),代码如下:
private void computeExactRadius() {
if (mSrc != null) {
if (mScaleType == SCALE_TYPE_ORIGINAL) {
int sideLength = Math.max(mSrc.getIntrinsicWidth(), mSrc.getIntrinsicHeight());
int srcBoundsRadius = (int) (sideLength / Math.sqrt(2));
mRadius = mRadius > srcBoundsRadius ? mRadius : srcBoundsRadius;
}
} else if (mText != null) {
Paint paint = new Paint();
Typeface font = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL);
Rect textBounds = new Rect();
paint.setColor(mTextColor);
paint.setTextAlign(Paint.Align.CENTER);
paint.setAntiAlias(true);
paint.setTextSize(mTextSize);
paint.setTypeface(font);
paint.getTextBounds(mText, 0, mText.length(), textBounds);
int sideLength = Math.max(textBounds.width(), textBounds.height());
int textBoundsRadius = (int) (sideLength / Math.sqrt(2));
mRadius = mRadius > textBoundsRadius ? mRadius : textBoundsRadius;
}
}
回到drawDrawable方法,计算得到最终呈现的drawable的宽高后,还需要利用宽高的值设置drawable绘制时的边界位置,保证drawable在Button中居中显示。
完成以上三步,RippleCircleButton也就基本实现完毕了。完整代码见github。
总结
RippleCircleButton并不完善,实际上,它的良好表现很大程度上需要依赖于用户的正确使用。比如说,为了使RippleCircleButton表现良好,使用的时候最好将layout_width和layout_height两个值设置为wrap_content(同时需要它的parentView确实留出了充足的空间)。如果设置一个特定的dimension值或者设置为match_parent,那么如果最终计算得到的RippleCircleButton的width和height值过大,实际上也无法对显示产生效果,因为内部Button和RippleView只依赖于radius属性值而不依赖于RippleCircleButton的width或者height的值;当然,如果最终计算得到的RippleCircleButton的width和height值过小,那么界面上就只能显示出圆形按钮的一个局部了。
当然,看着这感人的动效,我想它存在的最大意义在于练手(逃