一、前言
在很多app种内置了语音助手,也存在各种动画,主要原因是处理2个阶段问题,第一个是监听声音的等待效果,第二个是语意解析存在一定耗时的等待效果,前者要求有声音输入时有视觉反馈,后者让用户知道在处理某些事情,同时呢,这个效果还能互相切换,这是一般语音监听动画的设计逻辑。本文提供一种,希望对大家有所帮助。
1.1 效果图
这个View分为两种状态,显示LISTENING状态,即表示监听录音,而LOADING是将语音解析为文字的状态。
二、实现方法
2.1 过渡动画
必须等待上一个动画结束后再切换为指定状态,我们可以清楚的看到,点完按钮大概有1秒种的实心圆与空心圆的国度效果。
2.2 声音抖动计算
本文没有明确计算线性音量,取出音量数据,进行了简单的计算
public void updateShakeValue(int volume) {
if (this.getVisibility() != View.VISIBLE || !isAttachedToWindow()) return;
if (!isPlaying) return;
float ratio = volume * 1.0f / this.mMaxShakeRange;
if (ratio < 1f / 4) {
ratio = 0;
}
if (ratio >= 1f / 4 && ratio < 2f / 4) {
ratio = 1f / 4;
}
if (ratio >= 2f / 4 && ratio < 3f / 4) {
ratio = 2f / 4;
}
if (ratio >= 3f / 4) {
ratio = 1f;
}
updateShakeRatio(ratio);
}
2.3 模式切换
需要LISTENING和LOADING 模式之间互相切换
public void startPlay(final int state) {
post(new Runnable() {
@Override
public void run() {
setState(state);
if (!isPlaying) {
mCurrentState = mNextState;
}
isPlaying = true;
if (mNextState == mCurrentState) {
if (state == STATE_LISTENING) {
startListeningAnim();
} else if (state == STATE_LOADING) {
startLoadingAnim();
}
} else {
startTransformAnim();
}
}
});
}
#loading 效果
radarView.startPlay(SpeechRadarView.STATE_LOADING);
#listening效果
radarView.startPlay(SpeechRadarView.STATE_LISTENING);
#停止播放
radarView.stopPlay();
抖动幅度范围,以适应不同类型的需求
#最大振幅
radarView.setMaxShakeRange(30);
#当前值
radarView.updateShakeValue(20);
2.4 动画实现
STATE_LISTENING
三、全部代码
下面是完整代码,和普通的View定义一样。
在本篇我们使用了大量的属性动画,使用属性动画的好处就是帧率稳定性比较高。
public class SpeechRadarView extends View {
private static final long ANIMATION_CIRCLE_TIMEOUT = 1000;
private static final long ANIMATION_LOADING_TIMEOUT = 800;
private ValueAnimator mShakeAnimatorTimer;
private int mFixedRadius = 0;
private int mMaxRadius = 0;
private TextPaint mPaint;
private AnimationCircle[] mAnimationCircle = new AnimationCircle[2];
private float mBullketStrokeWidthSize;
private AnimatorSet mAnimatorTimerSet = null;
private AnimatorSet mNextAnimatorTimerSet = null;
private ValueAnimator mTransformAnimatorTimer;
private int ANIMATION_MAIN_COLOR = 0x99FF8C14;
private static final int MAIN_COLOR = 0xFFFF8C14;
RectF arcBounds = new RectF();
LinearInterpolator linearInterpolator = new LinearInterpolator();
AccelerateDecelerateInterpolator accelerateDecelerateInterpolator = new AccelerateDecelerateInterpolator();
public static final int STATE_LOADING = 0;
public static final int STATE_LISTENING = 1;
private int mCurrentState = STATE_LOADING;
private int mNextState = STATE_LOADING; //过渡值
private float LOADING_STOKE_WIDTH = 0;
private int LOADING_START_ANGLE = 90;
private int mCurrentAngle = LOADING_START_ANGLE;
private int mTransformLoadingColor = Color.TRANSPARENT;
private int mTransformListeningColor = Color.TRANSPARENT;
private boolean isPlaying = false;
private float mShakeRatio = 0;
private float mNextShakeRatio = 0;
private long mStartShakeTime = 0;
private int mMaxShakeRange = 100;
public SpeechRadarView(Context context) {
this(context, null);
}
public SpeechRadarView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SpeechRadarView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initPaint();
}
private void setState(int state) {
if (this.mNextState == state) {
return;
}
this.mNextState = state;
}
public int getState() {
return mNextState;
}
private void initPaint() {
// 实例化画笔并打开抗锯齿
mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mPaint.setAntiAlias(true);
mPaint.setPathEffect(new CornerPathEffect(10)); //设置线条类型
mPaint.setStrokeWidth(dip2px(1));
mPaint.setTextSize(dip2px((12)));
mPaint.setStyle(Paint.Style.STROKE);
mBullketStrokeWidthSize = dip2px(5);
LOADING_STOKE_WIDTH = dip2px(5);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
width = (int) dip2px(210);
}
if (heightMode != MeasureSpec.EXACTLY) {
height = (int) dip2px(210);
}
setMeasuredDimension(width, height);
}
public float dip2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width == 0 || height == 0) return;
int centerX = width / 2;
int centerY = height / 2;
int diameter = Math.min(width, height) / 2;
mFixedRadius = diameter / 3;
mMaxRadius = diameter;
initAnimationCircle();
if (!isInEditMode() && !isPlaying) return;
int layerId = saveLayer(canvas, centerX, centerY);
if (mNextState == mCurrentState) {
if (mCurrentState == STATE_LISTENING) {
drawAnimationCircle(canvas);
drawFixCircle(canvas, MAIN_COLOR);
drawFlashBullket(canvas, Color.WHITE, mShakeRatio);
mShakeRatio = 0;
} else if (mCurrentState == STATE_LOADING) {
drawLoadingArc(canvas, MAIN_COLOR);
drawFlashBullket(canvas, MAIN_COLOR, 0);
}
} else {
if (this.mNextState == STATE_LISTENING) {
drawLoadingArc(canvas, mTransformLoadingColor);
drawFixCircle(canvas, mTransformListeningColor);
drawFlashBullket(canvas, Color.WHITE, 0);
} else {
drawFixCircle(canvas, mTransformListeningColor);
drawLoadingArc(canvas, mTransformLoadingColor);
drawFlashBullket(canvas, MAIN_COLOR, 0);
}
}
restoreLayer(canvas, layerId);
}
private void drawLoadingArc(Canvas canvas, int color) {
int oldColor = mPaint.getColor();
Paint.Style style = mPaint.getStyle();
float strokeWidth = mPaint.getStrokeWidth();
mPaint.setStrokeWidth(LOADING_STOKE_WIDTH);
float innerOffset = LOADING_STOKE_WIDTH / 2;
mPaint.setColor(color);
mPaint.setStyle(Paint.Style.STROKE);
arcBounds.set(-mFixedRadius + innerOffset, -mFixedRadius + innerOffset, mFixedRadius - innerOffset, mFixedRadius - innerOffset);
canvas.drawArc(arcBounds, mCurrentAngle, 270, false, mPaint);
mPaint.setColor(oldColor);
mPaint.setStyle(style);
mPaint.setStrokeWidth(strokeWidth);
}
private void drawFlashBullket(Canvas canvas, int color, float fraction) {
int bullketZoneWidth = mFixedRadius;
int bullketZoneHeight = mFixedRadius * 2 / 3;
int minHeight = (int) (bullketZoneHeight / 3f);
int maxRangeHeight = (int) (bullketZoneHeight * 2 / 3f);
drawFlashBullket(canvas, bullketZoneWidth, color, minHeight, (maxRangeHeight * fraction));
}
private void drawFlashBullket(Canvas canvas, int width, int color, int height, float delta) {
int offset = (int) ((width - mBullketStrokeWidthSize * 4) / 3);
int oldColor = mPaint.getColor();
float strokeWidth = mPaint.getStrokeWidth();
if (delta < 0f) {
delta = 0f;
}
mPaint.setColor(color);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setStrokeWidth(mBullketStrokeWidthSize);
for (int i = 0; i < 4; i++) {
int startX = (int) (i * (offset + mBullketStrokeWidthSize) - width / 2 + mBullketStrokeWidthSize / 2);
if (i == 0 || i == 3) {
canvas.drawLine(startX, -height / 2F + delta * 1 / 3, startX, height / 2F + delta * 1 / 3, mPaint);
} else {
canvas.drawLine(startX, -(height / 2F + delta * 2 / 3), startX, (height / 2F + delta * 2 / 3), mPaint);
}
}
mPaint.setColor(oldColor);
mPaint.setStrokeWidth(strokeWidth);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
}
private void drawAnimationCircle(Canvas canvas) {
for (int i = 0; i < mAnimationCircle.length; i++) {
AnimationCircle circle = mAnimationCircle[i];
if (circle.radius > mFixedRadius) {
drawCircle(canvas, circle.color, circle.radius);
Log.e("AnimationCircle", "i=" + i + " , radius=" + circle.radius);
} else {
Log.d("AnimationCircle", "i=" + i + " , radius=" + circle.radius);
}
}
}
private void initAnimationCircle() {
for (int i = 0; i < mAnimationCircle.length; i++) {
if (mAnimationCircle[i] == null) {
if (i == 0) {
mAnimationCircle[i] = new AnimationCircle(mMaxRadius, mFixedRadius, 0x88FF8C14);
} else {
mAnimationCircle[i] = new AnimationCircle(mMaxRadius, mFixedRadius, 0x99FF8C14);
}
} else {
if (mAnimationCircle[i].token != mMaxRadius) {
mAnimationCircle[i].radius = mFixedRadius;
mAnimationCircle[i].token = mMaxRadius;
}
}
}
}
private void drawCircle(Canvas canvas, int color, float radius) {
int oldColor = mPaint.getColor();
Paint.Style style = mPaint.getStyle();
float strokeWidth = mPaint.getStrokeWidth();
mPaint.setStrokeWidth(0);
mPaint.setColor(color);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawCircle(0, 0, radius, mPaint);
mPaint.setColor(oldColor);
mPaint.setStyle(style);
mPaint.setStrokeWidth(strokeWidth);
}
private void restoreLayer(Canvas canvas, int save) {
canvas.restoreToCount(save);
}
private int saveLayer(Canvas canvas, int centerX, int centerY) {
int save = canvas.save();
canvas.translate(centerX, centerY);
return save;
}
private void drawFixCircle(Canvas canvas, int color) {
drawCircle(canvas, color, mFixedRadius);
}
public void startPlay(final int state) {
post(new Runnable() {
@Override
public void run() {
setState(state);
if (!isPlaying) {
mCurrentState = mNextState;
}
isPlaying = true;
if (mNextState == mCurrentState) {
if (state == STATE_LISTENING) {
startListeningAnim();
} else if (state == STATE_LOADING) {
startLoadingAnim();
}
} else {
startTransformAnim();
}
}
});
}
public void startLoadingAnim() {
if (mAnimatorTimerSet != null) {
mAnimatorTimerSet.cancel();
}
mAnimatorTimerSet = getAnimatorLoadingSet();
if (mAnimatorTimerSet != null) {
mAnimatorTimerSet.start();
}
}
private void startTransformAnim() {
if (mNextAnimatorTimerSet != null) {
mNextAnimatorTimerSet.cancel();
}
if (mTransformAnimatorTimer != null) {
mTransformAnimatorTimer.cancel();
}
mTransformAnimatorTimer = buildTransformAnimatorTimer(mCurrentState, mNextState);
if (mNextState == STATE_LISTENING) {
mNextAnimatorTimerSet = getAnimatorCircleSet();
} else {
mNextAnimatorTimerSet = getAnimatorLoadingSet();
}
if (mTransformAnimatorTimer != null) {
mTransformAnimatorTimer.start();
}
if (mNextAnimatorTimerSet != null) {
mNextAnimatorTimerSet.start();
}
}
public void startListeningAnim() {
if (mAnimatorTimerSet != null) {
mAnimatorTimerSet.cancel();
}
AnimatorSet animatorTimerSet = getAnimatorCircleSet();
if (animatorTimerSet == null) return;
mAnimatorTimerSet = animatorTimerSet;
mAnimatorTimerSet.start();
}
@Nullable
private AnimatorSet getAnimatorCircleSet() {
AnimatorSet animatorTimerSet = new AnimatorSet();
ValueAnimator firstAnimatorTimer = buildCircleAnimatorTimer(mAnimationCircle[0]);
ValueAnimator secondAnimatorTimer = buildCircleAnimatorTimer(mAnimationCircle[1]);
if (firstAnimatorTimer == null || secondAnimatorTimer == null) return null;
secondAnimatorTimer.setStartDelay(ANIMATION_CIRCLE_TIMEOUT / 2);
animatorTimerSet.playTogether(firstAnimatorTimer, secondAnimatorTimer);
return animatorTimerSet;
}
@Nullable
private AnimatorSet getAnimatorLoadingSet() {
ValueAnimator valueAnimator = buildLoadingAnimatorTimer();
if (valueAnimator == null) return null;
AnimatorSet animatorTimerSet = new AnimatorSet();
animatorTimerSet.play(valueAnimator);
return animatorTimerSet;
}
@Nullable
private ValueAnimator buildCircleAnimatorTimer(final AnimationCircle circle) {
if (mFixedRadius <= 0 || circle == null) return null;
ValueAnimator animatorTimer = ValueAnimator.ofFloat(mFixedRadius, Math.min(getWidth(),getHeight()) / 2F);
animatorTimer.setDuration(ANIMATION_CIRCLE_TIMEOUT);
animatorTimer.setRepeatCount(ValueAnimator.INFINITE);
animatorTimer.setInterpolator(linearInterpolator);
animatorTimer.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float dx = (float) animation.getAnimatedValue();
float fraction = 1 - animation.getAnimatedFraction();
float radius = dx;
int color = argb((int) (Color.alpha(ANIMATION_MAIN_COLOR) * fraction), Color.red(ANIMATION_MAIN_COLOR), Color.green(ANIMATION_MAIN_COLOR), Color.blue(ANIMATION_MAIN_COLOR));
if (mCurrentState != mNextState) {
color = Color.TRANSPARENT;
}
if (circle.radius != radius || circle.color != color) {
circle.radius = radius;
circle.color = color;
postInvalidate();
}
}
});
return animatorTimer;
}
@Nullable
private ValueAnimator buildLoadingAnimatorTimer() {
if (mFixedRadius <= 0) return null;
ValueAnimator animatorTimer = ValueAnimator.ofFloat(0, 1);
animatorTimer.setDuration(ANIMATION_LOADING_TIMEOUT);
animatorTimer.setRepeatCount(ValueAnimator.INFINITE);
animatorTimer.setInterpolator(new AccelerateDecelerateInterpolator());
animatorTimer.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float fraction = animation.getAnimatedFraction();
int angle = (int) (LOADING_START_ANGLE + fraction * 360);
if (mCurrentAngle != angle) {
mCurrentAngle = angle;
postInvalidate();
}
}
});
return animatorTimer;
}
@Nullable
private ValueAnimator buildTransformAnimatorTimer(final int currentState, final int nextState) {
if (mFixedRadius <= 0) return null;
final int alpha = Color.alpha(MAIN_COLOR);
final int red = Color.red(MAIN_COLOR);
final int green = Color.green(MAIN_COLOR);
final int blue = Color.blue(MAIN_COLOR);
ValueAnimator animatorTimer = ValueAnimator.ofFloat(currentState, nextState);
animatorTimer.setDuration(ANIMATION_LOADING_TIMEOUT);
animatorTimer.setInterpolator(accelerateDecelerateInterpolator);
animatorTimer.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
if (mCurrentState != mNextState) {
mTransformListeningColor = argb((int) (alpha * animatedValue), red, green, blue);
mTransformLoadingColor = argb((int) (alpha * (1 - animatedValue)), red, green, blue);
Log.d("animatedValue", " --- >" + animatedValue);
postInvalidate();
}
}
});
animatorTimer.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
resetAnimationState();
}
@Override
public void onAnimationCancel(Animator animation) {
super.onAnimationCancel(animation);
resetAnimationState();
}
});
return animatorTimer;
}
private void resetAnimationState() {
mCurrentState = mNextState;
if (mAnimatorTimerSet != null) {
if (mAnimatorTimerSet != mNextAnimatorTimerSet) {
mAnimatorTimerSet.cancel();
}
}
mAnimatorTimerSet = mNextAnimatorTimerSet;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
stopPlay();
}
public void stopPlay() {
isPlaying = false;
mCurrentAngle = LOADING_START_ANGLE;
try {
if (mAnimatorTimerSet != null) {
mAnimatorTimerSet.cancel();
}
if (mNextAnimatorTimerSet != null) {
mNextAnimatorTimerSet.cancel();
}
if (mShakeAnimatorTimer != null) {
mShakeAnimatorTimer.cancel();
}
} catch (Exception e) {
e.printStackTrace();
}
resetAnimationCircle();
postInvalidate();
}
private void resetAnimationCircle() {
for (AnimationCircle circle : mAnimationCircle) {
if (circle != null) {
circle.radius = mFixedRadius;
}
}
}
public static int argb(
@IntRange(from = 0, to = 255) int alpha,
@IntRange(from = 0, to = 255) int red,
@IntRange(from = 0, to = 255) int green,
@IntRange(from = 0, to = 255) int blue) {
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}
public boolean isPlaying() {
return isPlaying;
}
private void updateShakeRatio(final float ratio) {
long currentTimeMillis = System.currentTimeMillis();
if (currentTimeMillis - mStartShakeTime >= 150) {
mNextShakeRatio = ratio;
if (mShakeRatio != mNextShakeRatio) {
startShakeAnimation();
}
mStartShakeTime = currentTimeMillis;
}
}
private void startShakeAnimation() {
if (mShakeAnimatorTimer != null) {
mShakeAnimatorTimer.cancel();
}
mShakeAnimatorTimer = ValueAnimator.ofFloat(mShakeRatio, mNextShakeRatio);
mShakeAnimatorTimer.setDuration(100);
mShakeAnimatorTimer.setInterpolator(accelerateDecelerateInterpolator);
mShakeAnimatorTimer.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float ratio = (float) animation.getAnimatedValue();
if (mShakeRatio != ratio) {
mShakeRatio = ratio;
postInvalidate();
}
}
});
mShakeAnimatorTimer.start();
}
public void setMaxShakeRange(int maxShakeRange) {
this.mMaxShakeRange = maxShakeRange;
if (this.mMaxShakeRange <= 0) this.mMaxShakeRange = 100;
}
public void updateShakeValue(int volume) {
if (this.getVisibility() != View.VISIBLE || !isAttachedToWindow()) return;
if (!isPlaying) return;
float ratio = volume * 1.0f / this.mMaxShakeRange;
if (ratio < 1f / 4) {
ratio = 0;
}
if (ratio >= 1f / 4 && ratio < 2f / 4) {
ratio = 1f / 4;
}
if (ratio >= 2f / 4 && ratio < 3f / 4) {
ratio = 2f / 4;
}
if (ratio >= 3f / 4) {
ratio = 1f;
}
updateShakeRatio(ratio);
}
public boolean isAttachedToWindow() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return super.isAttachedToWindow();
} else {
return getWindowToken() != null;
}
}
private static class AnimationCircle {
private float radius;
private int color;
private int token;
AnimationCircle(int token, int radius, int color) {
this.radius = radius;
this.color = color;
this.token = token;
}
}
}
四、总结
总体上这个设计不是很难,难点是状态切换的一些过渡设计,保证上一个动画结束完成之后才能展示下一个动画,其词就是抖动逻辑,实际上也不是很复杂,第三方SDK的音量值一般都是有的,实时获取就好了。