????????上一章我們講解了如何繪制順滑、優美的曲線,為本項目的繪圖功能打下了基礎。本章我們將深入探討兩個關鍵功能的實現:歷史點和數學方式推導點。這些功能將大幅提升我們白板應用的專業性和用戶體驗。
一、History點
之前在onTouchEvent中獲取的MotionEvent,其實不是一個點的信息,而是一個觸摸事件的封裝。
(1)基本概念
????????在 Android 中,當用戶觸摸屏幕時,系統會生成一系列 MotionEvent 對象。為了提高效率,系統不會為每一個微小的移動都生成一個新事件,而是會將多個觸摸點"打包"在一個 MotionEvent 中。
(2)代碼示例
@Override
public boolean onTouchEvent(MotionEvent event) {final int action = event.getAction();//返回當前 MotionEvent 中包含的歷史觸摸點數量final int historySize = event.getHistorySize();for (int h = 0; h < historySize; h++) {float historicalX = event.getHistoricalX(h);float historicalY = event.getHistoricalY(h);long historicalTime = event.getHistoricalEventTime(h);// 處理歷史點processPoint(historicalX, historicalY, historicalTime);}// 處理當前點float currentX = event.getX();float currentY = event.getY();processPoint(currentX, currentY, event.getEventTime());return true;
}
(3)差異展示
1.手寫無history效果
點跟點之間的間隔很大,速度快了之后顯得越發不密集
2.手寫有history效果
其中黑色的為原始點,紅色的為history點。這個對比上面就密集了很多,可是黑點和紅點雖然軌跡一樣,但是兩者的分布間隔又長又短,甚至有的黑點和紅點重疊了。這個對嗎?我們再把紅的單獨點顯示下
3.手寫有history效果(無原始點)
這樣看著就順眼多了啊。
問題1:為什么將原始點和history點兩者疊加顯示會參差不齊。但是兩者分開又各自的軌跡是連貫平滑的。
解答1:history事件存在的本質是因為屏幕刷新率一般比觸摸屏刷新率要小,觸摸的move事件處理又是要跟隨?
VSYNC
?(由自身幀率決定)即刷新率一起的,所以導致在一個?VSYNC
?周期時間內,就會有多個觸摸事件產生,如果不使用history那么相當于繪制的軌跡采樣率就是屏幕刷新率。
觸摸屏采樣率(通常?100-1000Hz)
硬件以高頻率上報觸摸點坐標(如每?1-10ms?一個點)。屏幕刷新率(通常?60-120Hz,即每 8.3-16.6ms 一幀)
Android 的 UI 渲染和事件處理依賴?VSYNC
?信號,MotionEvent
?的分發會被對齊到最近的?VSYNC
。
問題2:歷史點的本質?
解答2:在兩次?
VSYNC
?之間(即一個屏幕刷新周期內),觸摸屏可能產生多個數據點,但系統只會合成一個?MotionEvent
?上報。所以history上面保存的是觸摸屏采樣率所采集的點。
以為這樣就結束了嗎?我們接下來看看筆觸的效果
4.筆寫有history效果
使用高采樣率的電子筆就可以達到近乎這種完美的效果
5.筆寫有history效果(原始點寬度縮小2倍)
當將原始點的寬度縮小后發現:原始點和歷史點有的會重合;有的只顯示原始點。從現象中表現:歷史點丟了。這個咱就不探究了,可能跟硬件、系統、底層代碼邏輯有關。
(4)小結
綜合上面的效果目前可以定下來方案
- 手寫:使用history點即可。
- 筆寫:使用原始點和history點相結合達到最好效果。(以具體情況為主)
如果感覺手寫點的數量跟筆寫點的數量差距很大,那有沒有辦法提升手寫點的數量???
有的、有的、兄弟包有的!!!接下來介紹通過數學方式新增點的方法
二、數學方式推導點
目標:用戶繪制過程中根據特定條件自動添加額外的控制點,從而改變原始的繪制路徑。
效果圖:
其中紅色的點是原始點,黑色的點是推導新增的點。
View代碼:
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.view.MotionEvent;
import android.view.View;@SuppressLint("ViewConstructor")
public class DrawView_EventPoints_New extends View {////這個記錄第一個點private float pre1X = -1f;private float pre1Y = -1f;//記錄第二個點private float pre2X = -1f;private float pre2Y = -1f;//用于保存新算的點private float newX = -1f;private float newY = -1f;//垂足點和中點private float footX = -1f;private float footY = -1f;private float centerX = -1f;private float centerY = -1f;//用于保存兩點之間的距離private float distance = -1f;//獲取屏幕的寬度(在此只適用于橫屏)int viewWidth ;private Path path = new Path();Paint paint = new Paint(Paint.DITHER_FLAG);private Bitmap cacheBitmap;//定義cacheBitmap上的Canvas對象private Canvas cacheCanvas = new Canvas();private Paint bmpPaint = new Paint();DrawView_EventPoints_New(Context context , int width, int height){super(context);//創建一個與該View具有相同大小的圖片緩沖區cacheBitmap = Bitmap.createBitmap(width,height,Bitmap.Config.ARGB_8888);//設置cacheCanva將會繪制到內存中的cacheBitmap上cacheCanvas.setBitmap(cacheBitmap);//設置畫筆的顏色paint.setColor(Color.RED);//設置畫筆的風格paint.setStyle(Paint.Style.STROKE);paint.setStrokeJoin(Paint.Join.ROUND);paint.setStrokeCap(Paint.Cap.ROUND);paint.setStrokeWidth(12);//反鋸齒paint.setAntiAlias(true);paint.setDither(true);viewWidth = width;}@SuppressLint("ClickableViewAccessibility")@Overridepublic boolean onTouchEvent(MotionEvent event) {//獲取拖動事件發生的位置float x = event.getX();float y = event.getY();//初步的思路://1.判斷兩點之間的距離是否大于 屏幕(寬或高)的一個百分比值// 感覺只需要一層判斷就夠了,手寫的速度沒有沒有鼠標那么快,每個點的距離沒有那么開//2.需要三個點才能開始添加點,同時需要三個緩存(兩個緩存)// 開啟條件:當前兩個點滿足大于的條件,等待第三個點的到來(第三個比較重要,每次使用完都賦值成-1)+ 三點之間形成的角要大于90度// 第三個點假如超出了屏幕范圍之外,就丟棄。// 當第三個點兩種情況:(1)取到了:按照公式計算// (2)沒取到:收尾的兩個點大于條件//3.開始畫點:重要的是算法//算法思路:首先根據開始的兩個點,確定添加的點在那個垂直線上//一點和三點//缺點:顯示的點,在距離過大時會稍晚兩個點的顯示(只要跟手的速度給力,應該也不影響)switch (event.getAction()){case MotionEvent.ACTION_DOWN://第一個點path.moveTo(x,y);//DOWN的時候保存第一個點if(pre1X == -1f && pre1Y == -1f){pre1X = x;pre1Y = y;}//System.out.println("DOWN1 "+pre1X+" "+pre1Y);cacheCanvas.drawPoint(pre1X,pre1Y,paint);break;case MotionEvent.ACTION_MOVE:// 從前一個點繪制到當前點之后,把當前點定義成下次繪制的前一個點//MOVE的時候定義第二個點,并更新第一個點//這個是保存move的第一個點:這個時間段根本沒辦法獲取newX,newY。if(pre2X == -1f && pre2Y == -1f){pre2X = x;pre2Y = y;break;}//判斷兩個點是否過長distance = CalculatePointsDistance(pre1X,pre1Y,pre2X,pre2Y);if(distance >= (float)(viewWidth/22)){//System.out.println("AAAA distance:"+distance+" viewWidth/10: "+viewWidth/22);//判斷是銳角還是鈍角xif(isBluntAngle(pre1X,pre1Y,pre2X,pre2Y,x,y)){//根據三點計算新點getNewPoints(pre1X,pre1Y,pre2X,pre2Y,x,y);path.lineTo(newX,newY);paint.setColor(Color.BLACK);cacheCanvas.drawPoint(newX,newY,paint);}}path.lineTo(pre2X,pre2Y);paint.setColor(Color.RED);cacheCanvas.drawPoint(pre2X,pre2Y,paint);//第二個點和第是三個點前移pre1X = pre2X;pre1Y = pre2Y;pre2X = x;pre2Y = y;//這個時候新點是必定更新的(清空)newX = -1f; newY = -1f;break;case MotionEvent.ACTION_UP://因為最后一個一直沒有畫出來,所以在up的時候顯示。path.moveTo(x,y);cacheCanvas.drawPoint(x,y,paint);//不僅如此,還可以對尾部進行一個修飾//cacheCanvas.drawPath(path,paint);path.reset();//全部恢復初始狀態pre1X = -1f; pre1Y = -1f;pre2X = -1f; pre2Y = -1f;newX = -1f; newY = -1f;break;}invalidate();return true;}//得到新點private void getNewPoints(float p1X, float p1Y, float p2X, float p2Y, float X, float Y) {//求前兩個點的斜率,得到垂直平分線的斜率//根據中點,求得新點的值//---------------方法二------------------//獲取高的坐標float dx = p1X - X;float dy = p1Y - Y;float u =(p2X-p1X)*dx +(p2Y-p1Y)*dy;u/=dx*dx+dy*dy;footX = p1X+u*dx;footY = p1Y+u*dy;//根據p1點求中footX = (p1X+footX)/2f;footY = (p1Y+footY)/2f;//高的坐標與第一個點的中點+開始的中點 反推的一個點就是目標點centerX = (p1X+p2X)/2f;centerY = (p1Y+p2Y)/2f;newX = centerX*2f-footX;newY = centerY*2f-footY;newX = (centerX+newX)/2f;newY = (centerY+newY)/2f;}//判斷是否為鈍角,假如為鈍角則開辟新點private boolean isBluntAngle(float p1X, float p1Y, float p2X, float p2Y, float X, float Y) {//轉換為求兩個向量的夾角float x12 = p1X - p2X;float y12 = p1Y - p2Y;float x23 = X - p2X;float y23 = Y - p2Y;float mul_12_23 = x12*x23 + y12*y23;float dist_12 = (float) Math.sqrt(x12*x12+y12*y12);float dist_23 = (float) Math.sqrt(x23*x23+y23*y23);float cosValue = mul_12_23/(dist_12*dist_23);float angle = (float)((float) 180*Math.acos(cosValue)/Math.PI);//輸出一下角度//System.out.println("AAAAA 角度:"+angle);//當角度為180度,也可以不用畫了。同時可以確定可以組成一個三角形return angle >= 90f&& angle !=180f;}//計算兩點之間的距離private float CalculatePointsDistance(float p1X, float p1Y, float p2X, float p2Y) {return (float) Math.sqrt(Math.abs((p1X-p2X)*(p1X-p2X)+(p1Y-p2Y)*(p1Y-p2Y)));}@Overrideprotected void onDraw(Canvas canvas) {//將cacheBitmap繪制到View上(傳入這個bmpPaint一點用都沒有)canvas.drawBitmap(cacheBitmap,0f,0f,null);}
}
原理:
只看上面的代碼不一定能理解思路,我簡單說說我當時的設計思路。
方法1:
????????通過三個已知點,在其之間添加使其順滑的新增點,我首先想到的是拋物線。我在拋物線上隨便取哪個點,都具有使整體飽滿圓潤的效果。
我們可以在 P1 和 P2?之間取,也可以在 P2 和 (X,Y)? 之間取,如果嘗試取他們中點數據,效果應該是最好的,有了思路后接著看下面設計圖。
在大多數情況下,三個確定位置的坐標點可以確定兩條拋物線(垂直拋物線和水平拋物線)。只有在點的排列限制了某一種形式的拋物線時,才可能只能確定一條。其實確定垂直拋物線就行。
這個方式太復雜了,要用代碼操作計算量太大了。我直接pass掉了,不過按照道理來說是可行的,大家有意愿的話可以自己試試。
方法2:
這個方法就是代碼中使用的,依舊使用圖例講解:
- 其中 foot點 為 P2 到線段【P1,(X,Y)】的垂足。對應的代碼為:
float dx = p1X - X; float dy = p1Y - Y;float u =(p2X-p1X)*dx +(p2Y-p1Y)*dy; u/=dx*dx+dy*dy; footX = p1X+u*dx; footY = p1Y+u*dy;
- 下面代碼也好理解,取 foot點 和 P1點 賦值給 foot ;去 P1 和 P2 的中點center。
//根據p1點求中 footX = (p1X+footX)/2f; footY = (p1Y+footY)/2f;centerX = (p1X+p2X)/2f; centerY = (p1Y+p2Y)/2f;
- 之后的代碼需要圖解,上圖片。模擬一個坐標系就很明朗了。
newX = centerX*2f-footX; newY = centerY*2f-footY;newX = (centerX+newX)/2f; newY = (centerY+newY)/2f;
- 最終的結果其實只是為了讓模擬點突出來一下,沒想到實際效果還不錯!!!
流程詳解:
- 在onTouchEvent中獲取基礎點,當達到3個時開始計算目標點。
- 當距離大于固定長度時,開始計算。
- 當三個點組成的角為鈍角時才開始計算,因為當為銳角的時,此時速度不快,沒必要去計算新增點。
- 使用方法二,根據第三個點來預測出前兩個點的新增點。當然你也可以根據這個方法來計算后兩個點的新增點。