Android 折線圖繪製
阿新 • • 發佈:2019-02-09
專案需要一個折線圖,又不想引入那個MPAndroidChart和HelloCharts框架,看了看他們的原理和微信推薦的內容,修改整理出了下面的內容。
在此感謝原作者。
我們大致要實現的形式如下:
看完之後,讓我們進入正題:
自定義View四步驟走起;
還是我們自定View的那幾個步驟:
- 1、自定義View的屬性
- 2、在View的構造方法中獲得我們自定義的屬性
- [ 3、重寫onMesure ]
- 4、重寫onDraw
- 5、重寫onTouchEvent(如果你需要這個控制元件對手是操作進行特殊的處理)
1,在attrs裡面進行宣告
<!--這裡為什麼抽出去來?因為假如有兩個textSize分別在不同的自定義view下,構建的時候會報錯,抽取出來重複利用-->
<attr name="textSize" format="dimension|reference"/>
<attr name="textColor" format="color"/>
<declare-styleable name="ChartView">
<attr name="max_score" format="integer"/>
<attr name="min_score" format="integer"/>
<attr name="broken_line_color" format="color"/>
<attr name="textColor"/>
<attr name="textSize"/>
<attr name="dottedlineColor" format="color"/>
</declare-styleable>
2、在View的構造方法中獲得我們自定義的屬性
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ChartView);
maxScore = a.getInt (R.styleable.ChartView_max_score, 800);
minScore = a.getInt(R.styleable.ChartView_min_score, 600);
brokenLineColor = a.getColor(R.styleable.ChartView_broken_line_color, brokenLineColor);
textNormalColor = a.getColor(R.styleable.ChartView_textColor, textNormalColor);
textSize = a.getDimensionPixelSize(R.styleable.ChartView_textSize, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
15, getResources().getDisplayMetrics()));
straightLineColor = a.getColor(R.styleable.ChartView_dottedlineColor, straightLineColor);
a.recycle();
在View的構造方法中獲得我們自定義的屬性後,我們要對Paint,Path進行初始化:
//初始化path以及Paint
brokenPath = new Path();
brokenPaint = new Paint();
brokenPaint.setAntiAlias(true);
brokenPaint.setStyle(Paint.Style.STROKE);
brokenPaint.setStrokeWidth(dipToPx(brokenLineWith));
brokenPaint.setStrokeCap(Paint.Cap.ROUND);
straightPaint = new Paint();
straightPaint.setAntiAlias(true);
straightPaint.setStyle(Paint.Style.STROKE);
straightPaint.setStrokeWidth(brokenLineWith);
straightPaint.setColor((straightLineColor));
straightPaint.setStrokeCap(Paint.Cap.ROUND);
dottedPaint = new Paint();
dottedPaint.setAntiAlias(true);
dottedPaint.setStyle(Paint.Style.STROKE);
dottedPaint.setStrokeWidth(brokenLineWith);
dottedPaint.setColor((straightLineColor));
dottedPaint.setStrokeCap(Paint.Cap.ROUND);
textPaint = new Paint();
textPaint.setAntiAlias(true);
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setColor((textNormalColor));
textPaint.setTextSize(textSize);
3、重寫onMesure(此View不需要我們去計算,但我們可以重寫onSizeChanged進行一些寬高確定,資料的獲取等等)
由於onSizeChanged方法在構造方法、onMeasure之後,又在onDraw之前,此時已經完成全域性變數初始化,也得到了控制元件的寬高,所以可以在這個方法中確定一些與寬高有關的數值。
比如這個View的半徑啊、padding值等,方便繪製的時候計算大小和位置:
View的座標軸及獲取方法如圖:
下面是onSizeChanged方法:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
viewWith = w;
viewHeight = h;
initData();
}
//初始化資料,這裡將資料轉換成point點集合,在ondraw的時候取出來畫好,連線
private void initData() {
scorePoints = new ArrayList<Point>();
float maxScoreYCoordinate = viewHeight * 0.1f;
float minScoreYCoordinate = viewHeight * 0.6f;
Log.v(TAG, "initData: " + maxScoreYCoordinate);
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
int coordinateX;
for (int i = 0; i < score.length; i++) {
Log.v(TAG, "initData: " + score[i]);
Point point = new Point();
coordinateX = (int) (newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f));//確定point的X座標
point.x = coordinateX;
if (score[i] > maxScore) {
score[i] = maxScore;
} else if (score[i] < minScore) {
score[i] = minScore;
}
point.y = (int) (((float) (maxScore - score[i]) / (maxScore - minScore)) * (minScoreYCoordinate - maxScoreYCoordinate) + maxScoreYCoordinate);////確定point的Y座標
scorePoints.add(point);
}
}
4、重寫onDraw(一般來說展現出view的形態最複雜的地方)
onDraw方法如下
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawDottedLine(canvas, viewWith * 0.15f, viewHeight * 0.1f, viewWith, viewHeight * 0.1f);//上面一條虛線的畫法,不懂看座標系那一張圖
drawDottedLine(canvas, viewWith * 0.15f, viewHeight * 0.6f, viewWith, viewHeight * 0.6f);//下面一條虛線的畫法
drawText(canvas);//繪製文字,minScore,maxScore
drawMonthLine(canvas);//月份的線及座標點
drawBrokenLine(canvas);//繪製折線,就是畫點,moveto連線
drawPoint(canvas);//繪製穿過折線的點
}
下面,讓我們來一步步對其進行分解:
- 1,畫兩條虛線
/**
* @param canvas 畫布
* @param startX 起始點X座標
* @param startY 起始點Y座標
* @param stopX 終點X座標
* @param stopY 終點Y座標
*/
private void drawDottedLine(Canvas canvas, float startX, float startY, float stopX,
float stopY) {
dottedPaint.setPathEffect(new DashPathEffect(new float[]{20, 10}, 4));//DashPathEffect如果不理解,看我上一篇文章
dottedPaint.setStrokeWidth(1);
// 例項化路徑
Path mPath = new Path();
mPath.reset();
// 定義路徑的起點
mPath.moveTo(startX, startY);
mPath.lineTo(stopX, stopY);
canvas.drawPath(mPath, dottedPaint);
}
- 2,繪製文字,minScore,maxScore等等
/**
* @param canvas
* */
private void drawText(Canvas canvas) {
textPaint.setTextSize(textSize);//預設字型15
textPaint.setColor(textNormalColor);
canvas.drawText(String.valueOf(maxScore), viewWith * 0.1f - dipToPx(10), viewHeight * 0.1f + textSize * 0.25f, textPaint);
canvas.drawText(String.valueOf(minScore), viewWith * 0.1f - dipToPx(10), viewHeight * 0.6f + textSize * 0.25f, textPaint);
textPaint.setColor(0xff7c7c7c);
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
float coordinateX;//分隔線X座標
textPaint.setTextSize(textSize);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setColor(textNormalColor);
textSize = (int) textPaint.getTextSize();
for (int i = 0; i < monthText.length; i++) {//這裡是繪製月份,從陣列中取出來,一個個的寫
coordinateX = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
if (i == selectMonth - 1)//被選中的月份要單獨畫出來多幾個圈圈
{
textPaint.setStyle(Paint.Style.STROKE);
textPaint.setColor(brokenLineColor);
RectF r2 = new RectF();
r2.left = coordinateX - textSize - dipToPx(4);
r2.top = viewHeight * 0.7f + dipToPx(4) + textSize / 2;
r2.right = coordinateX + textSize + dipToPx(4);
r2.bottom = viewHeight * 0.7f + dipToPx(4) + textSize + dipToPx(8);
canvas.drawRoundRect(r2, 10, 10, textPaint);
}
//繪製月份
canvas.drawText(monthText[i], coordinateX, viewHeight * 0.7f + dipToPx(4) + textSize + dipToPx(5), textPaint);//不是就正常的畫出
textPaint.setColor(textNormalColor);
}
}
- 3,月份的座標軸線及座標點的繪製
//繪製月份的直線(包括刻度)
private void drawMonthLine(Canvas canvas) {
straightPaint.setStrokeWidth(dipToPx(1));
canvas.drawLine(0, viewHeight * 0.7f, viewWith, viewHeight * 0.7f, straightPaint);
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
float coordinateX;//分隔線X座標
for (int i = 0; i < monthCount; i++) {
coordinateX = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
canvas.drawLine(coordinateX, viewHeight * 0.7f, coordinateX, viewHeight * 0.7f + dipToPx(4), straightPaint);
//viewHeight * 0.7f + dipToPx(4)這個方法就是座標軸上的豎槓槓,你可以修改這裡來修改豎條的長度
}
}
- 4,繪製折線,就是畫點,lineTo連線drawPath畫出來。
//繪製折線
private void drawBrokenLine(Canvas canvas) {
brokenPath.reset();
brokenPaint.setColor(brokenLineColor);
brokenPaint.setStyle(Paint.Style.STROKE);
if (score.length == 0) {
return;
}
Log.v(TAG, "drawBrokenLine: " + scorePoints.get(0));
brokenPath.moveTo(scorePoints.get(0).x, scorePoints.get(0).y);
for (int i = 0; i < scorePoints.size(); i++) {
brokenPath.lineTo(scorePoints.get(i).x, scorePoints.get(i).y);
}
canvas.drawPath(brokenPath, brokenPaint);
}
- 5,繪製折線穿過的點
protected void drawPoint(Canvas canvas) {
if (scorePoints == null) {
return;
}
brokenPaint.setStrokeWidth(dipToPx(1));
for (int i = 0; i < scorePoints.size(); i++) {
brokenPaint.setColor(brokenLineColor);
brokenPaint.setStyle(Paint.Style.STROKE);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(3), brokenPaint);
brokenPaint.setColor(Color.WHITE);
brokenPaint.setStyle(Paint.Style.FILL);
if (i == selectMonth - 1) {//預設選中的才會繪製不同的點,如圖
brokenPaint.setColor(0xffd0f3f2);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(8f), brokenPaint);
brokenPaint.setColor(0xff81dddb);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(5f), brokenPaint);
//繪製浮動文字背景框
drawFloatTextBackground(canvas, scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(8f));
textPaint.setColor(0xffffffff);
//繪製浮動文字
canvas.drawText(String.valueOf(score[i]), scorePoints.get(i).x, scorePoints.get(i).y - dipToPx(5f) - textSize, textPaint);
}
brokenPaint.setColor(0xffffffff);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(1.5f), brokenPaint);
brokenPaint.setStyle(Paint.Style.STROKE);
brokenPaint.setColor(brokenLineColor);
canvas.drawCircle(scorePoints.get(i).x, scorePoints.get(i).y, dipToPx(2.5f), brokenPaint);
}
}
//這個方法是利用path和point畫出圖形,並設定背景顏色
private void drawFloatTextBackground(Canvas canvas, int x, int y) {
brokenPath.reset();
brokenPaint.setColor(brokenLineColor);
brokenPaint.setStyle(Paint.Style.FILL);
//P1
Point point = new Point(x, y);
brokenPath.moveTo(point.x, point.y);
//P2
point.x = point.x + dipToPx(5);
point.y = point.y - dipToPx(5);
brokenPath.lineTo(point.x, point.y);
//P3
point.x = point.x + dipToPx(12);
brokenPath.lineTo(point.x, point.y);
//P4
point.y = point.y - dipToPx(17);
brokenPath.lineTo(point.x, point.y);
//P5
point.x = point.x - dipToPx(34);
brokenPath.lineTo(point.x, point.y);
//P6
point.y = point.y + dipToPx(17);
brokenPath.lineTo(point.x, point.y);
//P7
point.x = point.x + dipToPx(12);
brokenPath.lineTo(point.x, point.y);
//最後一個點連線到第一個點
brokenPath.lineTo(x, y);
canvas.drawPath(brokenPath, brokenPaint);
}
5、重寫onTouchEvent
需求:點選一個點上面就會出現和預設選種一樣的效果,顯示背景圓圈和文字。底部文字也是選中狀態
//重寫ontouchevent,
@Override
public boolean onTouchEvent(MotionEvent event) {
this.getParent().requestDisallowInterceptTouchEvent(true);
//一旦底層View收到touch的action後呼叫這個方法那麼父層View就不會再呼叫onInterceptTouchEvent了,也無法截獲以後的action,這個事件被消費了
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP://觸控(ACTION_DOWN操作),滑動(ACTION_MOVE操作)和擡起(ACTION_UP)
onActionUpEvent(event);
this.getParent().requestDisallowInterceptTouchEvent(false);
break;
case MotionEvent.ACTION_CANCEL:
this.getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return true;
}
private void onActionUpEvent(MotionEvent event) {
boolean isValidTouch = validateTouch(event.getX(), event.getY());//判斷是否是指定的觸控區域
if (isValidTouch) {
invalidate();
}
}
//是否是有效的觸控範圍
private boolean validateTouch(float x, float y) {
//曲線觸控區域
for (int i = 0; i < scorePoints.size(); i++) {
// dipToPx(8)乘以2為了適當增大觸控面積
if (x > (scorePoints.get(i).x - dipToPx(8) * 2) && x < (scorePoints.get(i).x + dipToPx(8) * 2)) {
if (y > (scorePoints.get(i).y - dipToPx(8) * 2) && y < (scorePoints.get(i).y + dipToPx(8) * 2)) {
selectMonth = i + 1;
return true;
}
}
}
//月份觸控區域
//計算每個月份X座標的中心點
float monthTouchY = viewHeight * 0.7f - dipToPx(3);//減去dipToPx(3)增大觸控面積
float newWith = viewWith - (viewWith * 0.15f) * 2;//分隔線距離最左邊和最右邊的距離是0.15倍的viewWith
float validTouchX[] = new float[monthText.length];
for (int i = 0; i < monthText.length; i++) {
validTouchX[i] = newWith * ((float) (i) / (monthCount - 1)) + (viewWith * 0.15f);
}
if (y > monthTouchY) {
for (int i = 0; i < validTouchX.length; i++) {
Log.v(TAG, "validateTouch: validTouchX:" + validTouchX[i]);
if (x < validTouchX[i] + dipToPx(8) && x > validTouchX[i] - dipToPx(8)) {
Log.v(TAG, "validateTouch: " + (i + 1));
selectMonth = i + 1;
return true;
}
}
}
return false;
}
整體已經完成了,總結一下大致步驟:
- 初始化View的屬性
- 初始化畫筆
- 繪製代表最高分和最低分的兩根虛線
- 繪製文字
- 繪製代表月份的屬性
- 繪製芝麻分折線
- 繪製代表芝麻分的圓點
- 繪製選中分數的懸浮文字以及背景
- 處理點選事件
大家在看的時候按照這個邏輯走,很好理解自定義View的流程。