在上一篇雷達圖中留下了一個坑——折線圖。折線圖(broken-line graph)大概是初中數學就開始學習的,用來統計一段時間內某個數據的趨勢。反正就是用來反映數據的變化情況的,比如初中數學考試就會經常拿某個學生的成績來作爲例子。
這次我們還是仿照max+的折線圖來做(我真的沒給max+做廣告,只是因爲裏面的折線圖比較簡單而已,至少比扇貝單詞的要簡單一些→_→)。不過這次不像雷達圖那樣只是繪製View,我們可以設置一些可修改的屬性。首先分析一下原圖要怎麼畫。
分析View
首先這種圖呢官方沒有給我們提供類型的控件,所以我們需要自定義一個(廢話,如果有的話我還寫這篇文章幹啥?)。
先觀察一下上圖,和之前的雷達圖類似,只有文字可以用TextView來繪製,其他都需要重繪,這樣我們不打算使用官方的TextView了。這個折線圖有X軸和Y軸(包含標尺和名字),背景中有相間的淺灰色的柱子,另外還有幾條水平方向的參考線,最後就是數據形成的折線以及折線和座標軸圍成的一個填充了顏色的多邊形。座標軸和參考線可以用Canvas.drawLine()實現,文字可以用Canvas.drawText()實現,柱子可以用Canvas.drawRect()實現,最後是折線,由於是不規則的形狀,我們用萬能的Path來實現。好了,這就是該View所要繪製的東西。
在寫代碼之前,還有一個問題要考慮,就是我們希望哪些屬性是可修改的,然後把這些屬性寫到res/attrs.xml中,動態地去修改它。比如現在我們希望座標軸的顏色,數字顏色大小,座標軸名稱的文字顏色和大小還有折線的顏色和數據填充區域的不透明度(顏色設定爲和折線的顏色一樣,這樣看起來不會撞色顯得很奇怪)是可修改的。
實現View
以下是attrs.xml的定義:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="BLGView">
<!-- 折線顏色 -->
<attr name="lineColor" format="color" />
<!-- 區域填充不透明度 -->
<attr name="fillAlpha" format="integer" />
<!-- 座標軸顏色 -->
<attr name="axisColor" format="color" />
<!-- 座標軸名 -->
<attr name="axisX" format="string" />
<attr name="axisY" format="string" />
<!-- 座標軸名稱顏色 -->
<attr name="axisTextColor" format="color" />
<!-- 座標軸文字大小 -->
<attr name="axisTextSize" format="enum">
<enum name="large" value="14" />
<enum name="medium" value="16"/>
<enum name="small" value="18" />
</attr>
</declare-styleable>
</resources>
各個屬性都已經有註釋了,這裏就不再多說。
接下來是我們的邏輯實現,創建一個BLGView(這是broken-line graph的縮寫,不是板藍根!!!)繼承android.view.View,具體實現如下:
public class BLGView extends View {
//默認參數
private final int DEFAULT_LINE_COLOR = Color.parseColor("#5DA3EC");
private final int DEFAULT_FILL_ALPHA = 0x55;
private final int DEFAULT_AXIS_COLOR = Color.parseColor("#9099A3");
private final int DEFAULT_AXIS_TEXT_COLOR = Color.parseColor("#5B6C7E");
private final int DEFAULT_AXIS_TEXT_SIZE = 16;
private final int DEFAULT_BAR_COLOR = Color.parseColor("#F3F3F3");
private final String DEFAULT_AXIS_X = "X軸";
private final String DEFAULT_AXIS_Y = "Y軸";
//屬性變量
private int lineColor;
private int fillAlpha;
private int axisColor;
private int axisTextColor;
private int axisTextSize;
private int stuffTextSize; //座標軸標尺字體大小
private int realAxisTextSize;
private int realStuffTextSize;
private String axisX;
private String axisY;
//畫筆
Paint mPaint = new Paint();
TextPaint tPaint = new TextPaint();
//測試數據
private float[] mData = new float[]{
92.7f,
90.7f,
73.4f,
85.8f,
86.0f,
68.3f,
75.5f,
79.3f,
85.8f,
91.9f,
88.3f
};
public BLGView(Context context) {
this(context, null);
}
public BLGView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BLGView);
lineColor = a.getColor(R.styleable.BLGView_lineColor, DEFAULT_LINE_COLOR);
fillAlpha = a.getInt(R.styleable.BLGView_fillAlpha, DEFAULT_FILL_ALPHA);
axisColor = a.getColor(R.styleable.BLGView_axisColor, DEFAULT_AXIS_COLOR);
axisTextColor = a.getColor(R.styleable.BLGView_axisTextColor, DEFAULT_AXIS_TEXT_COLOR);
axisTextSize = a.getInt(R.styleable.BLGView_axisTextSize, DEFAULT_AXIS_TEXT_SIZE);
stuffTextSize = axisTextSize - 2; // 設置座標軸上的標尺數字字號比座標軸名稱的字號小2sp
axisX = a.getString(R.styleable.BLGView_axisX);
axisY = a.getString(R.styleable.BLGView_axisY);
a.recycle();
init();
}
//初始化
private void init() {
if (TextUtils.isEmpty(axisX)) {
axisX = DEFAULT_AXIS_X;
}
if (TextUtils.isEmpty(axisY)) {
axisY = DEFAULT_AXIS_Y;
}
realAxisTextSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, axisTextSize, getResources().getDisplayMetrics());
realStuffTextSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, stuffTextSize, getResources().getDisplayMetrics());
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//折線圖的最大寬度
int maxWidth = (int) (getWidth() * 0.8);
//折線圖的最大高度(固定了寬高比例爲5:3)
int maxHeight = (int) (maxWidth * 0.6);
float space = maxWidth * 0.15f;
canvas.save();
canvas.translate(space, getHeight() - space);
//柱圖
mPaint.setColor(DEFAULT_BAR_COLOR);
mPaint.setStyle(Paint.Style.FILL);
float barWidth = maxWidth / 10f;
for(int i = 0; i < 10; i+=2) {
canvas.drawRect(barWidth * i, -maxHeight, barWidth * (i + 1), 0, mPaint);
}
//座標軸
mPaint.setColor(axisColor);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(2f);
canvas.drawLine(0f, 0f, maxWidth, 0f, mPaint);
canvas.drawLine(0f, 0f, 0f, -maxHeight, mPaint);
//座標軸標尺
tPaint.setColor(axisColor);
tPaint.setTextSize(realStuffTextSize);
Rect r;
String s;
for(int i = 0; i < 100; i+=10) {
r = new Rect();
s = String.valueOf(i);
tPaint.getTextBounds(s, 0, s.length(), r);
canvas.drawText(s, barWidth * (i / 10) - r.width() / 2f, r.height(), tPaint);
}
float step = maxHeight * 0.25f;
float min = getMinData(mData);
float max = getMaxData(mData);
DecimalFormat df = new DecimalFormat("##0.0");
float v1 = min + (max - min) / 3;
float v2 = v1 + (max - min) / 3;
String minStr = String.valueOf(min);
String maxStr = String.valueOf(max);
String v1Str = String.valueOf(df.format(v1));
String v2Str = String.valueOf(df.format(v2));
String[] values = new String[]{minStr, v1Str, v2Str, maxStr};
mPaint.setColor(Color.parseColor("#D0D0D0"));
mPaint.setStrokeWidth(1);
r = new Rect();
tPaint.getTextBounds("8", 0, 1, r);
float halfLetter = r.width();
for (int i = 0; i < 4; i++) {
canvas.drawLine(0, -step * (i + 0.5f), maxWidth, -step * (i + 0.5f), mPaint);
tPaint.getTextBounds(values[i], 0, values[i].length(), r);
canvas.drawText(values[i], -r.width() - halfLetter, -(step * (i + 0.5f) - r.height() / 2.0f), tPaint);
}
tPaint.setColor(axisTextColor);
tPaint.setTextSize(realAxisTextSize);
r = new Rect();
tPaint.getTextBounds(axisX, 0, axisX.length(), r);
canvas.drawText(axisX, maxWidth, r.height(), tPaint);
tPaint.getTextBounds(axisY, 0, axisY.length(), r);
canvas.drawText(axisY, -(r.width() + halfLetter), -(maxHeight + r.height()), tPaint);
//繪製數據
float scope = max - min;
for (int i = 0; i < mData.length; i++) {
mData[i] = (mData[i] - min) / scope * 0.75f * maxHeight + 0.125f * maxHeight;
}
Path linePath = new Path();
Path fillPath = new Path();
linePath.moveTo(0, -mData[0]);
for (int i = 1; i < mData.length; i++) {
linePath.lineTo(maxWidth * i / 10, -mData[i]);
}
fillPath.addPath(linePath);
fillPath.lineTo(maxWidth, 0);
fillPath.lineTo(0, 0);
fillPath.close();
mPaint.setColor(lineColor);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(4);
canvas.drawPath(linePath, mPaint);
mPaint.setColor(Color.argb(fillAlpha, Color.red(lineColor), Color.green(lineColor), Color.blue(lineColor)));
mPaint.setStyle(Paint.Style.FILL);
canvas.drawPath(fillPath, mPaint);
canvas.restore();
}
/**
* 獲取數組最大值
*
* @param f 數組
* @return 數組的最大值
* */
private float getMaxData(float[] f) {
if(null == f || 0 == f.length) {
return Float.NaN;
}
float max = f[0];
for(int i = 1; i < f.length; i++) {
if(max < f[i]) {
max = f[i];
}
}
return max;
}
/**
* 獲取數組最小值
*
* @param f 數組
* @return 數組的最小值
* */
private float getMinData(float[] f) {
if(null == f || 0 == f.length) {
return Float.NaN;
}
float min = f[0];
for(int i = 1; i < f.length; i++) {
if(min > f[i]) {
min = f[i];
}
}
return min;
}
/**
* 設置數據
*
* @param f 數據源
* */
public void setData(float[] f) {
this.mData = f;
invalidate();
}
/**
* 設置折線的顏色
* */
public void setLineColor(int lineColor) {
this.lineColor = lineColor;
invalidate();
}
/**
* 設置數據區域的顏色不透明度(顏色和折線一致)
* */
public void setFillAlpha(int fillAlpha) {
this.fillAlpha = fillAlpha;
invalidate();
}
/**
* 設置座標軸的顏色
* */
public void setAxisColor(int axisColor) {
this.axisColor = axisColor;
invalidate();
}
/**
* 設置座標軸名稱的字體顏色
* */
public void setAxisTextColor(int axisTextColor) {
this.axisTextColor = axisTextColor;
invalidate();
}
/**
* 設置座標軸名稱的字體大小
* */
public void setAxisTextSize(int axisTextSize) {
this.axisTextSize = axisTextSize;
this.stuffTextSize = axisTextSize - 2;
realAxisTextSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, axisTextSize, getResources().getDisplayMetrics());
realStuffTextSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, stuffTextSize, getResources().getDisplayMetrics());
invalidate();
}
/**
* 設置X軸的名稱
* */
public void setAxisX(String axisX) {
this.axisX = axisX;
invalidate();
}
/**
* 設置Y軸名稱
* */
public void setAxisY(String axisY) {
this.axisY = axisY;
invalidate();
}
}
雖然代碼中加了註釋(其實等於沒有),不過還是解釋一下沒部分的含義。
- 首先我們需要給BLGView設置一些默認的屬性值,當用戶沒有在佈局文件或者代碼中設置屬性值時也能有一個默認的效果
- 然後是一些可變的屬性變量,還有兩個畫筆(一個用來畫圖,一個用來寫字),一組測試數據。
- 實現一個參數和兩個參數的構造器,在其中初始化屬性,注意字體大小因爲原來是SP做單位的,但是手機在繪製的時候是按照手機實際屏幕分辨率來計算控件的大小,所以要把SP轉換。
- 在onDraw方法中,開始畫柱子、座標軸、參考線、標尺、座標軸名稱和數據。值得說明的是,因爲我們的數據有時候會集中在某一個範圍中,比如測試數據集中在[68.3, 92.7]區間中,所以如果縱座標從0開始算,那麼整個圖形就會縮在上方,這不僅不能很直觀地反映數據起伏(因爲視覺效果造成誤讀),同時也不好看,讓折線看起來下方空上方密集。因此這裏的做法是縱座標只畫出數據源的最小值到最大值這個範圍,並且令最小值在縱座標的1/8處,最大值在縱座標的7/8處,在繪製數據那一塊的第一個for循環其實就是重新計算數據的各個點的縱座標的實際位置,公式大家可以自己推導一下(看來學好數學還是有點用處的→_→)。
- 最後就是暴露一些設置可變屬性的方法,設置好屬性記得調用invalidate()方法讓系統重繪。
測試
放上兩張測試圖:
默認的狀態
非默認狀態:
<com.spareyaya.sbww.view.BLGView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:fillAlpha="0x33"
app:lineColor="#FF0000"
app:axisX="次數"
app:axisY="成績"
app:axisTextSize="small"
app:axisColor="#0000FF"
app:axisTextColor="#000000"
/>
總結
到此,整個折線圖的功能就算完成了,別問我爲什麼不測試動態設置,因爲我懶哈哈哈哈,好吧,上面是直接在佈局文件中截圖的。
其實這個View的功能還可以做擴展,假設當數據量很大,把所有的數據一次性顯示出來同樣不易做分析,可以考慮把折線圖顯示一部分,然後通過左右滑動來觀看更多的數據等等,這個我以後(ruguo youkong)會繼續完善的。
這個折線圖和上一篇的雷達圖都只是顯示數據,沒有交互動作的,以後(ruguo youkong)我會繼續給大家帶來一些有交互性的自定義View。