安卓自定义频谱图控件

引言

之前我写过一篇文章,讲的是安卓自定义电平流控件的实现,在这片文章中我要讲的是频谱图的实现。相信我们大多数人都接触过或者是知道频谱吧,频谱图就是显示无线电信号在一定带宽范围内,信号强弱的变化,一目了然的可以看到信号有无,或者信号的变化等特征。这里我也就不过多的阐述,接下来主要讲解如何实现安卓客户端上的频谱图控件。

首先看下效果图:

实现了,最大值、最小值、实时值的绘制,同时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()中读取设置的值:

 <declare-styleable name="SpectrumView">
        <!--单位字体大小-->
        <attr name="unit_font_size" format="integer" />
        <!--单位-->
        <attr name="unit_sv" format="string" />
        <!--单位颜色-->
        <attr name="unit_color_sv" format="color" />
        <!--格子颜色-->
        <attr name="grid_color_sv" format="color" />
        <!--格子数,几等分-->
        <attr name="grid_count_sv" format="integer" />

        ...

接下来就是绘制,首先需要重载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);

好了,控件的大致实现过程就是如上所述,程序也并不复杂,只要细心点,把各种情况的考虑下,就没啥问题。

最后,如果要集成使用控件,可能还需添加其他功能或方法,才能完善系统,可以定制开发,如果有需要可以联系本人。

/**
 * @Title: SpectrumView.java
 * @Package: com.an.view
 * @Description: 自定义频谱图控件
 * @Author: AnuoF
 * @QQ/WeChat: 188512936
 * @Date 2019.08.09 20:27
 * @Version V1.0
 */

奉上源码,自由、开源:https://github.com/AnuoF/android_customview

AnuoF
Chengdu
Aug 20,2019

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,463评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,868评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,213评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,666评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,759评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,725评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,716评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,484评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,928评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,233评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,393评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,073评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,718评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,308评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,538评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,338评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,260评论 2 352

推荐阅读更多精彩内容

  • 引言 在无线电监测方面,需要对信号进行展示,其中一项数据就是设备返回的电平数据,需要对其实时展示,一图胜千言,最好...
    AnuoF阅读 453评论 0 4
  • 引言 之前写过电平图和频谱图的实现的文章,在这片文章中,我将要讲解频谱瀑布图的实现。 频谱瀑布图又叫谱阵图,它是将...
    AnuoF阅读 1,887评论 0 1
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,389评论 0 17
  • --绘图与滤镜全面解析 概述 在iOS中可以很容易的开发出绚丽的界面效果,一方面得益于成功系统的设计,另一方面得益...
    韩七夏阅读 2,720评论 2 10
  • 坦白讲最近被二宝折腾的够呛…… 两岁多了白天尿布都不用可以自己穿脱裤子上厕所的娃了,还不时要来一顿夜奶, 夜奶就夜...
    Rafen阅读 607评论 0 1