安卓自定義View進階-特殊控件的事件處理
本文帶大家了解 Android 特殊形狀控件的事件處理方式,主要是利用了 Region 和 Matrix 的一些方法,超級實用的事件處理方案,相信看完本篇之后,任何奇葩控件的事件處理都會變得十分簡單。
不得不說,Android 對事件體系封裝的非常棒,即便對事件體系不太了解的人,只要簡單的調用方法就能使用,而且具有防呆設計,能夠保證事件流的完整性和統一性,最大可能性的避免了事件處理的混亂,著實令人佩服。
然而世界上并沒有絕對完美的東西, 當【事件處理】遇上【自定義View】,一場好戲就開演了,玩的好叫坐鎮軍前,指揮千軍萬馬而分毫不亂,玩的不好就是抓耳撓腮,眼見敵人前后包抄而無可奈何。
特殊形狀控件
在通常的情況下,自定義 View 直接使用系統的事件體系處理就行,我們也不需要特殊處理,然而當一些特殊的控件出現的時候,麻煩就來了,舉個栗子:
這是一個在遙控器上非常常見的按鍵布局,注意中間上下左右選擇的部分,看起來十分簡單,然而當你真正準備在手機上實現的時候麻煩就出現了。因為所有的View默認都是矩形的,所以事件接收區域也是矩形的,如果直接使用系統提供的 View 來組合出一摸一樣的布局也很簡單,但點擊區域該如何處理?顯然這樣有部分點擊區域是在控件外面的,并且控件之間會產生重疊區域,點擊的時候容易判斷錯誤,例如:
紅色方框表示用 View 組合的情況下,單個 View 的可點擊區域。
當我們面對這樣比較奇特的控件的時候,有很多處理辦法,比較投機的一種就是背景貼一個靜態圖,按鈕做成透明的,設置小一點,放在對應的位置,這樣可以保證不會誤觸,當然了如果想要點擊效果可以在按鈕按下的時候更新一下背景圖,這樣雖然也可以,但可點擊區域會變小,體驗效果會變差。設計方案變得非常復雜,而且邏輯也不容易處理,可以說是一種非常糟糕的設計。
當然了,看了我這么多文章的小伙伴應該也猜到我接下來要說什么了,沒錯,就是自定義 View。當我們面對一些奇葩控件的時候,自定義 View 就變成了一種非常好用的處理方案。
相信小伙伴們看過前面的文章 之后,對各種圖形的繪制已經不成問題了,所以我們直接處理重點問題。
特殊形狀控的點擊區域判斷
要進行特殊形狀的點擊判斷,要用到一個之前沒有使用過的類:Region。
Region 直接翻譯的意思是 地域,區域。 在此處應該是區域的意思 。它和 Path 有些類似,但 Path 可以是不封閉圖形,而 Region 總是封閉的。可以通過 setPath 方法將 Path 轉換為 Region。
本文中我們重點要使用到的是 Region 中的 contains 方法,這個方法可以判斷一個點是否包含在該區域內。
下面是一個簡單的示例程序:
public class RegionClickView extends View {
Paint mPaint;
Region globalRegion;
Region circleRegion1;
Region circleRegion2;
Path circlePath1;
Path circlePath2;
public RegionClickView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.GRAY);
// 用Path創建兩個圓
circlePath1 = new Path();
circlePath2 = new Path();
circlePath1.addCircle(100, 100, 50, Path.Direction.CW);
circlePath2.addCircle(400, 400, 50, Path.Direction.CW);
// 創建 Region
circleRegion1 = new Region();
circleRegion2 = new Region();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 將剪裁邊界設置為視圖大小
globalRegion = new Region(0, 0, w, h);
// ▼將 Path 添加到 Region 中
circleRegion1.setPath(circlePath1, globalRegion);
circleRegion2.setPath(circlePath2, globalRegion);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
int x = (int) event.getX();
int y = (int) event.getY();
// ▼點擊區域判斷
if (circleRegion1.contains(x,y)){
Toast.makeText(this.getContext(),"圓1被點擊",Toast.LENGTH_SHORT).show();
}
if (circleRegion2.contains(x,y)){
Toast.makeText(this.getContext(),"圓2被點擊",Toast.LENGTH_SHORT).show();
}
break;
}
return super.onTouchEvent(event);
}
@Override
protected void onDraw(Canvas canvas) {
// ▼注意此處將全局變量轉化為局部變量,方便 GC 回收 Canvas
Path circle1 = circlePath1;
Path circle2 = circlePath2;
// 繪制兩個圓
canvas.drawPath(circle1,mPaint);
canvas.drawPath(circle2,mPaint);
}
}
代碼中比較重要的內容都用 ▼ 符號標記出來了。
上述代碼非常簡單,就是創建了兩個 Path,在 Path 中添加圓形,之后將 Path 設置到 Region 中,當手指在屏幕上按下的時候判斷一個手指按下位置是否在 Region 區域內。
代碼整體邏輯非常簡單,大家測試一下就行了。
畫布變換后坐標轉換問題
還是上述的例子,繪制一個上下左右選擇按鍵,這個控件是上下左右對稱的,熟悉我代碼風格的小伙伴都知道,如果遇上這種問題,我肯定是要將坐標系平移到這個控件中心的,這樣數據比較好計算,然而進行畫布變換操作會產生一個新問題: 手指觸摸的坐標系和畫布坐標系不統一,就可能引起手指觸摸位置和繪制位置不統一。
舉個栗子:
在手指按下位置繪制一個圓。
public class CanvasVonvertTouchTest extends CustomView{
float down_x = -1;
float down_y = -1;
public CanvasVonvertTouchTest(Context context) {
this(context, null);
}
public CanvasVonvertTouchTest(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()){
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
down_x = event.getX();
down_y = event.getY();
invalidate();
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
down_x = down_y = -1;
invalidate();
break;
}
return true;
}
@Override
protected void onDraw(Canvas canvas) {
float x = down_x;
float y = down_y;
// 繪制觸摸坐標系,顏色為灰色,為了能夠顯示出坐標系,將坐標系位置稍微偏移了一點
drawTouchCoordinateSpace(canvas);
canvas.translate(mViewWidth/2, mViewHeight/2); // 畫布平移
// 繪制平移后的坐標系,顏色為紅色
drawTranslateCoordinateSpace(canvas);
// 如果沒有就返回
if (x == -1 && y == -1)
return;
// 在觸摸位置繪制一個小圓
canvas.drawCircle(x,y,20,mDeafultPaint);
}
/**
* 繪制觸摸坐標系,顏色為灰色,為了能夠顯示出坐標系,將坐標系位置稍微偏移了一點
*/
private void drawTouchCoordinateSpace(Canvas canvas) {
canvas.save();
canvas.translate(10,10);
CanvasAidUtils.set2DAxisLength(1000, 0, 1400, 0);
CanvasAidUtils.setLineColor(Color.GRAY);
CanvasAidUtils.draw2DCoordinateSpace(canvas);
canvas.restore();
}
/**
* 繪制平移后的坐標系,顏色為紅色
*/
private void drawTranslateCoordinateSpace(Canvas canvas) {
CanvasAidUtils.set2DAxisLength(500, 500, 700, 700);
CanvasAidUtils.setLineColor(Color.RED);
CanvasAidUtils.draw2DCoordinateSpace(canvas);
CanvasAidUtils.draw2DCoordinateSpace(canvas);
}
}
可以看到,直接拿手指觸摸位置的坐標來繪制會導致繪制位置不正確,大概會像這樣子:
兩者坐標是相同的,但是由于坐標系不同,導致實際顯示位置不同。
那么問題來了,我們在之前的文章中講過,映射不同坐標系的坐標用 什么來著?
是 Matrix。
如果看過我之前的文章但沒有想起來的說明你們根本沒有認真看,全部拖出去糟蹋 5 分鐘!
沒看過的點Matrix原理 和Matrix詳解 。
Matrix 是一個矩陣,主要功能是坐標映射,數值轉換。
那么接下來我們就對上面的示例進行簡單的改造一下,讓觸摸位置和實際繪制繪制重合。
注意:比較重要的修改位置用▼標記出來了。
public class CanvasVonvertTouchTest extends CustomView{
float down_x = -1;
float down_y = -1;
public CanvasVonvertTouchTest(Context context) {
this(context, null);
}
public CanvasVonvertTouchTest(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()){
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
// ▼ 注意此處使用 getRawX,而不是 getX
down_x = event.getRawX();
down_y = event.getRawY();
invalidate();
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
down_x = down_y = -1;
invalidate();
break;
}
return true;
}
@Override
protected void onDraw(Canvas canvas) {
float[] pts = {down_x, down_y};
// 繪制觸摸坐標系,顏色為灰色,為了能夠顯示出坐標系,將坐標系位置稍微偏移了一點
drawTouchCoordinateSpace(canvas);
canvas.translate(mViewWidth/2, mViewHeight/2); // 畫布平移
// 繪制平移后的坐標系,顏色為紅色
drawTranslateCoordinateSpace(canvas);
// 如果沒有就返回
if (pts[0] == -1 && pts[1] == -1)
return;
// ▼ 獲得當前矩陣的逆矩陣
Matrix invertMatrix = new Matrix();
canvas.getMatrix().invert(invertMatrix);
// ▼ 使用 mapPoints 將觸摸位置轉換為畫布坐標
invertMatrix.mapPoints(pts);
// 在觸摸位置繪制一個小圓
canvas.drawCircle(pts[0],pts[1],20,mDeafultPaint);
}
/**
* 繪制觸摸坐標系,顏色為灰色,為了能夠顯示出坐標系,將坐標系位置稍微偏移了一點
*/
private void drawTouchCoordinateSpace(Canvas canvas) {
canvas.save();
canvas.translate(10,10);
CanvasAidUtils.set2DAxisLength(1000, 0, 1400, 0);
CanvasAidUtils.setLineColor(Color.GRAY);
CanvasAidUtils.draw2DCoordinateSpace(canvas);
canvas.restore();
}
/**
* 繪制平移后的坐標系,顏色為紅色
*/
private void drawTranslateCoordinateSpace(Canvas canvas) {
CanvasAidUtils.set2DAxisLength(500, 500, 700, 700);
CanvasAidUtils.setLineColor(Color.RED);
CanvasAidUtils.draw2DCoordinateSpace(canvas);
CanvasAidUtils.draw2DCoordinateSpace(canvas);
}
}
繪制結果:
小白點和黑色的圓沒有完全重合是因為系統顯示觸摸位置的繪制邏輯和我使用的繪制邏輯不太相同,兩者的位置是一樣的。
其實核心部分就這兩點:
// ▼ 注意此處使用 getRawX,而不是 getX
down_x = event.getRawX();
down_y = event.getRawY();
// -------------------------------------
// ▼ 獲得當前矩陣的逆矩陣
Matrix invertMatrix = new Matrix();
canvas.getMatrix().invert(invertMatrix);
// ▼ 使用 mapPoints 將觸摸位置轉換為畫布坐標
invertMatrix.mapPoints(pts);
- 使用全局坐標系
- 使用逆矩陣的 mapPoints
原理嘛,其實非常簡單,我們在畫布上正常的繪制,需要將畫布坐標系轉換為全局坐標系后才能真正的繪制內容。所以我們反著來,將獲得到的全局坐標系坐標使用當前畫布的逆矩陣轉化一下,就轉化為當前畫布的坐標系坐標了,如果對Matrix原理 和Matrix詳解 理解了,即便我不說你們也肯定會想到這個方案的。
仿遙控器按鈕代碼示例
在解決了上述兩大難題之后,相信不論形狀如何奇葩的自定義控件,基本上都難不倒大家了,最后用一個簡單的示例作為結尾,還是文章開頭所舉的例子,核心內容就是上面講的兩個東西。
public class RemoteControlMenu extends CustomView {
Path up_p, down_p, left_p, right_p, center_p;
Region up, down, left, right, center;
Matrix mMapMatrix = null;
int CENTER = 0;
int UP = 1;
int RIGHT = 2;
int DOWN = 3;
int LEFT = 4;
int touchFlag = -1;
int currentFlag = -1;
MenuListener mListener = null;
int mDefauColor = 0xFF4E5268;
int mTouchedColor = 0xFFDF9C81;
public RemoteControlMenu(Context context) {
this(context, null);
}
public RemoteControlMenu(Context context, AttributeSet attrs) {
super(context, attrs);
up_p = new Path();
down_p = new Path();
left_p = new Path();
right_p = new Path();
center_p = new Path();
up = new Region();
down = new Region();
left = new Region();
right = new Region();
center = new Region();
mDeafultPaint.setColor(mDefauColor);
mDeafultPaint.setAntiAlias(true);
mMapMatrix = new Matrix();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mMapMatrix.reset();
// 注意這個區域的大小
Region globalRegion = new Region(-w, -h, w, h);
int minWidth = w > h ? h : w;
minWidth *= 0.8;
int br = minWidth / 2;
RectF bigCircle = new RectF(-br, -br, br, br);
int sr = minWidth / 4;
RectF smallCircle = new RectF(-sr, -sr, sr, sr);
float bigSweepAngle = 84;
float smallSweepAngle = -80;
// 根據視圖大小,初始化 Path 和 Region
center_p.addCircle(0, 0, 0.2f * minWidth, Path.Direction.CW);
center.setPath(center_p, globalRegion);
right_p.addArc(bigCircle, -40, bigSweepAngle);
right_p.arcTo(smallCircle, 40, smallSweepAngle);
right_p.close();
right.setPath(right_p, globalRegion);
down_p.addArc(bigCircle, 50, bigSweepAngle);
down_p.arcTo(smallCircle, 130, smallSweepAngle);
down_p.close();
down.setPath(down_p, globalRegion);
left_p.addArc(bigCircle, 140, bigSweepAngle);
left_p.arcTo(smallCircle, 220, smallSweepAngle);
left_p.close();
left.setPath(left_p, globalRegion);
up_p.addArc(bigCircle, 230, bigSweepAngle);
up_p.arcTo(smallCircle, 310, smallSweepAngle);
up_p.close();
up.setPath(up_p, globalRegion);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
float[] pts = new float[2];
pts[0] = event.getRawX();
pts[1] = event.getRawY();
mMapMatrix.mapPoints(pts);
int x = (int) pts[0];
int y = (int) pts[1];
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
touchFlag = getTouchedPath(x, y);
currentFlag = touchFlag;
break;
case MotionEvent.ACTION_MOVE:
currentFlag = getTouchedPath(x, y);
break;
case MotionEvent.ACTION_UP:
currentFlag = getTouchedPath(x, y);
// 如果手指按下區域和抬起區域相同且不為空,則判斷點擊事件
if (currentFlag == touchFlag && currentFlag != -1 && mListener != null) {
if (currentFlag == CENTER) {
mListener.onCenterCliched();
} else if (currentFlag == UP) {
mListener.onUpCliched();
} else if (currentFlag == RIGHT) {
mListener.onRightCliched();
} else if (currentFlag == DOWN) {
mListener.onDownCliched();
} else if (currentFlag == LEFT) {
mListener.onLeftCliched();
}
}
touchFlag = currentFlag = -1;
break;
case MotionEvent.ACTION_CANCEL:
touchFlag = currentFlag = -1;
break;
}
invalidate();
return true;
}
// 獲取當前觸摸點在哪個區域
int getTouchedPath(int x, int y) {
if (center.contains(x, y)) {
return 0;
} else if (up.contains(x, y)) {
return 1;
} else if (right.contains(x, y)) {
return 2;
} else if (down.contains(x, y)) {
return 3;
} else if (left.contains(x, y)) {
return 4;
}
return -1;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.translate(mViewWidth / 2, mViewHeight / 2);
// 獲取測量矩陣(逆矩陣)
if (mMapMatrix.isIdentity()) {
canvas.getMatrix().invert(mMapMatrix);
}
// 繪制默認顏色
canvas.drawPath(center_p, mDeafultPaint);
canvas.drawPath(up_p, mDeafultPaint);
canvas.drawPath(right_p, mDeafultPaint);
canvas.drawPath(down_p, mDeafultPaint);
canvas.drawPath(left_p, mDeafultPaint);
// 繪制觸摸區域顏色
mDeafultPaint.setColor(mTouchedColor);
if (currentFlag == CENTER) {
canvas.drawPath(center_p, mDeafultPaint);
} else if (currentFlag == UP) {
canvas.drawPath(up_p, mDeafultPaint);
} else if (currentFlag == RIGHT) {
canvas.drawPath(right_p, mDeafultPaint);
} else if (currentFlag == DOWN) {
canvas.drawPath(down_p, mDeafultPaint);
} else if (currentFlag == LEFT) {
canvas.drawPath(left_p, mDeafultPaint);
}
mDeafultPaint.setColor(mDefauColor);
}
public void setListener(MenuListener listener) {
mListener = listener;
}
// 點擊事件監聽器
public interface MenuListener {
void onCenterCliched();
void onUpCliched();
void onRightCliched();
void onDownCliched();
void onLeftCliched();
}
}
運行效果:
當手指在某一區域活動時,該區域會高亮顯示,如果注冊了監聽器,點擊某一區域會觸發監聽器回調。
總結
本文雖然代碼比較多,但核心概念非常簡單,主要涉及以下兩點:
- Region 的區域檢測。
- Matrix 的坐標映射。
這兩個知識點都不是很難,然而靈活運用起來卻是非常強大的,如果有對 Matrix 不了解的小伙伴,推薦去看我之前的文章,里面有關于Matrix的詳細介紹,
來自:http://www.gcssloop.com/customview/touch-matrix-region