引言
之前我写过一篇文章,讲的是安卓自定义电平流控件的实现,在这片文章中我要讲的是频谱图的实现。相信我们大多数人都接触过或者是知道频谱吧,频谱图就是显示无线电信号在一定带宽范围内,信号强弱的变化,一目了然的可以看到信号有无,或者信号的变化等特征。这里我也就不过多的阐述,接下来主要讲解如何实现安卓客户端上的频谱图控件。
首先看下效果图:
实现了,最大值、最小值、实时值的绘制,同时Y轴拖动,以及框选显示(X轴缩放)等。
实现
布局
要画一个图形控件,首先是布局,要画哪些元素,元素的位置布局,元素的颜色、字体等等,这些东西捋清楚之后,就可以动手画了。您在网上随便一搜频谱图,应该就可以看到大致长啥样了,基本都差不多,我的频谱图布局如下:
接下来就应该是编码实现了。
编码
首先新建一个类SpectrumView继承View,实现必要的构造函数:
public class SpectrumView extends View implements View.OnTouchListener {
public SpectrumView(Context context, AttributeSet attrs, int defStypeAttr) {
super(context, attrs, defStypeAttr);
initView(context, attrs);
}
public SpectrumView(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context, attrs);
}
public SpectrumView(Context context) {
super(context);
initView();
}
}
在attr.xml中定义控件的属性,便于调用这可以定制某些属性,比如颜色、字体大小等,并在初始化initView()中读取设置的值:
...
接下来就是绘制,首先需要重载onMeasure()和onDraw()方法,在onMeasure()中确定View的高和宽:
_width = getMeasuredWidth();
_height = getMeasuredHeight();
在onDraw()中就是具体的绘制了,首先是drawUnit()绘制左中的单位“电平(dBuV)”,drawAxis()绘制坐标轴和网格,这两个个的实现基本和电平流控件的那里完全一样,就不再阐述。至此,基本的背景绘制已完成,效果图如下:
当setData()来数据之后,就开始绘制频谱了drawSpectrum():
/**
* 画频谱
*
* @param canvas
*/
private void drawSpectrum(Canvas canvas) {
if (_data.length == 0 || _startIndex >= _endIndex)
return; // 没有数据时不需要绘制
_paint.setColor(_realTimeLineColor);
_paint.setStyle(Paint.Style.STROKE);
int maxValue = _maxValue + _offsetY - _zoomOffsetY;
int minValue = _minValue + _offsetY + _zoomOffsetY;
int scaleHeight = _height - _marginTop - _marginBottom; // 绘制区总高度
int scaleWidth = _width - _marginLeft - _marginRight - _scaleLineLength; // 绘制区总宽度
float perHeight = scaleHeight / (float) Math.abs(maxValue - minValue); // 每一格的高度
float perWidth = scaleWidth / (float) (_endIndex - _startIndex);
Path realTimePath = new Path();
Path maxValuePath = null;
Path minValuePath = null;
for (int i = _startIndex; i <= _endIndex; i++) { // 此处需要加上=,确保最后一个点可以绘制
if (i >= _data.length) // 防止越界
continue;
float level = _data[i];
int x = (int) ((i - _startIndex) * perWidth) + _marginLeft + _scaleLineLength;
int y = (int) ((maxValue - level) * perHeight) + _marginTop;
if (i == _startIndex) {
realTimePath.moveTo(x, y);
} else {
realTimePath.lineTo(x, y);
}
if (_drawMaxValue) {
if (maxValuePath == null) {
maxValuePath = new Path();
}
float maxLevel = _maxData[i];
int max_x = (int) ((i - _startIndex) * perWidth) + _marginLeft + _scaleLineLength;
int max_y = (int) ((maxValue - maxLevel) * perHeight) + _marginTop;
if (i == _startIndex) {
maxValuePath.moveTo(max_x, max_y);
} else {
maxValuePath.lineTo(max_x, max_y);
}
}
if (_drawMinValue) {
if (minValuePath == null) {
minValuePath = new Path();
}
float minLevel = _minData[i];
int min_x = (int) ((i - _startIndex) * perWidth) + _marginLeft + _scaleLineLength;
int min_y = (int) ((maxValue - minLevel) * perHeight) + _marginTop;
if (i == _startIndex) {
minValuePath.moveTo(min_x, min_y);
} else {
minValuePath.lineTo(min_x, min_y);
}
}
}
canvas.drawPath(realTimePath, _paint);
if (maxValuePath != null) {
_paint.setColor(_maxValueLineColor);
canvas.drawPath(maxValuePath, _paint); // 画最大值
}
if (minValuePath != null) {
_paint.setColor(_minValueLineColor);
canvas.drawPath(minValuePath, _paint); // 画最小值
}
// 覆盖上边和下边,使频谱看上去是在指定区域进行绘制的
_paint.setStyle(Paint.Style.FILL);
Drawable background = getBackground();
if (background instanceof ColorDrawable) {
ColorDrawable colorDrawable = (ColorDrawable) background;
int color = colorDrawable.getColor();
_paint.setColor(color);
canvas.drawRect(_marginLeft + _scaleLineLength, 0, _width - _marginRight, _marginTop, _paint);
canvas.drawRect(_marginLeft + _scaleLineLength, _height - _marginBottom + 1, _width - _marginRight, _height + 1, _paint);
}
// 计算并绘制中心频率和带宽
double perFreq = _spectrumSpan / _data.length / 1000;
double span = perFreq * (_endIndex - _startIndex) / 2 * 1000;
String centerFreqStr, startFreqStr, endFreqStr;
// 如果是全景,则显示中心频率和带宽,局部缩放则显示起始、终止频率和中心点频率
if (_startIndex == 0 && _endIndex == _data.length) {
centerFreqStr = String.format("%.3f", _frequency) + "MHz";
startFreqStr = "-" + String.format("%.3f", span) + "kHz";
endFreqStr = "+" + String.format("%.3f", span) + "kHz";
} else {
int centerIndex = (_startIndex + (_endIndex - _startIndex) / 2);
centerFreqStr = String.format("%.3f", centerIndex * perFreq + (_frequency - _spectrumSpan / 2 / 1000)) + " MHz";
startFreqStr = String.format("%.3f", _startIndex * perFreq + (_frequency - _spectrumSpan / 2 / 1000)) + " MHz";
endFreqStr = String.format("%.3f", _endIndex * perFreq + (_frequency - _spectrumSpan / 2 / 1000)) + " MHz";
}
Rect freqRect = new Rect();
_paint.setColor(_gridColor);
_paint.getTextBounds(centerFreqStr, 0, centerFreqStr.length(), freqRect);
canvas.drawText(centerFreqStr, _width - _marginRight - scaleWidth / 2 - freqRect.width() / 2, _height - _marginBottom + freqRect.height() + 5, _paint);
canvas.drawText(startFreqStr, _marginLeft + _scaleLineLength, _height - _marginBottom + freqRect.height() + 5, _paint);
canvas.drawText(endFreqStr, _width - _marginRight - (float) _paint.measureText(endFreqStr), _height - _marginBottom + freqRect.height() + 5, _paint);
}
这里的关键点在于计算出幅度与屏幕上所在的位置,并不复杂,具体可以参见代码,画了Path之后,在上边和下边各画一个矩形,覆盖在上面,这样当频谱的图形移动到上面去的时候并不会越过网格界限,看起来就是绘制在网格以内,实际上您要是不绘制矩形的话,可以试试,看下又会是什么效果。
画到这里,频谱图的静态展示效果就完成了,这还没完,还需要加上一些交互事件:图形上下拖动,局部缩放等。下面是具体实现步骤,先重载onTouch()方法:
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
actionDown(motionEvent);
break;
case MotionEvent.ACTION_POINTER_DOWN:
actionPointerDown(motionEvent);
break;
case MotionEvent.ACTION_MOVE:
actionMove(motionEvent);
break;
case MotionEvent.ACTION_POINTER_UP:
actionPointerUp(motionEvent);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
actionCancelUp(motionEvent);
break;
}
return true;
}
在ACTION_DOWN中,主要根据手指按下的位置,来确定当前操作是Y轴拖动还是X轴缩放:
private void actionDown(MotionEvent event) {
if (Utils.IsPointInRect(0, 0, _marginLeft + _scaleLineLength, _height, (int) event.getX(), (int) event.getY())) {
_handleType = HandleType.DRAG; // 纵轴拖动
_startY = event.getY();
} else if (Utils.IsPointInRect(_marginLeft + _scaleLineLength, _marginTop, _width - _marginRight, _height - _marginBottom, (int) event.getX(), (int) event.getY())) {
_handleType = HandleType.ZONE; // 缩放频谱
_startX = _endX = event.getX();
}
}
在ACTION_POINTER_DOWN中主要是记录两个手指按下时的初始距离:
private void actionPointerDown(MotionEvent event) {
if (event.getPointerCount() == 2) {
_handleType = HandleType.ZOOM;
_oldDistanceY = Math.abs(event.getY(0) - event.getY(1));
}
}
在ACTION_MOVE中主要是根据当前的操作类型,来确定_offsetY/_zoomOffsetY/_endX等字段的值,并实时刷新绘制图形:
private void actionMove(MotionEvent event) {
if (_handleType == HandleType.DRAG) {
float currrentY = event.getY();
int spanScale = (int) ((currrentY - _startY) / ((_height - _marginTop - _marginBottom) / Math.abs((_maxValue - _minValue))));
if (spanScale != 0) {
_offsetY = spanScale;
postInvalidate();
}
} else if (_handleType == HandleType.ZOOM && event.getPointerCount() == 2) {
float currentDistanceY = Math.abs(event.getY(0) - event.getY(1));
float perScaleHeight = (_height - _marginTop - _marginBottom) / (float) Math.abs(_maxValue - _minValue);
int spanScale = (int) ((currentDistanceY - _oldDistanceY) / perScaleHeight);
if (spanScale != 0 && ((_maxValue - spanScale) - (_minValue + spanScale) >= _gridCount)) { // 防止交叉越界,并且在放大到 总刻度长为 _gridCount 时,不能再放大
_zoomOffsetY = spanScale;
postInvalidate();
}
} else if (_handleType == HandleType.ZONE) {
_endX = event.getX();
if (_endX < _marginLeft + _scaleLineLength) {
_endX = _marginLeft + _scaleLineLength;
} else if (_endX > _width - _marginRight) {
_endX = _width - _marginRight;
} // 此处的判断是为了防止Rect越界
postInvalidate();
}
}
在ACTION_POINTER_UP和ACTION_CANCEL或ACTION_UP中,主要是固化字段的状态。
private void actionPointerUp(MotionEvent event) {
if (_handleType == HandleType.ZOOM) {
_maxValue -= _zoomOffsetY;
_minValue += _zoomOffsetY;
_zoomOffsetY = 0;
}
_handleType = HandleType.NONE;
}
private void actionCancelUp(MotionEvent event) {
if (_handleType == HandleType.DRAG) {
_maxValue += _offsetY;
_minValue += _offsetY;
_offsetY = 0;
} else if (_handleType == HandleType.ZONE) {
// 这里需要读取索引
if (_startX > _endX) {
// 缩小
_startIndex = 0;
_endIndex = _data.length;
postInvalidate();
} else if (_startX < _endX) {
// 放大。 根据 _startX 和 _endX 来确定 _startIndex 和 _endIndex,以及中心频率和带宽
if (_data.length == 0 || _endIndex - _startIndex <= 2) { // 没有数据,或者只要小于2个点时,不再放大
_handleType = HandleType.NONE;
return;
}
float perScaleLength = (_width - _marginLeft - _scaleLineLength - _marginRight) / (float) (_endIndex - _startIndex); // 一格的距离
// 在放大的基础上再次放大,巧妙啊,佩服我自己了,哈哈哈
int tempEndIndex = _startIndex + (int) ((_endX - _marginLeft - _scaleLineLength) / perScaleLength);
int tempStartIndex = _startIndex + (int) ((_startX - _marginLeft - _scaleLineLength) / perScaleLength);
if (tempEndIndex > tempStartIndex) { // 保证至少有2个点(一条直线)
_endIndex = tempEndIndex;
_startIndex = tempStartIndex;
postInvalidate();
}
}
}
_handleType = HandleType.NONE;
}
至此,频谱控件的交互事件也基本完成。最后,我们再对外提供一些方法,便于调用:
public void setData(double frequency, double spectrunSpan, float[] data);
public void offsetY(int offset);
public void zoomY(int zoom);
public void clear();
public void autoView();
public void setMaxValueLineVisible(boolean visible);
public void setMinValueLineVisible(boolean visible);
好了,控件的大致实现过程就是如上所述,程序也并不复杂,只要细心点,把各种情况的考虑下,就没啥问题。
最后,如果要集成使用控件,可能还需添加其他功能或方法,才能完善系统,可以定制开发,如果有需要可以联系本人。
最后1,我的联系方式,呃,,如果您真的需要的话,随便那种方式应该都可以找到我的,哈哈,提醒一下,在代码里面也有,例如:
/**
* @Title: SpectrumView.java
* @Package: com.an.view
* @Description: 自定义频谱图控件
* @Author: AnuoF
* @QQ/WeChat: 188512936
* @Date 2019.08.09 20:27
* @Version V1.0
*/
最后2:奉上源码,自由、开源:https://github.com/AnuoF/android_customview
最后,定稿!!!!
AnuoF
Chengdu
Aug 20,2019