Android自定義View(12)- 畫一幅實(shí)時(shí)心電測(cè)量圖

概述

這次來講講心電圖的繪制女气,這也是項(xiàng)目當(dāng)中用到過的肮之。心電圖繼承自View,概括一下主要有以下內(nèi)容要實(shí)現(xiàn):實(shí)時(shí)顯示動(dòng)態(tài)心電測(cè)量數(shù)據(jù)衣摩、心電波形左右滑動(dòng)昂验、慣性滑動(dòng)及波形 X軸和 Y軸方向雙指滑動(dòng)縮放。下面我們來看看效果圖艾扮,圖片上傳大小有限制既琴,所以分兩張:

Screenrecorder-2021-08-09-18-44-54-1282021891847387.gif

ECG_2.gif

下面我們將功能拆解,分步實(shí)現(xiàn):

  • 畫背景綠色網(wǎng)格線
  • 繪制實(shí)時(shí)動(dòng)態(tài)心電曲線
  • 實(shí)現(xiàn)單指曲線左右平移
  • 實(shí)現(xiàn)曲線慣性滑動(dòng)
  • 實(shí)現(xiàn) X軸及 Y軸方向上曲線的雙指滑動(dòng)縮放(多點(diǎn)觸控改變曲線增益)
  • 左上角顯示當(dāng)前增益
1泡嘴、畫網(wǎng)格線

這個(gè)就比較簡(jiǎn)單了甫恩。首先確定每一小格的邊長(zhǎng),然后獲取控件寬高酌予。這樣就能分別計(jì)算出水平方向及豎直方向有多少小格磺箕,也就是可以確定橫線和豎線一共要畫多少條。然后就可以用循環(huán)畫出所有的線條抛虫,其中每隔5條進(jìn)行線條加粗松靡,而且畫實(shí)線,這樣就形成了實(shí)線大格建椰。下面先看實(shí)現(xiàn):

// 畫 Bitmap
    protected Bitmap gridBitmap;
 // 畫 Canvas
    protected Canvas bitmapCanvas;
// 控件寬高
    protected int viewWidth, viewHeight;
 @Override
    protected void onSizeChange() {
        // 獲取控件寬高
        viewWidth = mBaseChart.getWidth();
        viewHeight = mBaseChart.getHeight();
        // 初始化網(wǎng)格 Bitmap
        gridBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
        bitmapCanvas = new Canvas(gridBitmap);
        Log.d(TAG, "onSizeChange - " + "-- width = " +
                mBaseChart.getWidth() + "-- height = " + mBaseChart.getHeight());
    }

 /**
     * 準(zhǔn)備好畫網(wǎng)格的 Bitmap
     */
    private void initBitmap(){
        // 計(jì)算橫線和豎線條數(shù)
        hLineCount = (int) (viewHeight / gridSpace) + 2;
        vLineCount = (int) (viewWidth / gridSpace) + 2;
        // 畫橫線
        for (int h = 0; h < hLineCount; h ++){
            float startX = 0f;
            float startY = gridSpace * h;
            float stopX = viewWidth;
            float stopY = gridSpace * h;
            // 每個(gè) 5根畫一條粗實(shí)線
            if (h % 5 != 0){
                linePaint.setPathEffect(pathEffect);
                linePaint.setStrokeWidth(1.5f);
            }else {
                linePaint.setPathEffect(null);
                linePaint.setStrokeWidth(3f);
            }
            // 畫線
            bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
        }
        // 畫豎線
        for (int v = 0; v < vLineCount; v ++){
            float startX = gridSpace * v;
            float startY = 0f;
            float stopX = gridSpace * v;
            float stopY = viewHeight;
            // 每隔 5根畫一條粗實(shí)線
            if (v % 5 != 0){
                linePaint.setPathEffect(pathEffect);
                linePaint.setStrokeWidth(1.5f);
            }else {
                linePaint.setPathEffect(null);
                linePaint.setStrokeWidth(3f);
                Log.d(TAG, "v = " + v);
            }
            // 畫線
            bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
        }
    }

 @Override
    protected void onDraw(Canvas canvas) {
         // 注釋 1雕欺,Bitmap左邊緣位置為getScrollX(),防止網(wǎng)格滑動(dòng)
        canvas.drawBitmap(gridBitmap, mBaseChart.getScrollX(), 0, null);
    }

這里想提一下的是广凸,這里網(wǎng)格線并不是直接畫在控件 onDraw方法的 Canvas上的阅茶。而是在控件初始化時(shí),事先將網(wǎng)格所有線條畫在一張 Bitmap上谅海,然后繪制時(shí)直接繪制 Bitmap脸哀。這樣搞就不用每次繪制時(shí)都計(jì)算一遍線條的位置了。

還有就是上面注釋 1處扭吁,繪制網(wǎng)格 Bitmap的左邊緣的位置是 getScrollX()撞蜂。因?yàn)楹竺嬉獙?shí)現(xiàn)曲線左右滑動(dòng),但網(wǎng)格要固定不動(dòng)侥袜。

2蝌诡、繪制動(dòng)態(tài)實(shí)時(shí)心電曲線

這就是心電圖最主要的實(shí)現(xiàn)了。心電在測(cè)量的時(shí)候會(huì)實(shí)時(shí)傳遞電壓值枫吧,我們需要把電壓值實(shí)時(shí)存進(jìn)數(shù)組里浦旱。然后把電壓值換算成 Y坐標(biāo)值,再根據(jù)事先確定好的 X軸方向兩個(gè)數(shù)據(jù)點(diǎn)的距離來確定每個(gè)電壓值在 X軸方向的坐標(biāo)九杂。然后從左到右確定曲線的路徑Path颁湖,再將Path繪制到Canvas上就可以了宣蠕。

我們觀察上面效果圖會(huì)發(fā)現(xiàn),這里的實(shí)現(xiàn)是最后一個(gè)到達(dá)的數(shù)據(jù)的顯示不會(huì)超過控件右邊緣甥捺。也就是當(dāng)曲線 X方向的長(zhǎng)度不超過控件寬度時(shí)抢蚀,曲線第一個(gè)點(diǎn)的橫坐標(biāo) x = 0。當(dāng)曲線 X方向長(zhǎng)度大于控件寬度時(shí)镰禾,曲線 Path的第一個(gè)點(diǎn)的橫坐標(biāo)就向左移皿曲,也就是 x為負(fù)的了。這樣就實(shí)現(xiàn)上面效果中吴侦,測(cè)量實(shí)時(shí)心電時(shí)屋休,曲線會(huì)向左移。這樣新來的數(shù)據(jù)就顯示在控件可見范圍內(nèi)妈倔,早來的數(shù)據(jù)逐步向左移出控件可見范圍博投。下面畫個(gè)草圖吧,草圖大概就這么個(gè)意思:


心電.png

下面看一下實(shí)現(xiàn):

    /**
     * 創(chuàng)建曲線
     */
    private boolean createPath() {
        // 曲線長(zhǎng)度超過控件寬度盯蝴,曲線起點(diǎn)往左移
        // 根據(jù)控件寬度和數(shù)組長(zhǎng)度以及 X增益算出數(shù)組第一個(gè)數(shù)的 X坐標(biāo)
        float startX = (this.data.size() * dataSpaceX > viewWidth) ?
                (viewWidth - (this.data.size() * dataSpaceX)) : 0f;
        // 曲線復(fù)位
        dataPath.reset();
        for (int i = 0; i < this.data.size(); i++) {
            // 確定 X軸坐標(biāo)
            float x = startX + i * this.dataSpaceX;
            // 確定 Y軸坐標(biāo)
            float y = getVisibleY(this.data.get(i));
            // 繪制曲線
            if (i == 0) {
                dataPath.moveTo(x, y);
            } else {
                dataPath.lineTo(x, y);
            }
        }
        return true;
    }
    /**
     * 電壓 mv(毫伏)在 Y軸方向的換算
     * 屏幕向上往下是 Y 軸正方向毅哗,所以電壓值要乘以 -1進(jìn)行翻轉(zhuǎn)
     * 目前默認(rèn)每一大格代表 1000 mv,而真正一大格的寬度只有 150,所以 data要以兩數(shù)換算
     * Y == 0捧挺,是在 View的上邊緣虑绵,所以要向下偏移將波形顯示在中間
     *
     * @param data
     * @return
     */
   // 注釋 2
    private float getVisibleY(int data) {
        // 電壓值換算成 Y值
        float visibleY = -smallGridSpace * 5 / mvPerLargeGrid * data;
        // 向下偏移
        visibleY = visibleY + smallGridSpace * 5 * offset;
        return visibleY;
    }

 @Override
    protected void onDraw(Canvas canvas) {
        // 繪制心電曲線
        canvas.drawPath(dataPath, linePaint);
    }

上面有一點(diǎn)需要注意的,就是我們的 Y值的換算闽烙。我們知道Android屏幕自上而下是 Y軸正方向翅睛,所以我們?nèi)绻苯影央妷褐诞嬙谄聊簧纤堑箳斓摹A硗夂诰海@里默認(rèn)的一大格代表1000mv電壓值(可設(shè))捕发,而真正一大格的邊長(zhǎng)是150。所以我們需要將電壓值換算成屏幕像素很魂。具體看上面注釋 2的getVisibleY方法上面注釋扎酷。

3、實(shí)現(xiàn)曲線左右平移

當(dāng)心電測(cè)量完之后遏匆,我們需要實(shí)現(xiàn)曲線隨手指滑動(dòng)平移法挨。這樣才能看到心電圖的全部?jī)?nèi)容。這個(gè)實(shí)現(xiàn)原理也簡(jiǎn)單幅聘,也就是監(jiān)聽onTouch事件凡纳,根據(jù)手指位移使用View的scrollBy方法來實(shí)現(xiàn)內(nèi)容平移就可以了:

 /**
     * @param event 單指事件
     */
    private void singlePoint(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = event.getX();
                break;
            case MotionEvent.ACTION_MOVE:
                float deltaX = event.getX() - lastX;
                delWithActionMove(deltaX);
                lastX = event.getX();
                break;
            case MotionEvent.ACTION_UP:
                // 計(jì)算滑動(dòng)速度
                computeVelocity();
                break;
        }
    }

 /**
     * @param deltaX 處理 MOVE事件
     */
    private void delWithActionMove(float deltaX) {
        if (this.data.size() * dataSpaceX <= viewWidth) return;
        int leftBorder = getLeftBorder(); // 左邊界
        int rightBorder = getRightBorder(); // 右邊界
        int scrollX = mBaseChart.getScrollX(); // X軸滑動(dòng)偏移量

        if ((scrollX <= leftBorder) && (deltaX > 0)) {
            mBaseChart.scrollTo((int) (viewWidth - this.data.size() * dataSpaceX), 0);
        } else if ((scrollX >= rightBorder) && (deltaX < 0)) {
            mBaseChart.scrollTo(0, 0);
        } else {
            // 內(nèi)容平移
            mBaseChart.scrollBy((int) -deltaX, 0);
        }
    }

注意上面左右邊界的設(shè)定,別讓曲線劃出屏幕了帝蒿。

4荐糜、慣性滑動(dòng)

慣性滑動(dòng)的實(shí)現(xiàn),這里使用的套路是 VelocityTracker。先追蹤手指滑動(dòng)速度狞尔,然后使用 Scroller并結(jié)合 View的 computeScroll()方法和 scrollTo方法丛版,實(shí)現(xiàn)手指離開屏幕后的慣性滑動(dòng)。這部分內(nèi)容在我上一篇文章畫一個(gè)FM調(diào)頻收音機(jī)刻度表
有講偏序,這里不再重復(fù)。

5胖替、實(shí)現(xiàn)雙指滑動(dòng)研儒,在橫縱坐標(biāo)方向縮放曲線

在實(shí)現(xiàn)雙指滑動(dòng)曲線縮放功能之前,我們先講講一小部分 MotionEvent的基礎(chǔ)知識(shí)独令。為什么說只講一小部分呢端朵?因?yàn)?MotionEvent這個(gè)事件體系還蠻大。我們只講一下這次用到的部分燃箭。


onTou.png
onTouch2.png

好吧冲呢,還是直接畫表格吧。這樣也直觀一點(diǎn)招狸,不用解釋那么多敬拓。上面紅色圈圈圈出來的幾個(gè)哥們是我們這次要用到的。

  • event.getActionMasked() :上面也有解釋裙戏,這個(gè)方法和 getAction()類似乘凸。只不過我們這次要處理多點(diǎn)觸控,所以一定要用 getActionMasked() 來獲取事件類型累榜。

  • event.getPointerCount() :上面也有解釋营勤,獲取屏幕上手指?jìng)€(gè)數(shù)。因?yàn)槲覀冞@次要處理雙指滑動(dòng)壹罚,所以要用 (getPointerCount() == 2)進(jìn)行判斷葛作。兩根手指以外的事件我們不做縮放處理。

  • ACTION_POINTER_DOWN :上面又有解釋猖凛,第一根手指之后赂蠢,按下的其他手指。如果結(jié)合 (getPointerCount() == 2)這個(gè)前提條件形病,那么我們可以認(rèn)為這次ACTION_POINTER_DOWN 就是第二根手指按下所觸發(fā)的事件客年。

  • event.getX(int pointerIndex):上面也有介紹,獲取某個(gè)手指當(dāng)前的 X坐標(biāo)漠吻。我們?cè)讷@取到兩個(gè)手指當(dāng)前的 X坐標(biāo)之后量瓜,就可以算出兩指當(dāng)前在 X軸方向的距離。然后再結(jié)合 ACTION_POINTER_DOWN 時(shí)所記錄的坐標(biāo)值途乃,就可以計(jì)算出兩個(gè)手指在 X方向上是靠近了還是疏遠(yuǎn)了(收縮了還是放大了)绍傲。getY(int pointerIndex) 方法同理,不做解釋了。

  • ACTION_MOVE :兩指滑動(dòng)當(dāng)然也要用到 MOVE事件烫饼,只不過這里 ACTION_MOVE 和單指的使用方法一樣猎塞,就不做解釋了。

好了杠纵,我們?cè)倏纯?X軸方向縮放具體實(shí)現(xiàn)吧:

  /**
     * 處理onTouch事件
     *
     * @param event 事件
     * @return 攔截
     */
    @Override
    protected boolean onTouchEvent(MotionEvent event) {
        Log.d(TAG, "pointerCount = " + event.getPointerCount());
        if (event.getPointerCount() == 1) {  // 單指平滑
            singlePoint(event);
        }
        if (event.getPointerCount() == 2) { // 雙指縮放
            doublePoint(event);
        }
        return true;
    }

  /**
     * @param event 雙指事件
     */
    private void doublePoint(MotionEvent event) {
        if (pointOne == null) pointOne = new PointF();
        if (pointTwo == null) pointTwo = new PointF();

        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_POINTER_DOWN:  // 第二根手指按下
                Log.d(TAG, "ACTION_POINTER_DOWN");
               // 記錄第二根手指按下時(shí)荠耽,兩指的坐標(biāo)點(diǎn)
                saveLastPoint(event);
                numbersPerLargeGridOnThisTime = getDataNumbersPerGrid();
                mvPerLargeGridOnThisTime = getMvPerLargeGrid();
                break;
            case MotionEvent.ACTION_MOVE:  // 雙指拉伸
                Log.d(TAG, "ACTION_MOVE");
                // 計(jì)算 X方向縮放量
                getScaleX(event);
               // 計(jì)算 Y軸方向所放量
                getScaleY(event);
                break;
            case MotionEvent.ACTION_POINTER_UP:  // 先離開的手指
                Log.d(TAG, "ACTION_POINTER_UP");
                break;
        }
    }

    /**
     * 處理 X方向的縮放
     *
     * @param event 事件
     * @return 拉伸量
     */
    private float getScaleX(MotionEvent event) {
        float pointOneX = event.getX(0);
        float pointTwoX = event.getX(1);
        // 算出 X軸方向的拉伸量
        float deltaScaleX = Math.abs(pointOneX - pointTwoX) - Math.abs(pointOne.x - pointTwo.x);
        // 設(shè)置拉伸敏感度
        int inDevi = mBaseChart.getWidth() / 54;
        // 計(jì)算拉伸時(shí)增益偏移量
        int inDe = (int) deltaScaleX / inDevi;
        // 算出最終增益
        int perNumber = numbersPerLargeGridOnThisTime - inDe;
        // 設(shè)置增益
        setDataNumbersPerGrid(perNumber);
        return deltaScaleX;
    }

好了,該解釋的原理上面都做了解釋比藻。上面代碼要解釋的無非就是縮放敏感度調(diào)節(jié)的問題铝量,代碼里做了解釋∫祝縮放量計(jì)算出來之后慢叨,我們就可以改變心電曲線的增益了。比如說 X方向兩點(diǎn)數(shù)據(jù)之間的距離做了調(diào)整务蝠、Y方向心電數(shù)值計(jì)算因子做了調(diào)整拍谐,然后重新算出曲線 Path再重繪,也就可以了馏段。

6轩拨、左上角顯示當(dāng)前增益

最后我們要把當(dāng)前增益顯示出來,比如說 X軸方向一大格繪制了多少點(diǎn)數(shù)據(jù)毅弧、Y軸方向一大格代表多少毫伏气嫁。這兩個(gè)參數(shù)都是在上一步雙指縮放時(shí)動(dòng)態(tài)改變的,所以要留一個(gè)對(duì)外接口讓外界獲取到這兩個(gè)參數(shù)够坐。

 /**
     * 獲取每大格顯示的數(shù)據(jù)個(gè)數(shù)寸宵,再結(jié)合醫(yī)療版的采樣率,就可以算出一格顯示了多長(zhǎng)時(shí)間的數(shù)據(jù)
     *
     * @return
     */
    public int getDataNumbersPerGrid() {
        return this.dataNumbersPerGrid;
    }
 /**
     * @return 獲取每大格代表多少毫伏
     */
    public float getMvPerLargeGrid() {
        return this.mvPerLargeGrid;
    }

因?yàn)檫@次心電圖的繪制比以往的文章都涉及到更多的細(xì)節(jié)元咙,所以之前文章里講過的一些實(shí)現(xiàn)細(xì)節(jié)這里就沒重復(fù)講梯影。另外,這次自定義 View使用了 Base模板設(shè)計(jì)模式庶香,用好幾個(gè)類來實(shí)現(xiàn)了這幅心電圖甲棍,所以沒把完整代碼貼在這里。代碼還是直接放Github吧 :心電圖

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末赶掖,一起剝皮案震驚了整個(gè)濱河市感猛,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌奢赂,老刑警劉巖陪白,帶你破解...
    沈念sama閱讀 216,496評(píng)論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異膳灶,居然都是意外死亡咱士,警方通過查閱死者的電腦和手機(jī)立由,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來序厉,“玉大人锐膜,你說我怎么就攤上這事〕诜浚” “怎么了道盏?”我有些...
    開封第一講書人閱讀 162,632評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)庭再。 經(jīng)常有香客問我捞奕,道長(zhǎng),這世上最難降的妖魔是什么拄轻? 我笑而不...
    開封第一講書人閱讀 58,180評(píng)論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮伟葫,結(jié)果婚禮上恨搓,老公的妹妹穿的比我還像新娘。我一直安慰自己筏养,他們只是感情好斧抱,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,198評(píng)論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著渐溶,像睡著了一般辉浦。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上茎辐,一...
    開封第一講書人閱讀 51,165評(píng)論 1 299
  • 那天宪郊,我揣著相機(jī)與錄音,去河邊找鬼拖陆。 笑死弛槐,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的依啰。 我是一名探鬼主播乎串,決...
    沈念sama閱讀 40,052評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼速警!你這毒婦竟也來了叹誉?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,910評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤闷旧,失蹤者是張志新(化名)和其女友劉穎长豁,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體鸠匀,經(jīng)...
    沈念sama閱讀 45,324評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡蕉斜,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,542評(píng)論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片宅此。...
    茶點(diǎn)故事閱讀 39,711評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡机错,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出父腕,到底是詐尸還是另有隱情弱匪,我是刑警寧澤,帶...
    沈念sama閱讀 35,424評(píng)論 5 343
  • 正文 年R本政府宣布璧亮,位于F島的核電站萧诫,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏枝嘶。R本人自食惡果不足惜帘饶,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,017評(píng)論 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望群扶。 院中可真熱鬧及刻,春花似錦、人聲如沸竞阐。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,668評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽骆莹。三九已至颗搂,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間幕垦,已是汗流浹背丢氢。 一陣腳步聲響...
    開封第一講書人閱讀 32,823評(píng)論 1 269
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留智嚷,地道東北人卖丸。 一個(gè)月前我還...
    沈念sama閱讀 47,722評(píng)論 2 368
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像盏道,于是被迫代替她去往敵國(guó)和親稍浆。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,611評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容