最近做一款即时通讯软件,一些UI效果参考陌陌实现,看到陌陌的语音发送控件比较不错,于是试着仿照实现了一个。
先看效果图:
以下是我实现时考虑的几个问题:
①圆形一缩一放的效果这么实现
②按下View的时候圆形由空心变实心,图片还在,动画开始后图片消失了,这个怎么做。
③什么时候回收不需要的资源文件
下面看代码:
1.
public class PressSpeakView extends View {
/**
* View宽度
*/
private int mWidth;
/**
* View高度
*/
private int mHeight;
/**
* 圆半径
*/
private float mRadius;
/**
* 圆左边界
*/
private float mCircleLeftBorder;
/**
* 圆右边界
*/
private float mCircleRightBorder;
/**
* 圆上边界
*/
private float mCircleTopBorder;
/**
* 圆下边界
*/
private float mCircleBottomBorder;
/**
* 话筒Bitmap
*/
private Bitmap mMicBitmap;
/**
* 话筒Bitmap绘制区域
*/
private Rect mMicArea;
/**
* 垃圾箱Bitmap
*/
private Bitmap mTrashCanBitmap;
/**
* 垃圾箱Bitmap绘制区域
*/
private Rect mTrashCanArea;
/**
* 记录是否是按住状态
*/
private boolean isPressed;
/**
* 记录是否是可取消语音状态
*/
private boolean isCancelable;
private Paint mCirclePaint;
private ValueAnimator animator;
private int mGrayColor;
private MyOnTouchEventListener myTouchListener;
public PressSpeakView(Context context) {
this(context, null);
}
public PressSpeakView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PressSpeakView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initDefaultValue(context);
initPaint();
initAnimator();
}
这个类继承自View,在构造方法中执行一些初始化操作。其中mCirclePaint是画圆的画笔,animator是属性动画,为了实现圆形一收一放的动画效果,MyTouchListener是对外提供的接口,用于用户继续重写onTouchEvent方法。
2.
private void initAnimator() {
MyAnimatorListener animatorListener = new MyAnimatorListener();
animator = ValueAnimator.ofFloat(1f, 1.5f, 1f);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (Float) animation.getAnimatedValue();
mRadius = (mWidth / 2 - mWidth / 5) * value;
invalidate();
}
});
animator.addListener(animatorListener);
animator.setDuration(1000);
animator.setRepeatCount(ValueAnimator.INFINITE);
}
private void initDefaultValue(Context context) {
isPressed = false;
isCancelable = false;
mGrayColor = ContextCompat.getColor(context, R.color.gray_f5f5f5);
mWidth = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 100, getResources().getDisplayMetrics());
mHeight = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 100, getResources().getDisplayMetrics());
mMicBitmap = BitmapFactory.decodeResource(
getResources(), R.drawable.ic_chat_audio_record_blue);
mTrashCanBitmap = BitmapFactory.decodeResource(
getResources(), R.drawable.ic_chat_audio_record_cancel);
mMicArea = new Rect();
mTrashCanArea = new Rect();
}
private void initPaint() {
mCirclePaint = new Paint();
mCirclePaint.setColor(mGrayColor);
mCirclePaint.setAntiAlias(true);
mCirclePaint.setStrokeWidth(6);
mCirclePaint.setStyle(Paint.Style.STROKE);
}
这里是三个初始化方法。
① initDefaultValue方法初始化了一些属性值,View的宽高默认为100dp,拿到显示的Bitmap等等。
② initPaint方法初始化了画圆的画笔。
③ initAnimator方法初始化了属性动画,实现的效果是float值从1->1.5->1,持续1秒,无限循环。在这个方法里我们注册了两个listener,这两个listener都做了什么呢?
a.通过addUpdateListener方法,我们注册了一个AnimatorUpdateListener,用来监听动画的执行过程。在onAnimationUpdate方法中,我们得到当前的float值,用这个值动态计算圆半径(mRadius),执行invalidate重绘方法,这样就能实现圆的动画效果(解决了第一个考虑的问题)。
b.通过addListener,我们注册了一个AnimatorListner,代码如下:
private class MyAnimatorListener extends AnimatorListenerAdapter {
@Override
public void onAnimationStart(Animator animation) {
mMicArea.setEmpty();
}
@Override
public void onAnimationEnd(Animator animation) {
mMicArea.left = mWidth / 2 - 50;
mMicArea.right = mWidth / 2 + 50;
mMicArea.top = mHeight / 2 - 50;
mMicArea.bottom = mHeight / 2 + 50;
}
}
原生的AnimatorListener接口有很多方法需要我们实现,但是我们用不上那么多,所以我们自定义一个类,继承了AnimatorListenerAdapter。
AnimatorListenerAdapter是一个抽象类,已经实现了AnimatorListener接口并实现了其中所有方法,但都是空方法,所以我们继承这个类,重写我们需要的方法就可以了。
在这里我们只需要监听动画的开始和停止状态,并且都操作了mMicArea变量,mMicArea是Rect类的对象,用于放置“话筒”图片的位置,在动画开始时,我们执行setEmpty()方法,其实就是把mMicArea四个顶点都置0,这样图片就消失了,而动画结束时,我们再把mMicArea的四个顶点恢复,这样图片就又显示出来了。
3.
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (widthMode == MeasureSpec.EXACTLY) {
mWidth = widthSize;
}
if (heightMode == MeasureSpec.EXACTLY) {
mHeight = heightSize;
}
mMicArea.left = mTrashCanArea.left = mWidth / 2 - 50;
mMicArea.right = mTrashCanArea.right = mWidth / 2 + 50;
mMicArea.top = mTrashCanArea.top = mHeight / 2 - 50;
mMicArea.bottom = mTrashCanArea.bottom = mHeight / 2 + 50;
mRadius = mWidth / 2 - mWidth / 5;
mCircleLeftBorder = mWidth / 2 - mRadius;
mCircleRightBorder = mWidth / 2 + mRadius;
mCircleTopBorder = mHeight / 2 - mRadius;
mCircleBottomBorder = mHeight / 2 + mRadius;
setMeasuredDimension(mWidth, mHeight);
}
进入onMeasure方法,在这个方法里我们需要测量View的大小。
① 首先拿到widthMode、widthSize、heightMode、heightSize四个值
② 如果widthMode或heightMode为Exactly说明用户指定了宽高值,我们就使用widthSize或heightSize作为View的宽或高,并赋值给mWidth或mHeight,否则用户可能设置了WRAP_CONTENT,我们就使用默认的宽高值。
③ 根据上一步计算出的mWidth和mHeight设置放置图片矩形的位置、圆形的半径和圆形的四个边界(用于判定手指是否点在了圆形内部)。
4.
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (!isPressed) {
mCirclePaint.setStyle(Paint.Style.STROKE);
} else {
mCirclePaint.setStyle(Paint.Style.FILL);
if (isCancelable) {
mCirclePaint.setColor(Color.RED);
canvas.drawCircle(mWidth / 2, mHeight / 2, mRadius, mCirclePaint);
canvas.drawBitmap(mTrashCanBitmap, null, mTrashCanArea, null);
return;
}
}
mCirclePaint.setColor(mGrayColor);
canvas.drawCircle(mWidth / 2, mHeight / 2, mRadius, mCirclePaint);
canvas.drawBitmap(mMicBitmap, null, mMicArea, null);
}
在onDraw中执行绘制操作。
① 如果View不在按下状态,则绘制空心圆
② 如果View在按下状态,则绘制实心圆。
③ 在按下状态下进一步判断是否是可取消状态,如果是的话绘制红色实心圆,且显示“垃圾箱”图标,直接退出方法,不再往下进行。
④ 如果不是可取消状态,则绘制灰色实心圆,显示“话筒”图标。
⑤ 我们使用了drawBitmap(Bitmap bitmap, Rect src, Rect dst,Paint paint)这个方法绘制图片。源码如下:
public void drawBitmap(@NonNull Bitmap bitmap, @Nullable Rect src, @NonNull Rect dst,
@Nullable Paint paint) {
if (dst == null) {
throw new NullPointerException();
}
throwIfCannotDraw(bitmap);
final long nativePaint = paint == null ? 0 : paint.getNativeInstance();
int left, top, right, bottom;
if (src == null) {
left = top = 0;
right = bitmap.getWidth();
bottom = bitmap.getHeight();
} else {
left = src.left;
right = src.right;
top = src.top;
bottom = src.bottom;
}
native_drawBitmap(mNativeCanvasWrapper, bitmap, left, top, right, bottom,
dst.left, dst.top, dst.right, dst.bottom, nativePaint, mScreenDensity,
bitmap.mDensity);
}
第一个参数代表要绘制的原始图片,第二个参数代表要绘制原始图片的区域,第三个参数代表原始图片绘制到的区域,第四个参数是画笔。在这里我们第二个参数传入null,代表绘制整个图片。第三个参数是一个Rect对象且不能为null,前面我们已经得到了,如果我们不希望图片显示出来,就把这个Rect对象四个顶点设置成0,反之把四个顶点设置成有效值,这就是图片显示与消失的实现方式。
5.
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//判断点击区域是否在圆内
if (event.getX() < mCircleRightBorder
&& event.getX() > mCircleLeftBorder
&& event.getY() < mCircleBottomBorder
&& event.getY() > mCircleTopBorder) {
isPressed = true;
invalidate();
delayStartAnimator();
}
break;
case MotionEvent.ACTION_MOVE:
float x = event.getX();
float y = event.getY();
isCancelable = (y < mCircleTopBorder)
|| (y > mCircleBottomBorder)
|| (x > mCircleRightBorder)
|| (x < mCircleLeftBorder);
if (isCancelable) {
if (animator.isStarted()) {
animator.end();
}
} else {
if (!animator.isStarted()) {
instantStartAnimator();
}
}
break;
case MotionEvent.ACTION_UP:
isPressed = false;
isCancelable = false;
animator.end();
break;
}
return myTouchListener == null || myTouchListener.onTouchEvent(event);
}
在onTouchEvent中判断用户的操作。
① 在用户执行按下屏幕操作时(ACTION_DOWN),判断是否按在圆形内部,如果在的话,则设置按下状态,重绘一次View,再延时开启一个动画。
这里有个疑问,前面动画的监听事件里调用了 invalidate()重绘View的方法,为什么这里在开启动画前又调用了一次呢?
这里我们先调用了一次invalidate(),这时空心圆已经变成实心圆,但是圆中的图片还在,等动画开始时,圆中的图片就消失了,解决了第二点考虑的问题。
② 在用户手指在屏幕上移动时(ACTION_MOVE),判断手指是否移到了圆外,如果到圆外了说明要取消发送语音,这时动画停止,显示删除图片,如果手指又从圆外移回圆内,则继续播放动画。(取消发送语音一般是上滑取消,但我看陌陌的取消方式就是以圆的边界作为判断,所以也使用了这种判断方式)。
③用户手指离开屏幕时(ACTION_UP),恢复初始值,在这里可以判断手指最后在圆内还是圆外,决定是发送语音还是放弃发送语音。
④ 在最后返回值判断上,如果没有设置MyTouchListener,则默认返回true,说明我们消费了这个事件,如果设置了MyTouchListener,以listener返回为准。
6.
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mMicBitmap.recycle();
mTrashCanBitmap.recycle();
mMicBitmap = null;
mTrashCanBitmap = null;
if (animator.isStarted()) {
animator.cancel();
}
animator.removeAllListeners();
animator.removeAllUpdateListeners();
animator = null;
}
通过在网上查阅资料知道,View生命周期的最后一步就是走onDetachedFromWindow方法,在这里我们执行了两个操作:
① 回收bitmap对象,释放内存。
② 判断动画是否还在执行,如果正在执行就取消,同时取消动画的监听事件,这样能够防止程序意外停止时动画还在一直执行,持有它的对象不能被回收,造成内存泄漏。
解决了第三点考虑的问题。
小结:
由于自身水平有限,写出的代码与文章难免有错误与疏漏,还请大家指正,谢谢!
Github:https://github.com/venfor/PressSpeakDemo