1. 效果图
2. 完整代码
/**
* VisualizerView
* Created by QiuLong on 2021/4/2.
*/
public class VisualizerView extends View implements ValueAnimator.AnimatorUpdateListener {
private final static int DEFAULT_COLOR = 0xFF0C0D13;//默认颜色
private final static int[] GRADIENT_COLORS = new int[]{0xFF30C7FF, 0xFF2DFFAD, 0xFFF2FF21, 0xFFE89719, 0xFFFF2727};// 渐变颜色
private LinearGradient mLinearGradient;
private final static int ROWS_NUMBER = 11;// 条形的数量
private final static int WIDTH_SCALE = 6;// 宽度的比例
private final Paint mPaint;
private final Path mPath;
private final RectF mRectF;
private int mRealWidth, mRealHeight;
private float mRectHeight;// 单行的高度
private float mRectMinWidth;// 最短行的宽度
private final ValueAnimator mValueAnimator;
private float mSampleValue;//采样值0~1
private boolean isPlaying = true;
public VisualizerView(Context context) {
this(context, null);
}
public VisualizerView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public VisualizerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setColor(Color.BLUE);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setShader(mLinearGradient);
mPath = new Path();
mRectF = new RectF();
mValueAnimator = new ValueAnimator();
mValueAnimator.setFloatValues();
mValueAnimator.setDuration(200);
mValueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
mValueAnimator.addUpdateListener(this);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (w == 0 || h == 0) {
return;
}
// 去掉padding后的实际宽高
mRealWidth = w - getPaddingLeft() - getPaddingRight();
mRealHeight = h - getPaddingTop() - getPaddingBottom();
// 计算每一行的高度、最短一行的宽度、以及行的增长比例
mRectHeight = (h - getPaddingTop() - getPaddingBottom()) / (float) getRowsNumberSum();
mRectMinWidth = mRealWidth / 2.6F;
mLinearGradient = new LinearGradient(0, mRealHeight, 0, 0, GRADIENT_COLORS, null, Shader.TileMode.CLAMP);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//1.绘制默认背景
mPaint.setColor(DEFAULT_COLOR);
mPaint.setShader(null);
drawSpectrum(canvas, 0, ROWS_NUMBER, 1.0f);
if (isPlaying) {
//2.绘制跳动的彩色频谱
float sampleValue = Math.max(0, Math.min(1.0f, mSampleValue));
int pos = (int) (sampleValue * ROWS_NUMBER);
mPaint.setShader(mLinearGradient);
//2.1 绘制完整方块的频谱图
drawSpectrum(canvas, 0, pos - 1, 1.0f);
//2.1 绘制不完整方块的频谱图
float factor = sampleValue * ROWS_NUMBER - pos;
if (factor > 0) {
drawSpectrum(canvas, pos, pos, factor);
}
}
}
private void drawSpectrum(Canvas canvas, int startPos, int endPos, float factor) {
for (int r = ROWS_NUMBER - 1 - startPos; r >= ROWS_NUMBER - 1 - endPos; r--) {
drawRect(canvas, r, factor);
}
}
private void drawRect(Canvas canvas, int position, float factor) {
float radius = mRectHeight / 2f;// 圆弧半径
float maxRectWidth = mRealWidth - radius;// 最大矩形宽度
float minRectWidth = mRectMinWidth - radius;// 最小矩形宽度
// 计算动态改变的柱子实际宽高
float width;
int centerPosition = (ROWS_NUMBER - 1) / 2;// 中心position
float hAverageValue = (maxRectWidth - minRectWidth) / centerPosition;// 柱子的横向增长比例
if (position < centerPosition) {
width = minRectWidth + hAverageValue * position;
} else if (position > centerPosition) {
width = maxRectWidth - hAverageValue * (position - centerPosition);
} else {
width = maxRectWidth;
}
float height = mRectHeight * factor;
// 计算矩形的绘制点
float bottom = mRealHeight - (ROWS_NUMBER - position - 1) * (mRectHeight * 2);
float top = bottom - mRectHeight;
// 开始计算path
mPath.reset();
mRectF.set(width - radius, top, width + radius, bottom);
if (height > mRectHeight / 2f) {
int angle = (int) (Math.asin(height / radius - 1) / Math.PI * 180);
mPath.arcTo(mRectF, 360 - angle, angle);
mPath.arcTo(mRectF, 0, 90);
} else {
int angle = (int) (Math.asin(1 - height / radius) / Math.PI * 180);
mPath.arcTo(mRectF, angle, 90 - angle);
}
mPath.lineTo(getPaddingLeft(), bottom);
mPath.lineTo(getPaddingLeft(), bottom - height);
mPath.close();
canvas.drawPath(mPath, mPaint);
}
private int getRowsNumberSum() {
return ROWS_NUMBER * 2 - 1;
}
public boolean isPlaying() {
return isPlaying;
}
public void setPlaying(boolean playing) {
mSampleValue = 0;
isPlaying = playing;
cancelAnimator();
postInvalidate();
}
public void onCaptureChanged(float[] fft) {
if (isPlaying) {
cancelAnimator();
// fft就是音频的数据源,newValue目前只是一个随机数
float newValue = new Random().nextInt(100) / 100.f;
mValueAnimator.setFloatValues(mSampleValue, newValue);
mSampleValue = newValue;
mValueAnimator.start();
}
}
private void cancelAnimator() {
if (mValueAnimator.isRunning()) {
mValueAnimator.cancel();
}
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mSampleValue = (float) animation.getAnimatedValue();
postInvalidate();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 根据高度按比例测量控件宽度
setMeasuredDimension(getDefaultSize(0, widthMeasureSpec), getDefaultSize(0, heightMeasureSpec));
int measureWidth = getMeasuredHeight() / getRowsNumberSum() * WIDTH_SCALE;
super.onMeasure(View.MeasureSpec.makeMeasureSpec(measureWidth, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(getMeasuredHeight(), View.MeasureSpec.EXACTLY));
}
}
3. xml布局文件
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF22272A"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2">
<com.qiulong.testdemo.view.VisualizerView
android:id="@+id/left_visualizer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="left" />
<com.qiulong.testdemo.view.VisualizerView
android:id="@+id/right_visualizer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="right"
android:rotationY="-180" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="播放" />
</FrameLayout>
</LinearLayout>
4. Activity使用
这里用采用Handler来模拟音频的数据源,实际在监听回调函数中调用onCaptureChanged方法即可
public class MainActivity extends AppCompatActivity {
private VisualizerView visualizerLeft, visualizerRight;
private final Handler handler = new Handler(Looper.myLooper());
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
visualizerLeft = findViewById(R.id.left_visualizer);
visualizerRight = findViewById(R.id.right_visualizer);
handler.postDelayed(new Runnable() {
@Override
public void run() {
visualizerLeft.onCaptureChanged(null);
visualizerRight.onCaptureChanged(null);
handler.postDelayed(this, 100);
}
}, 500);
final Button button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (visualizerLeft.isPlaying() && visualizerRight.isPlaying()) {
visualizerLeft.setPlaying(false);
visualizerRight.setPlaying(false);
button.setText("播放");
} else {
visualizerLeft.setPlaying(true);
visualizerRight.setPlaying(true);
button.setText("暂停");
}
}
});
}
}