Android自定義View系列(二)——打造一個仿2K游戲搖桿
寫作原因:Android進階過程中有一個繞不開的話題——自定義View。這一塊是安卓程序員更好地實現功能自主化必須邁出的一步。下面這個系列博主將通過實現幾個例子來認識安卓自定義View的方法。從自定義View到自定義ViewGroup,View事件處理再到View深入分析(這一章如果水平未到位可能今后再補充),其中會涉及一些小的知識,包括Canvas的使用、動畫等等。這是本系列的第二章,博主將通過定制一個搖桿的實例鞏固上章的知識,并引入自定義View中實現用戶交互和數據回調兩個方面的功能。此外在本章中我們將看到數學知識(尤其是三角函數)在自定義View中的重要作用(這也是本例的一個難點),下面開始解放大腦和雙手吧。
最終效果
本例的最終效果如下:
這就是本例的最終效果,我們將實現一個虛擬游戲方向搖桿,模擬搖桿操作。此外我們將為它寫一個監聽器實現搖動方向和速度等數據返回(本例中只實現了監聽部分代碼,數據讀者可以自行加上)。
基本思路
首先依然是上一篇所講述的那幾個步驟,包括自定義XML屬性,引入屬性,測量和繪制幾個部分(沒看過上一篇文章的點擊博客閱讀或者查看博主的簡書),除了這幾個部分外我們還需要重寫onTouchEvent()方法進行View事件處理和利用回調寫好監聽器。整體思路就是這樣,看起來不難,實際操作起來陷阱多多。
具體實現
前期準備
新建value/attrs.xml,在XML中聲明并引入以下屬性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="InnerColor" format="color"/>
<attr name="OuterColor" format="color"/>
<declare-styleable name="NavController">
<attr name="InnerColor" />
<attr name="OuterColor"/>
</declare-styleable>
</resources>
這次我們只需要兩個屬性,小圓顏色和大圓顏色。然后新建一個java文件,繼承View命名為NavController,在java中重寫構造方法并且將XML屬性導入,新建畫筆對象,為之設置好屬性。關鍵代碼如下:
public NavController(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray ta = getResources().obtainAttributes(attrs,R.styleable.NavController);
innerColor = ta.getColor(R.styleable.NavController_InnerColor,INNER_COLOR_DEFAULT);
outerColor = ta.getColor(R.styleable.NavController_OuterColor,OUTER_COLOR_DEFAULT);
ta.recycle();
OUTER_WIDTH_SIZE = dip2px(context,125.0f);
OUTER_HEIGHT_SIZE = dip2px(context,125.0f);
outerPaint = new Paint();
innerPaint = new Paint();
outerPaint.setColor(outerColor);
outerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
innerPaint.setColor(innerColor);
innerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
上面的OUTER_WIDTH_SIZE和OUTER_HEIGHT_SIZE分別是大圓在沒有設置具體值下的默認大小,我們使用dip2px()方法將我們熟練掌握的dip轉化為java邏輯唯一承認的px單位,具體實現:
public static int dip2px(Context context, float dpValue){
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue*scale +0.5f);
}
這樣就做好了前期準備工作,由于上篇講述過關于onMeasure和onDraw的相關理解和用法,這里就簡單闡述,將這兩塊寫在同一個部分。
測量繪制
測量時我們分別對三種模式下的尺寸進行不同的處理,分別是返回父View給的值加上padding值(EXACTLY),返回大圓的寬高(UNSPECIFIED)和返回大圓寬高與父View允許最大值之間的最小值(AT_MOST)。然后回調onSizeChanged()中取出實際寬高值,利用該值進行View繪制。onDraw中主要是確定了兩個圓的半徑(大圓半徑為去除padding的寬高一半下四種情況的最小值,參照代碼看這句話。小圓半徑為大圓的一半)和繪制了兩個圓。此外小圓的中心點我們現在onSizeChanged中進行了賦值,注意小圓中心點坐標值的改變是本例的關鍵,通過改變它來實現效果。這樣我們就把View的顯示區域和View的基本形狀定義完畢。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = measureWidth(widthMeasureSpec);
int height = measureHeight(heightMeasureSpec);
setMeasuredDimension(width,height);
}
private int measureWidth(int widthMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthVal = MeasureSpec.getSize(widthMeasureSpec);
//處理三種模式
if(widthMode==MeasureSpec.EXACTLY){
return widthVal+getPaddingLeft()+getPaddingRight();
}else if(widthMode==MeasureSpec.UNSPECIFIED){
return OUTER_WIDTH_SIZE;
}else{
return Math.min(OUTER_WIDTH_SIZE,widthVal);
}
}
private int measureHeight(int heightMeasureSpec) {
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightVal = MeasureSpec.getSize(heightMeasureSpec);
//處理三種模式
if(heightMode==MeasureSpec.EXACTLY){
return heightVal+getPaddingTop()+getPaddingBottom();
}else if(heightMode==MeasureSpec.UNSPECIFIED){
return OUTER_HEIGHT_SIZE;
}else{
return Math.min(OUTER_HEIGHT_SIZE,heightVal);
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
realWidth = w;
realHeight = h;
innerCenterX = realWidth/2;
innerCenterY = realHeight/2;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
outRadius = Math.min(Math.min(realWidth/2-getPaddingLeft(),realWidth/2-getPaddingRight()),Math.min(realHeight/2-getPaddingTop(),realHeight/2-getPaddingBottom()));
//畫外部圓
canvas.drawCircle(realWidth/2,realHeight/2,outRadius,outerPaint);
//內部圓
innerRedius = outRadius*0.5f;
canvas.drawCircle(innerCenterX,innerCenterY,innerRedius,innerPaint);
}
View事件處理
下面這一步是實現效果的關鍵步驟。上面我們已經繪制出了基本的形狀,但是View觸摸后沒有任何效果。這一步正是實現View觸摸的效果。對于這一步的理解可能有一定的難度,讀者應該反復揣摩其中涉及到基本的三角函數和圓的方程的計算,如果不明白這兩部分數學知識的最好先去翻一翻課本……定義一些炫酷的View大都基于數學知識的基礎上。我們要重寫onTouchEvent()方法,先放上這部分代碼:
@Override
public boolean onTouchEvent(MotionEvent event) {
if(event.getAction()==MotionEvent.ACTION_DOWN){
changeInnerCirclePosition(event);
}
if(event.getAction()==MotionEvent.ACTION_MOVE){
changeInnerCirclePosition(event);
Log.i("TAG","MOVED");
}
if(event.getAction()==MotionEvent.ACTION_UP){
innerCenterX = realWidth/2;
innerCenterY = realHeight/2;
invalidate();
}
return true;
}
可以看到,上面的對onTouchEvent()方法的重寫處理了三種情況下的邏輯:用戶在View的區域按下、移動和離開三種情況,當手指離開時我們把內圓的中心點移動到View的最中間(恢復初始狀態),然后刷新,當按下或者移動時調用changeInnerCirclePosition(event)方法,這個方法用于對內圓進行處理讓內圓根據手指位置判斷調整位置。
下面看看changeInnerCirclePosition()方法。
private void changeInnerCirclePosition(MotionEvent e) {
//圓的方程:(x-realWidth/2)^2 +(y - realHeight/2)^2 <= outRadius^2
//第一步,確定有效的觸摸點集
float X = e.getX();
float Y = e.getY();
if(mCallBack!=null){
mCallBack.onNavAndSpeed(X,Y);
}
boolean isPointInOutCircle = Math.pow(X-realWidth/2,2) +Math.pow(Y-realHeight/2,2)<=Math.pow(outRadius,2);
if(isPointInOutCircle){
Log.i("TAG","inCircle");
//兩種情況:小圓半徑
boolean isPointInFree = Math.pow(X-realWidth/2,2) +Math.pow(Y-realHeight/2,2)<=Math.pow(outRadius-innerRedius,2);
if(isPointInFree){
innerCenterX = X;
innerCenterY = Y;
}else{
//處理限制區域,這部分使用觸摸點與中心點與外圓方程交點作為內圓的中心點
//使用近似三角形來確定這個點
//求出觸摸點,觸摸點垂足和中心點構成的直角三角形(pointTri)的直角邊長
float pointTriX = Math.abs(realWidth/2-X);//橫邊
float pointTriY = Math.abs(realHeight/2-Y);//豎邊
float pointTriZ = (float) Math.sqrt((Math.pow(pointTriX,2)+Math.pow(pointTriY,2)));
float TriSin = pointTriY/pointTriZ;
float TriCos = pointTriX/pointTriZ;
//求出在圓環上的三角形的兩個直角邊的長度
float limitCircleTriY = (outRadius-innerRedius)*TriSin;
float limitCircleTriX = (outRadius-innerRedius)*TriCos;
//確定內圓中心點的位置,分四種情況
if(X>=realWidth/2 && Y>=realHeight/2){
innerCenterX = realWidth/2+limitCircleTriX;
innerCenterY = realHeight/2+limitCircleTriY;
}else if(X<realWidth/2 && Y>=realHeight/2){
innerCenterX = realWidth/2-limitCircleTriX;
innerCenterY= realHeight/2+limitCircleTriY;
}else if(X>=realWidth/2 && Y<realHeight/2){
innerCenterX = realWidth/2+limitCircleTriX;
innerCenterY= realHeight/2-limitCircleTriY;
}else{
innerCenterX = realWidth/2-limitCircleTriX;
innerCenterY= realHeight/2-limitCircleTriY;
}
Log.i("TAG","inLimit");
}
invalidate();
}else{
Log.i("TAG","notInCircle");
}
}
這個方法很長,上面我已經說了,它是用于讓內圓根據用戶的手指的位置進行位置變動的關鍵。下面一步一步剖析。附上本人實現過程中繪制的圖片。建議在實現一些邏輯時可以繪圖幫助記錄和啟發思維。
第一步,我們先獲取有效的觸摸范圍(根據個人實際情況而定)
本例中博主不使用希望用戶在點擊大圓外面的范圍時內圓還跟著運動,所以要先確定一下觸摸的有效范圍。
使用圓的方程來判斷用戶的觸摸點是否在大圓內,代碼: boolean isPointInOutCircle = Math.pow(X-realWidth/2,2) +Math.pow(Y-realHeight/2,2)<=Math.pow(outRadius,2); 。如果不在大圓內不執行邏輯,在大圓內則進行下一步判斷。
第二步,觸摸點是否在自由域內?
這里博主使用了自由域這個詞(好吧,自己扯的)。以大圓半徑減去小圓半徑后的值作為半徑生成新的圓(命名為 限制圓 ,下面用到),我把下圖中S1部分叫做自由域,S2為非自由域。
自由域有什么特點?就是當用戶把觸摸點落在自由域內我們小圓的中心點只要跟著觸摸點的坐標就行了,沒有任何限制;而當落在非自由域內時小圓的中心點就受到限制了。這里分成兩部分討論。對于自由域的處理見下面:
if(isPointInFree){
innerCenterX = X;
innerCenterY = Y;
}
對于非自由域我們怎么處理呢?當用戶觸摸點落在非自由域時,我們把觸摸點與限制圓作為小圓中心點的坐標,見下圖展示:
下面就是求解直線與圓的交點坐標的問題了(博主整整搞了一個小時……),只要解決這個問題即可,不過要注意這里的坐標與數學的坐標系略有不同,橫軸為x向右,縱軸為y向下。我的處理方式見上面代碼,主要利用相似三角形和三角函數的知識。具體注解上面有表述。這一步跟繪制往往表現了一個自定義View的質量高低。
監聽器的構造
有了這個搖桿,最后就是為它實現返回數據的功能了,我們使用監聽器來實現。(可以參考 利用Android回調機制對Dialog進行簡單封裝 關于回調監聽的知識)先寫好監聽的回調接口,
public interface OnNavAndSpeedListener{
public void onNavAndSpeed(float nav,float speed);
}
聲明接口對象mCallBack,然后在需要回調返回數據的地方調用改接口中的方法: mCallBack.onNavAndSpeed(float nav,float speed) ,注意先判斷mCallBack對象是否為null。然后使用
public void setOnNavAndSpeedListener(OnNavAndSpeedListener listener){
mCallBack = listener;
}
在Activity中讓調用者新建接口并傳入。Activity中具體使用如下:
navController.setOnNavAndSpeedListener(new NavController.OnNavAndSpeedListener() {
@Override
public void onNavAndSpeed(float nav, float speed) {
Log.i("TAG",nav+speed+"");
}
});
這樣就實現了速度和方向的返回,定制完了一個游戲搖桿。讀者可以根據需求進行優化更改。
總結
本章借助實現游戲搖桿的例子回顧了自定義View的基本步驟和引入事件處理和監聽器的相關實現,下面的文章將對ViewGroup一塊開始動刀子學習。如果感覺對您有幫助可以關注本人博客或者簡書。
附錄:View具體代碼
public class NavController extends View {
private int innerColor;
private int outerColor;
private final static int INNER_COLOR_DEFAULT = Color.parseColor("#d32f2f");
private final static int OUTER_COLOR_DEFAULT = Color.parseColor("#f44336");
private int OUTER_WIDTH_SIZE;
private int OUTER_HEIGHT_SIZE;
private int realWidth;//繪圖使用的寬
private int realHeight;//繪圖使用的高
private float innerCenterX;
private float innerCenterY;
private float outRadius;
private float innerRedius;
private Paint outerPaint;
private Paint innerPaint;
private OnNavAndSpeedListener mCallBack = null;
public interface OnNavAndSpeedListener{
public void onNavAndSpeed(float nav,float speed);
}
public NavController(Context context) {
this(context,null);
}
public NavController(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray ta = getResources().obtainAttributes(attrs,R.styleable.NavController);
innerColor = ta.getColor(R.styleable.NavController_InnerColor,INNER_COLOR_DEFAULT);
outerColor = ta.getColor(R.styleable.NavController_OuterColor,OUTER_COLOR_DEFAULT);
ta.recycle();
OUTER_WIDTH_SIZE = dip2px(context,125.0f);
OUTER_HEIGHT_SIZE = dip2px(context,125.0f);
outerPaint = new Paint();
innerPaint = new Paint();
outerPaint.setColor(outerColor);
outerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
innerPaint.setColor(innerColor);
innerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = measureWidth(widthMeasureSpec);
int height = measureHeight(heightMeasureSpec);
setMeasuredDimension(width,height);
}
private int measureWidth(int widthMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthVal = MeasureSpec.getSize(widthMeasureSpec);
//處理三種模式
if(widthMode==MeasureSpec.EXACTLY){
return widthVal+getPaddingLeft()+getPaddingRight();
}else if(widthMode==MeasureSpec.UNSPECIFIED){
return OUTER_WIDTH_SIZE;
}else{
return Math.min(OUTER_WIDTH_SIZE,widthVal);
}
}
private int measureHeight(int heightMeasureSpec) {
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightVal = MeasureSpec.getSize(heightMeasureSpec);
//處理三種模式
if(heightMode==MeasureSpec.EXACTLY){
return heightVal+getPaddingTop()+getPaddingBottom();
}else if(heightMode==MeasureSpec.UNSPECIFIED){
return OUTER_HEIGHT_SIZE;
}else{
return Math.min(OUTER_HEIGHT_SIZE,heightVal);
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
realWidth = w;
realHeight = h;
innerCenterX = realWidth/2;
innerCenterY = realHeight/2;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
outRadius = Math.min(Math.min(realWidth/2-getPaddingLeft(),realWidth/2-getPaddingRight()),Math.min(realHeight/2-getPaddingTop(),realHeight/2-getPaddingBottom()));
//畫外部圓
canvas.drawCircle(realWidth/2,realHeight/2,outRadius,outerPaint);
//內部圓
innerRedius = outRadius*0.5f;
canvas.drawCircle(innerCenterX,innerCenterY,innerRedius,innerPaint);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if(event.getAction()==MotionEvent.ACTION_DOWN){
changeInnerCirclePosition(event);
}
if(event.getAction()==MotionEvent.ACTION_MOVE){
changeInnerCirclePosition(event);
Log.i("TAG","MOVED");
}
if(event.getAction()==MotionEvent.ACTION_UP){
innerCenterX = realWidth/2;
innerCenterY = realHeight/2;
invalidate();
}
return true;
}
private void changeInnerCirclePosition(MotionEvent e) {
//圓的方程:(x-realWidth/2)^2 +(y - realHeight/2)^2 <= outRadius^2
//第一步,確定有效的觸摸點集
float X = e.getX();
float Y = e.getY();
if(mCallBack!=null){
mCallBack.onNavAndSpeed(X,Y);
}
boolean isPointInOutCircle = Math.pow(X-realWidth/2,2) +Math.pow(Y-realHeight/2,2)<=Math.pow(outRadius,2);
if(isPointInOutCircle){
Log.i("TAG","inCircle");
//兩種情況:小圓半徑
boolean isPointInFree = Math.pow(X-realWidth/2,2) +Math.pow(Y-realHeight/2,2)<=Math.pow(outRadius-innerRedius,2);
if(isPointInFree){
innerCenterX = X;
innerCenterY = Y;
}else{
//處理限制區域,這部分使用觸摸點與中心點與外圓方程交點作為內圓的中心點
//使用近似三角形來確定這個點
//求出觸摸點,觸摸點垂足和中心點構成的直角三角形(pointTri)的直角邊長
float pointTriX = Math.abs(realWidth/2-X);//橫邊
float pointTriY = Math.abs(realHeight/2-Y);//豎邊
float pointTriZ = (float) Math.sqrt((Math.pow(pointTriX,2)+Math.pow(pointTriY,2)));
float TriSin = pointTriY/pointTriZ;
float TriCos = pointTriX/pointTriZ;
//求出在圓環上的三角形的兩個直角邊的長度
float limitCircleTriY = (outRadius-innerRedius)*TriSin;
float limitCircleTriX = (outRadius-innerRedius)*TriCos;
//確定內圓中心點的位置,分四種情況
if(X>=realWidth/2 && Y>=realHeight/2){
innerCenterX = realWidth/2+limitCircleTriX;
innerCenterY = realHeight/2+limitCircleTriY;
}else if(X<realWidth/2 && Y>=realHeight/2){
innerCenterX = realWidth/2-limitCircleTriX;
innerCenterY= realHeight/2+limitCircleTriY;
}else if(X>=realWidth/2 && Y<realHeight/2){
innerCenterX = realWidth/2+limitCircleTriX;
innerCenterY= realHeight/2-limitCircleTriY;
}else{
innerCenterX = realWidth/2-limitCircleTriX;
innerCenterY= realHeight/2-limitCircleTriY;
}
Log.i("TAG","inLimit");
}
invalidate();
}else{
Log.i("TAG","notInCircle");
}
}
public void setOnNavAndSpeedListener(OnNavAndSpeedListener listener){
mCallBack = listener;
}
public static int dip2px(Context context, float dpValue){
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue*scale +0.5f);
}
}
系列文章
Android自定義View系列(一)——打造一個愛心進度條
Android自定義View系列(二)——打造一個仿2K游戲搖桿