本篇文章已授權微信公衆號 hongyangAndroid(鴻洋)獨家發佈。
掌盟中能力七星圖截圖
仿照完成的效果截圖
基本上模仿的與原控件一致了,就是文字與頂點的距離有一些小瑕疵,這塊還需需要優化。
本文目的
可以使讀者:
1. 鞏固自定義控件的基礎知識以及正多邊形的繪製,熟悉繪製流程。
2. 複習了高中的一點數學幾何知識。
源碼地址
整體思路
通過數學幾何知識計算出每一圈(多邊形)的頂點座標,然後用Path這個類就能繪製出多邊形,一層一層的繪製,就出現了顏色不同的圈,再通過能力值和所在的能力對應的半徑計算出這個能力點在這條半徑上所在的位置,也就是座標,然後通過path就能繪製出能力的線。至於文字的話就是最外圈的頂點半徑延長一點所在的座標點繪製文字即可。
數學知識
要在自定義控件中繪製一個正多邊形,也就是說比較標準、對稱的多邊形就要用到一點數學幾何中的知識。
如圖,在一平面直角座標系上有一點P且座標爲(x,y),原點到P點的距離爲r,且這兩點所在的直線與X軸正半軸形成的夾角爲θ,那麼:
通過正餘弦定理,能得到關係式:
x=rcosθ
y=rsinθ
如果不懂正餘弦定理的自行百度或者看看高中的數學書,很簡單,勾股定理推廣得來的。
通過這個關係式,其實我們就能知道
在平面直角座標系上的任意一點,都可以通過這個點到原點的距離(r),和兩點所在的直線與X軸正半軸的夾角(θ)表示出來。
這裏的夾角(θ)還需要注意一點,是X正半軸按逆時針的方向旋轉然後得到的夾角,如圖。
這樣、我們想要一個點在平面的位置通過r和θ就能得到,而我們所繪製的多邊形這個兩個值其實是很好拿到的。
半徑r:我們自定定義,這個值用來控制多邊形的大小
角度θ:這個值是算出來的。
我們要畫正七邊形,通過圖可以看出來,就分成了7份,而我們都知道,一週是360°
那麼θ=360° / 7 ,就得到了θ,
不過需要注意的是,度數(°)是60進制的,而我們直接用360° / 7 得到的θ去參與座標運算(10進制)是有問題的,所以這裏應該是θ=2π / 7 ,π是弧度(π = 180°),而弧度是10進制的,這樣就沒有問題了。
最後需要注意一點的是,數學上的笛卡爾座標系(直角座標系)的如上面的圖,Y軸的正向是向上的,而安卓中的視圖座標系的Y軸是向下的。
所以、我們在計算點的時候,注意是順時針方向的(數學上是逆時針方向的)。
具體實現
AbilityBean
首先我們需要一個數據的實體類,通過效果圖可以看出來,只需要7個能力的文字描述以及7個能力的能力值,這裏能力值就用整形0~100,代表這個能力值是百分之多少。
public class AbilityBean {
//有哪個些能力
public static final String[] abilitys = {"擊殺", "生存", "助攻", "物理", "魔法", "防禦", "金錢"};
//每個能力的值,範圍0~100,單位%
private int kill;
private int survival;
private int assist;
private int ad;
private int ap;
private int defense;
private int money;
public AbilityBean(int kill, int survival, int assist, int ad, int ap, int defense, int money) {
this.kill = kill;
this.survival = survival;
this.assist = assist;
this.ad = ad;
this.ap = ap;
this.defense = defense;
this.money = money;
}
public static String[] getAbilitys() {
return abilitys;
}
public int getKill() {
return kill;
}
public void setKill(int kill) {
this.kill = kill;
}
public int getSurvival() {
return survival;
}
public void setSurvival(int survival) {
this.survival = survival;
}
public int getAssist() {
return assist;
}
public void setAssist(int assist) {
this.assist = assist;
}
public int getAd() {
return ad;
}
public void setAd(int ad) {
this.ad = ad;
}
public int getAp() {
return ap;
}
public void setAp(int ap) {
this.ap = ap;
}
public int getDefense() {
return defense;
}
public void setDefense(int defense) {
this.defense = defense;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
public int[] getAllAbility() {
int[] allAbility = {kill, survival, assist, ad, ap, defense, money};
return allAbility;
}
控件參數
private AbilityBean data; //元數據
private int n; //邊的數量或者能力的個數
private float R; //最外圈的半徑,頂點到中心點的距離
private int intervalCount; //間隔數量,就把半徑分爲幾段
private float angle; //兩條頂點到中線點的線之間的角度
private Paint linePaint; //畫線的筆
private Paint textPaint; //畫文字的筆
private int viewHeight; //控件寬度
private int viewWidth; //控件高度
private ArrayList<ArrayList<PointF>> pointsArrayList; //存儲多邊形頂點數組的數組
private ArrayList<PointF> abilityPoints; //存儲能力點的數組
部分參數示意圖
初始化
由於硬件加速會引起自定義view出現問題,我們這裏需要關閉硬件加速。
關閉硬件加速的方法是在AndroidManifest.xml里加入一句
android:hardwareAccelerated=”false”
放在< application />節點下表示關閉整個項目的硬件加速
放在< activity />下表示關閉該組件硬件加速
創建一個AbilityMapView類並繼承View,實現1~3個參數的構造,並在1和2個參數的構造函數中依次調用。
public AbilityMapView(Context context) {
//這地方改爲this,使得不管怎麼初始化都會進入第三個構造函數中
this(context, null);
}
public AbilityMapView(Context context, @Nullable AttributeSet attrs) {
//這地方改爲this,使得不管怎麼初始化都會進入第三個構造函數中
this(context, attrs, 0);
}
public AbilityMapView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initSize(context);
initPoints();
initPaint(context);
}
在第三個構造函數中依次調用:initSize(context)、initPoints(context)、initPaint()。
/**
* 初始化一些固定數據
*
* @param context
*/
private void initSize(Context context) {
n = 7; //七條邊
R = dp2pxF(context, 100); //半徑暫時設爲100dp
intervalCount = 4; //有四層
angle = (float) ((2 * Math.PI) / n); //一週是2π,這裏用π,因爲進制的問題,不能用360度,畫出來會有問題
//拿到屏幕的寬高,單位是像素
int screenWidth = getResources().getDisplayMetrics().widthPixels;
//控件設置爲正方向
viewWidth = screenWidth;
viewHeight = screenWidth;
}
/**
* 初始化多邊形的所有點 每一圈7個點,有4圈
*/
private void initPoints() {
//一個數組中每個元素又一是一個點數組,有幾個多邊形就有幾個數組
pointsArrayList = new ArrayList<>();
float x;
float y;
for (int i = 0; i < intervalCount; i++) {
//創建一個存儲點的數組
ArrayList<PointF> points = new ArrayList<>();
for (int j = 0; j < n; j++) {
float r = R * ((float) (4 - i) / intervalCount); //每一圈的半徑都按比例減少
//這裏減去Math.PI / 2 是爲了讓多邊形逆時針旋轉90度,所以後面的所有用到cos,sin的都要減
x = (float) (r * Math.cos(j * angle - Math.PI / 2));
y = (float) (r * Math.sin(j * angle - Math.PI / 2));
points.add(new PointF(x, y));
}
pointsArrayList.add(points);
}
}
/**
* 初始化畫筆
*
* @param context
*/
private void initPaint(Context context) {
//畫線的筆
linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
//設置線寬度
linePaint.setStrokeWidth(dp2px(context, 1f));
//畫文字的筆
textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
textPaint.setTextAlign(Paint.Align.CENTER); //設置文字居中
textPaint.setColor(Color.BLACK);
textPaint.setTextSize(sp2pxF(context, 14f));
}
在initSize我把viewWidth和viewHeigh都設置爲了屏幕寬度,因爲這裏弄成大一點的正方形好看一點。
initPoints中,是初始化了多邊形的座標,具體邏輯就是用到了開篇講的數學知識,相信讀者配合註釋一眼就能看明白,需要注意的是這裏是兩個循環,並且把點裝在了二維數組中(就是數組中的元素還是數組),因爲我們這裏要繪製4個多邊形。而每一層的多邊形中的半徑都按比例減小。
接下來我們需要給view設置元數據,所以提供一個對外公開方法:setData()
/**
* 傳入元數據
*
* @param data
*/
public void setData(AbilityBean data) {
if (data == null) {
return;
}
this.data = data;
//View本身調用迫使view重畫
invalidate();
}
重寫onMeasure、onSizeChanged
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//設置控件的最終視圖大小(寬高)
setMeasuredDimension(viewWidth, viewHeight);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
initSize(getContext());
}
onDraw
好了,終於到了最核心的地方了,這裏繪製控件、編寫核心邏輯的地方。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//把畫布的原點移動到控件的中心點
canvas.translate(viewWidth / 2, viewHeight / 2);
drawPolygon(canvas);
drawOutLine(canvas);
drawAbilityLine(canvas);
drawAbilityText(canvas);
}
在onDraw中我們首先把畫布的座標原點移動到控件的中心點,因爲默認畫布的原點在左上角,而且我們算多邊形頂點座標的時候原點也是在控件中心。然後分了4步來繪製這個控件,下面來一一講解每一步。
1.drawPolygon(canvas)
/**
* 繪製多邊形框,每一層都繪製
*
* @param canvas
*/
private void drawPolygon(Canvas canvas) {
canvas.save();//保存畫布當前狀態(平移、放縮、旋轉、裁剪等),和canvas.restore()配合使用
linePaint.setStyle(Paint.Style.FILL_AND_STROKE); //設置爲填充且描邊
Path path = new Path(); //路徑
for (int i = 0; i < intervalCount; i++) { //循環、一層一層的繪製
//每一層的顏色都都不同
switch (i) {
case 0:
linePaint.setColor(Color.parseColor("#D4F0F3"));
break;
case 1:
linePaint.setColor(Color.parseColor("#99DCE2"));
break;
case 2:
linePaint.setColor(Color.parseColor("#56C1C7"));
break;
case 3:
linePaint.setColor(Color.parseColor("#278891"));
break;
}
for (int j = 0; j < n; j++) { //每一層有n個點
float x = pointsArrayList.get(i).get(j).x;
float y = pointsArrayList.get(i).get(j).y;
if (j == 0) {
//如果是每層的第一個點就把path的起點設置爲這個點
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
path.close(); //設置爲閉合的
canvas.drawPath(path, linePaint);
path.reset(); //清除path存儲的路徑
}
canvas.restore();
}
這裏就是繪製多邊形的,由於我們前面已經把多邊形的頂點都出計算出來了,所以這裏我們只需通過循環給Path設置好路徑,然後一層一層的畫,通過switch爲每一層設置不同的顏色,就能看出層次感,一圈一圈的。需要注意的是要調用linePaint.setStyle(Paint.Style.FILL_AND_STROKE);,因爲我們是要繪製實心的。
效果圖:
2.drawOutLine(Canvas canvas)
/**
* 畫輪廓線
* 1.先畫最外面的多邊形輪廓
* 2.再畫頂點到中心的線
*
* @param canvas
*/
private void drawOutLine(Canvas canvas) {
canvas.save();//保存畫布當前狀態(平移、放縮、旋轉、裁剪等),和canvas.restore()配合使用
linePaint.setColor(Color.parseColor("#99DCE2"));
linePaint.setStyle(Paint.Style.STROKE); //設置空心的
//先畫最外面的多邊形輪廓
Path path = new Path(); //路徑
for (int i = 0; i < n; i++) {
//只需要第一組的點
float x = pointsArrayList.get(0).get(i).x;
float y = pointsArrayList.get(0).get(i).y;
if (i == 0) {
//如果是第一個點就把path的起點設置爲這個點
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
path.close(); //閉合路徑
canvas.drawPath(path, linePaint);
//再畫頂點到中心的線
for (int i = 0; i < n; i++) {
float x = pointsArrayList.get(0).get(i).x;
float y = pointsArrayList.get(0).get(i).y;
canvas.drawLine(0, 0, x, y, linePaint); //起點都是中心點
}
canvas.restore();
}
這裏是畫輪廓線的,因爲我們只需要畫最外圈的輪廓和半徑線、所以只需要最外圈的7個點和中心點原點就夠了,外圈用Path來畫,注意這裏畫筆就要設置linePaint.setStyle(Paint.Style.STROKE),要空心的,還有別忘了是閉合路徑,調用path.close()。而半徑線就循環7次一根一根畫出來就可以了,看註釋,很簡單。
效果圖:
3.drawAbilityLine(Canvas canvas)
/**
* 畫能力線
*
* @param canvas
*/
private void drawAbilityLine(Canvas canvas) {
canvas.save();
//先把能力點初始化出來
abilityPoints = new ArrayList<>();
int[] allAbility = data.getAllAbility();
for (int i = 0; i < n; i++) {
float r = R * (allAbility[i] / 100.0f); //能力值/100再乘以半徑就是所佔的比例
float x = (float) (r * Math.cos(i * angle - Math.PI / 2));
float y = (float) (r * Math.sin(i * angle - Math.PI / 2));
abilityPoints.add(new PointF(x, y));
}
linePaint.setStrokeWidth(dp2px(getContext(), 2f));
linePaint.setColor(Color.parseColor("#E96153"));
linePaint.setStyle(Paint.Style.STROKE); //設置空心的
Path path = new Path(); //路徑
for (int i = 0; i < n; i++) {
float x = abilityPoints.get(i).x;
float y = abilityPoints.get(i).y;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
path.close(); //別忘了閉合
canvas.drawPath(path, linePaint);
canvas.restore();
}
這裏就是畫能力線了,首先我們需要根據能力值把能力點計算出來,這裏思路就是我們把原點到頂點的距離看成是0~100%,然後看能力值是多少,就在這個能力所對應的半徑上哪個位置,能力值越低就離原點越近,通過能力點的半徑就能算出座標,因爲角度已經全部是一樣的了,然後這裏畫筆設置空心的linePaint.setStyle(Paint.Style.STROKE);通過閉合的Path就能畫出來。
效果圖:
3.drawAbilityText(Canvas canvas)
/**
* 畫能力描述的文字
*
* @param canvas
*/
private void drawAbilityText(Canvas canvas) {
canvas.save();
//先計算出座標來
ArrayList<PointF> textPoints = new ArrayList<>();
for (int i = 0; i < n; i++) {
float r = R + dp2pxF(getContext(), 15f);
float x = (float) (r * Math.cos(i * angle - Math.PI / 2));
float y = (float) (r * Math.sin(i * angle - Math.PI / 2));
textPoints.add(new PointF(x, y));
}
//拿到字體測量器
Paint.FontMetrics metrics = textPaint.getFontMetrics();
String[] abilitys = AbilityBean.getAbilitys();
for (int i = 0; i < n; i++) {
float x = textPoints.get(i).x;
//ascent:上坡度,是文字的基線到文字的最高處的距離
//descent:下坡度,,文字的基線到文字的最低處的距離
float y = textPoints.get(i).y - (metrics.ascent + metrics.descent) / 2;
canvas.drawText(abilitys[i], x, y, textPaint);
}
canvas.restore();
}
這裏很簡單,我們只需要把半徑R稍微延遲一點跟前面的計算方法一樣,就能計算出座標來,然後繪製文字即可,具體看註釋。
效果圖:
到了這裏,能力七星圖的自定義View就算是大功告成了。
編寫完了自定義View的類別忘了添加到Activity裏
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.fu.abilitymapview.MainActivity">
<com.fu.abilitymapview.AbilityMapView
android:id="@+id/ability_map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
MainActivity
public class MainActivity extends AppCompatActivity {
private AbilityMapView abilitymapview;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
this.abilitymapview = (AbilityMapView) findViewById(R.id.ability_map_view);
abilitymapview.setData(new AbilityBean(65, 70, 80, 70, 80, 80, 80));
}
}
補充說明
1.爲什麼每個點計算的時候sin、cos裏面的角度都減去π/2?
爲了使繪製出來的圖形逆時針旋轉90°,形成以Y軸爲軸的對稱圖形。因爲這樣計算出來的點的座標都是向逆時針方向旋轉了90°,自然繪製出來的圖形也旋轉了90°
如果我不減去π/2呢,是個什麼效果呢?
這樣就是以X軸爲軸的對稱圖形了,看上沒那麼舒服了,對吧。所以要減去π/2。
2.輔助座標軸
在剛開始繪製的時候可以加個座標座標軸來輔助我們繪製。只需在onDraw中加三行代碼。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//把畫布的原點移動到控件的中心點
canvas.translate(viewWidth / 2, viewHeight / 2);
drawPolygon(canvas);
drawOutLine(canvas);
drawAbilityLine(canvas);
drawAbilityText(canvas);
//座標軸x,y 輔助用
linePaint.setColor(Color.RED);
canvas.drawLine(-(viewWidth / 2), 0, viewWidth / 2, 0, linePaint);
canvas.drawLine(0, -(viewWidth / 2), 0, viewWidth / 2, linePaint);
}
注意一定要放在onDraw中的最後面,這樣座標軸纔不會被覆蓋而看不到。
效果圖:
3.顏色提取
顏色提取器下載地址:https://github.com/qq908323236/AbilityMapView/blob/master/Colors.rar
關於擴展
看完本篇文章,明白了數學知識和邏輯,其實不管畫幾星圖,幾邊形,都可以,只需要改一下n就可以了。
比如n=5
最後感謝每一位讀我文章的人~