概述
這次來講講心電圖的繪制女气,這也是項(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)縮放。下面我們來看看效果圖艾扮,圖片上傳大小有限制既琴,所以分兩張:
下面我們將功能拆解,分步實(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è)意思:
下面看一下實(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è)事件體系還蠻大。我們只講一下這次用到的部分燃箭。
好吧冲呢,還是直接畫表格吧。這樣也直觀一點(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吧 :心電圖