先來看一下效果
表格可以左右滑動,我寫的比較簡單,就是做個演示,知識點如下:
- CustomPainter的使用
- 如何畫連續的線段
- 如何新增滾動事件
- 如何使用clipRect擷取繪製範圍,並且不影響其他圖層
- 如何繪製文字
Let's go!
1. 定義自定義表格的屬性
先給我們這個示例定義一些屬性
//折線圖的背景顏色
Color bgColor;
//x軸與y軸的顏色
Color xyColor;
//是否顯示x軸與y軸的基準線,就是中間的網格線
bool showBaseline;
//實際的資料,用來顯示折線
List<ChartData> dataList;
//x軸之間的間隔,就是圖中Data之間的間隔
double columnSpace;
//表格距離左邊的距離,不設定距離的話,x軸和y軸的標識沒空間顯示
int paddingLeft;
//表格距離頂部的距離
int paddingTop;
//表格距離底部的距離
int paddingBottom;
//繪製x軸、y軸、標記文字的畫筆
Paint linePaint;
//標記線的長度,就是表格外面的那段線
int markLineLength;
//y軸資料最大值
int maxYValue;
//y軸分多少行
int yCount;
//折線的顏色
Color polygonalLineColor;
//x軸所有內容的偏移量,用來在滑動的時候改變內容的位置
double xOffset;
//該值保證最後一條資料的底部文字能正常顯示出來
int paddingRight = 30;
//內部折線圖的實際寬度
double realChartRectWidth;
Function xOffsetSet = (double xOffset) {};
複製程式碼
然後是建構函式,在這裡做一些屬性的初始化:
LineChartWidget({
@required this.dataList,
@required this.maxYValue,
@required this.yCount,
@required this.xOffsetSet,
this.bgColor = Colors.white,
this.xyColor = Colors.black,
this.showBaseline = false,
this.columnSpace,
this.paddingLeft,
this.paddingTop,
this.paddingBottom,
this.markLineLength,
this.polygonalLineColor = Colors.blue,
this.xOffset,
}) {
linePaint = Paint()..color = xyColor;
realChartRectWidth = (dataList.length - 1) * columnSpace;
}
複製程式碼
realChartRectWidth是我們內部矩形的寬度,如下圖紅色區域所示,建立這個矩形是為了方便後面座標的計算
然後我們就開始繪製吧,繪製在paint(Canvas canvas, Size size)
方法裡:
2. 繪製背景顏色
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = bgColor);
其中size是控制元件的寬高大小,Paint是我們的畫筆,color就是畫筆的顏色
3. 建立一個矩形,方便後續繪製
Rect innerRect = Rect.fromPoints(
Offset(paddingLeft.toDouble(), paddingTop.toDouble()),
Offset(size.width, size.height - paddingBottom),
);
複製程式碼
這個矩形就是上圖的紅色部分,paddingLeft和paddingTop就是紅色矩形距離左邊緣和上邊緣的距離
4. 畫y軸
canvas.drawLine(innerRect.topLeft, innerRect.bottomLeft.translate(0, markLineLength.toDouble()), linePaint);
複製程式碼
innerRect.bottomLeft.translate
這個方法只是將你傳入的引數做了一個相加,可以看內部實現,這樣我們的y軸就比innerRect長了一點點,就是圖中Data A等標籤上面多的那一小部分線段。
5. 畫x軸
canvas.drawLine(innerRect.bottomLeft, innerRect.bottomRight, linePaint);
複製程式碼
這個就是上圖中紅色矩形的最下面的那條邊的繪製
6. 畫y軸標記
double ySpace = innerRect.height / yCount;
double startX = innerRect.topLeft.dx - markLineLength;
double startY;
for (int i = 0; i < yCount + 1; i++) {
startY = innerRect.topLeft.dy + i * ySpace;
if (showBaseline) {
canvas.drawLine(
Offset(innerRect.topLeft.dx - markLineLength, startY),
Offset(innerRect.topLeft.dx + innerRect.width, startY),
linePaint,
);
} else {
canvas.drawLine(
Offset(innerRect.topLeft.dx - markLineLength, startY),
Offset(innerRect.topLeft.dx, startY),
linePaint,
);
}
drawYText(
(i * maxYValue ~/ yCount).toString(),
Offset(innerRect.topLeft.dx - markLineLength, innerRect.bottomLeft.dy - i * ySpace),
canvas,
);
}
複製程式碼
ySpace就是y軸每個標籤之間的距離間隔,startX就是紅色矩形的左邊的x左邊減去那個小線段的長度,就是這個小線段的起始座標,再下面的邏輯也很簡單,如果顯示基準線,就是網格,就從這個小線段的x軸畫到紅色矩形的最右邊,否則就只畫到紅色矩形的左邊。drawYText方法要說明一下,Canvas是可以畫text的,但是我覺得直接用TextPainter更簡單一些,這個方法的實現如下(drawXText一起給出):
List getTextPainterAndSize(String text) {
TextPainter textPainter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: text,
style: TextStyle(color: Colors.black),
),
);
textPainter.layout();
Size size = textPainter.size;
return [textPainter, size];
}
void drawYText(String text, Offset topLeftOffset, Canvas canvas) {
List list = getTextPainterAndSize(text);
list[0].paint(canvas, topLeftOffset.translate(-list[1].width, -list[1].height / 2));
}
void drawXText(String text, Offset topLeftOffset, Canvas canvas) {
List list = getTextPainterAndSize(text);
list[0].paint(canvas, topLeftOffset.translate(-list[1].width / 2, 0));
}
複製程式碼
7. 然後我們用一個集合儲存每個實際資料的值在螢幕中的x、y座標值,也就是折線圖的點集合
List<Pair<double, double>> pointList = [];
8. 畫x軸下面的標誌文字
//畫x軸標記
int xCount = dataList.length;
startY = innerRect.bottom + markLineLength;
for (int i = 0; i < xCount; i++) {
startX = innerRect.bottomLeft.dx + i * columnSpace + xOffset;
if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {//標記1
canvas.save();
canvas.clipRect(
Rect.fromLTWH(
innerRect.left,
innerRect.top,
innerRect.width,
innerRect.height,
),
);
}
//保證向右拖動的時候第一個資料保持在起始位置
if (i == 0 && startX > paddingLeft) {//標記2
startX = innerRect.bottomLeft.dx;
// 在這裡將LineChart的xOffset置為0,否則LineChart向右滑到第一個值的時候繼續向右滑動會導致xOffset累加向右拖動的值,
// 然後會導致向左拖動的時候只能等xOffset等於0的時候UI才會變化,這樣看起來就是向左拖動但是UI沒有變化
// 所以這裡加此判斷
xOffset = 0;
xOffsetSet(0.toDouble());
}
pointList.add(
Pair(
startX,
//內矩形高度減去資料實際值的實際畫素大小,再加上頂部空白的距離
innerRect.height - dataList[i].value / maxYValue * innerRect.height + paddingTop,
),
);
if (showBaseline) {
canvas.drawLine(
Offset(startX, innerRect.top),
Offset(startX, startY),
linePaint,
);
} else {
canvas.drawLine(
Offset(startX, innerRect.bottom),
Offset(startX, startY),
linePaint,
);
}
if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {//標記3
canvas.restore();
}
drawXText(
dataList[i].type,
Offset(innerRect.bottomLeft.dx + i * columnSpace + xOffset, startY),
canvas,
);
}
複製程式碼
這部分程式碼多一些,其中columnSpace是我們在建立折線圖的時候,給x軸的標記之間設定的間隔距離。而xOffset是我們拖動折線圖的時候,x軸的標籤和豎線移動的偏移量。
其中標記1的程式碼說明一下,為什麼有這個判斷呢,這是為了折線圖在向左拖動的時候,繪製的折線圖豎線保持只顯示在紅色矩形裡,做到這一點是靠canvas.save();
和canvas.clipRect
這兩個方法,save方法可以保證canvas接下來的繪製不影響之前的繪製,然後clipRect方法指定了接下來的繪製範圍在紅色矩形中。
然後標記2的判斷,是為了在向右拖動折線圖的時候,如果第一個資料已經到了紅色矩形最左邊的位置,就讓使用者繼續向右拖動無效,不再改變x軸的座標和豎線的startX的值,不再改變xOffset的偏移量,這裡有個關鍵操作是將剛才重置的xOffset偏移量通知到上層節點(一會給出程式碼),告訴上層節點改變xOffset偏移量,因為此時已經將xOffset的值改變了,需要告訴上層節點以保持兩個值的統一,否則只在這裡改變了偏移量而上層節點沒有改變,會導致下次傳入xOffset這個值的時候不正確。
pointList.add這個方法儲存了實際資料的實際x、y座標,計算方法應該不用說了吧很簡單。
其中標記3,是為了和上面的sava方法對應,這兩個地方的判斷方式一樣,sava必須和restore方法一一對應,在這裡我們恢復了之前的繪製範圍,然後我們繼續繪製文字,如果不恢復繪製範圍的話,我們的文字是看不到的,因為文字的座標是在紅色矩形外面。
9. 畫折線
canvas.save();
canvas.clipRect(
Rect.fromLTWH(
paddingLeft.toDouble(),
paddingTop.toDouble(),
innerRect.width,
innerRect.height,
),
);
canvas.drawPoints(
///PointMode的列舉型別有三個,points(點),lines(線,隔點連線),polygon(線,相鄰連線)
PointMode.polygon,
pointList.map((pair) => Offset(pair.first, pair.last)).toList(),
Paint()
..color = polygonalLineColor
..strokeWidth = 2,
);
canvas.restore();
複製程式碼
同樣,我們的折線必須在紅色矩形範圍裡,所以這裡又有一個save和restore操作,這裡很簡單就不多說了。
10. 然後我們向此CustomPainter新增手勢操作,手勢操作主要就是改變剛才的xOffset的值,程式碼如下
class LineChart extends StatefulWidget {
final double width;
final double height;
//柱狀圖的背景顏色
final Color bgColor;
//x軸與y軸的顏色
final Color xyColor;
//柱狀圖的顏色
final Color columnarColor;
//是否顯示x軸與y軸的基準線
final bool showBaseline;
//實際的資料
final List<ChartData> dataList;
//每列之間的間隔
final double columnSpace;
//控制元件距離左邊的距離
final int paddingLeft;
//控制元件距離頂部的距離
final int paddingTop;
//控制元件距離底部的距離
final int paddingBottom;
//標記線的長度
final int markLineLength;
//y軸最大值
final int maxYValue;
//y軸分多少行
final int yCount;
//折線的顏色
final Color polygonalLineColor;
//x軸所有內容的偏移量
final double xOffset;
LineChart(
this.width,
this.height, {
@required this.dataList,
@required this.maxYValue,
@required this.yCount,
this.bgColor = Colors.white,
this.xyColor = Colors.black,
this.columnarColor = Colors.blue,
this.showBaseline = false,
this.columnSpace = 60,
this.paddingLeft = 40,
this.paddingTop = 30,
this.paddingBottom = 30,
this.markLineLength = 10,
this.polygonalLineColor = Colors.blue,
this.xOffset = 0,
});
@override
_LineChartState createState() => _LineChartState();
}
class _LineChartState extends State<LineChart> {
double xOffset;
@override
void initState() {
xOffset = widget.xOffset;
super.initState();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
xOffset += details.primaryDelta;
});
},
onHorizontalDragDown: (DragDownDetails details){
print("onHorizontalDragDown");
},
onHorizontalDragCancel: (){
print("onHorizontalDragCancel");
},
onHorizontalDragEnd: (DragEndDetails details){
print("onHorizontalDragEnd");
},
onHorizontalDragStart: (DragStartDetails details){
print("onHorizontalDragStart");
},
child: CustomPaint(
size: Size(widget.width, widget.height),
painter: LineChartWidget(
bgColor: widget.bgColor,
xyColor: widget.xyColor,
showBaseline: widget.showBaseline,
dataList: widget.dataList,
maxYValue: widget.maxYValue,
yCount: widget.yCount,
columnSpace: widget.columnSpace,
paddingLeft: widget.paddingLeft,
paddingTop: widget.paddingTop,
paddingBottom: widget.paddingBottom,
markLineLength: widget.markLineLength,
polygonalLineColor: Colors.blue,
xOffset: xOffset,
xOffsetSet: (double xOffset) {//標記4
this.xOffset = xOffset;
},
),
),
);
}
}
複製程式碼
這段程式碼最關鍵的地方在於將CustomPaint作為了GestureDetector的child,然後在onHorizontalDragUpdate方法裡,我們將xOffset記錄下來並且重新整理UI,而該引數傳給了LineChartWidget所以LineChartWidget中的xOffset也發生了改變,這樣紅色矩形裡的折線圖,通過偏移量就可以計算x軸的標籤和豎線的x座標值,然後標記4就是最上面的折線圖類在向右拖動的時候如果第一個資料已經到了紅色矩形的左邊緣,就停止滑動,在這裡將xOffset值傳遞了過來這樣改變了LineChart這裡的值。
11. 完整程式碼如下:
import 'dart:ui';
import 'package:campsite_flutter/util/collection.dart';
import 'package:flutter/material.dart';
/// 自定義折線圖
/// 作者:liuhc
class LineChartWidget extends CustomPainter {
//折線圖的背景顏色
Color bgColor;
//x軸與y軸的顏色
Color xyColor;
//是否顯示x軸與y軸的基準線
bool showBaseline;
//實際的資料
List<ChartData> dataList;
//x軸之間的間隔
double columnSpace;
//表格距離左邊的距離
int paddingLeft;
//表格距離頂部的距離
int paddingTop;
//表格距離底部的距離
int paddingBottom;
//繪製x軸、y軸、標記文字的畫筆
Paint linePaint;
//標記線的長度
int markLineLength;
//y軸資料最大值
int maxYValue;
//y軸分多少行
int yCount;
//折線的顏色
Color polygonalLineColor;
//x軸所有內容的偏移量,用來在滑動的時候改變內容的位置
double xOffset;
//該值保證最後一條資料的底部文字能正常顯示出來
int paddingRight = 30;
//內部折線圖的實際寬度
double realChartRectWidth;
Function xOffsetSet = (double xOffset) {};
LineChartWidget({
@required this.dataList,
@required this.maxYValue,
@required this.yCount,
@required this.xOffsetSet,
this.bgColor = Colors.white,
this.xyColor = Colors.black,
this.showBaseline = false,
this.columnSpace,
this.paddingLeft,
this.paddingTop,
this.paddingBottom,
this.markLineLength,
this.polygonalLineColor = Colors.blue,
this.xOffset,
}) {
linePaint = Paint()..color = xyColor;
realChartRectWidth = (dataList.length - 1) * columnSpace;
}
@override
void paint(Canvas canvas, Size size) {
//畫背景顏色
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = bgColor);
//建立一個矩形,方便後續繪製
Rect innerRect = Rect.fromPoints(
Offset(paddingLeft.toDouble(), paddingTop.toDouble()),
Offset(size.width, size.height - paddingBottom),
);
//畫y軸
canvas.drawLine(innerRect.topLeft, innerRect.bottomLeft.translate(0, markLineLength.toDouble()), linePaint);
//畫x軸
canvas.drawLine(innerRect.bottomLeft, innerRect.bottomRight, linePaint);
//畫y軸標記
double ySpace = innerRect.height / yCount;
double startX = innerRect.topLeft.dx - markLineLength;
double startY;
for (int i = 0; i < yCount + 1; i++) {
startY = innerRect.topLeft.dy + i * ySpace;
if (showBaseline) {
canvas.drawLine(
Offset(innerRect.topLeft.dx - markLineLength, startY),
Offset(innerRect.topLeft.dx + innerRect.width, startY),
linePaint,
);
} else {
canvas.drawLine(
Offset(innerRect.topLeft.dx - markLineLength, startY),
Offset(innerRect.topLeft.dx, startY),
linePaint,
);
}
drawYText(
(i * maxYValue ~/ yCount).toString(),
Offset(innerRect.topLeft.dx - markLineLength, innerRect.bottomLeft.dy - i * ySpace),
canvas,
);
}
//儲存每個實際資料的值在螢幕中的x、y座標值
List<Pair<double, double>> pointList = [];
//畫x軸標記
int xCount = dataList.length;
startY = innerRect.bottom + markLineLength;
for (int i = 0; i < xCount; i++) {
startX = innerRect.bottomLeft.dx + i * columnSpace + xOffset;
if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {
canvas.save();
canvas.clipRect(
Rect.fromLTWH(
innerRect.left,
innerRect.top,
innerRect.width,
innerRect.height,
),
);
}
//保證向右拖動的時候第一個資料保持在起始位置
if (i == 0 && startX > paddingLeft) {
startX = innerRect.bottomLeft.dx;
// 在這裡將LineChart的xOffset置為0,否則LineChart向右滑到第一個值的時候繼續向右滑動會導致xOffset累加向右拖動的值,
// 然後會導致向左拖動的時候只能等xOffset等於0的時候UI才會變化,這樣看起來就是向左拖動但是UI沒有變化
// 所以這裡加此判斷
xOffset = 0;
xOffsetSet(0.toDouble());
}
pointList.add(
Pair(
startX,
//內矩形高度減去資料實際值的實際畫素大小,再加上頂部空白的距離
innerRect.height - dataList[i].value / maxYValue * innerRect.height + paddingTop,
),
);
if (showBaseline) {
canvas.drawLine(
Offset(startX, innerRect.top),
Offset(startX, startY),
linePaint,
);
} else {
canvas.drawLine(
Offset(startX, innerRect.bottom),
Offset(startX, startY),
linePaint,
);
}
if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {
canvas.restore();
}
drawXText(
dataList[i].type,
Offset(innerRect.bottomLeft.dx + i * columnSpace + xOffset, startY),
canvas,
);
}
//畫折線
canvas.save();
canvas.clipRect(
Rect.fromLTWH(
paddingLeft.toDouble(),
paddingTop.toDouble(),
innerRect.width,
innerRect.height,
),
);
canvas.drawPoints(
///PointMode的列舉型別有三個,points(點),lines(線,隔點連線),polygon(線,相鄰連線)
PointMode.polygon,
pointList.map((pair) => Offset(pair.first, pair.last)).toList(),
Paint()
..color = polygonalLineColor
..strokeWidth = 2,
);
canvas.restore();
}
List getTextPainterAndSize(String text) {
TextPainter textPainter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: text,
style: TextStyle(color: Colors.black),
),
);
textPainter.layout();
Size size = textPainter.size;
return [textPainter, size];
}
void drawYText(String text, Offset topLeftOffset, Canvas canvas) {
List list = getTextPainterAndSize(text);
list[0].paint(canvas, topLeftOffset.translate(-list[1].width, -list[1].height / 2));
}
void drawXText(String text, Offset topLeftOffset, Canvas canvas) {
List list = getTextPainterAndSize(text);
list[0].paint(canvas, topLeftOffset.translate(-list[1].width / 2, 0));
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return oldDelegate != this;
}
}
class ChartData {
String type;
double value;
ChartData(this.type, this.value);
}
class LineChart extends StatefulWidget {
final double width;
final double height;
//柱狀圖的背景顏色
final Color bgColor;
//x軸與y軸的顏色
final Color xyColor;
//柱狀圖的顏色
final Color columnarColor;
//是否顯示x軸與y軸的基準線
final bool showBaseline;
//實際的資料
final List<ChartData> dataList;
//每列之間的間隔
final double columnSpace;
//控制元件距離左邊的距離
final int paddingLeft;
//控制元件距離頂部的距離
final int paddingTop;
//控制元件距離底部的距離
final int paddingBottom;
//標記線的長度
final int markLineLength;
//y軸最大值
final int maxYValue;
//y軸分多少行
final int yCount;
//折線的顏色
final Color polygonalLineColor;
//x軸所有內容的偏移量
final double xOffset;
LineChart(
this.width,
this.height, {
@required this.dataList,
@required this.maxYValue,
@required this.yCount,
this.bgColor = Colors.white,
this.xyColor = Colors.black,
this.columnarColor = Colors.blue,
this.showBaseline = false,
this.columnSpace = 60,
this.paddingLeft = 40,
this.paddingTop = 30,
this.paddingBottom = 30,
this.markLineLength = 10,
this.polygonalLineColor = Colors.blue,
this.xOffset = 0,
});
@override
_LineChartState createState() => _LineChartState();
}
class _LineChartState extends State<LineChart> {
double xOffset;
@override
void initState() {
xOffset = widget.xOffset;
super.initState();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
// print("DragUpdateDetails");
setState(() {
xOffset += details.primaryDelta;
});
},
onHorizontalDragDown: (DragDownDetails details){
print("onHorizontalDragDown");
},
onHorizontalDragCancel: (){
print("onHorizontalDragCancel");
},
onHorizontalDragEnd: (DragEndDetails details){
print("onHorizontalDragEnd");
},
onHorizontalDragStart: (DragStartDetails details){
print("onHorizontalDragStart");
},
child: CustomPaint(
size: Size(widget.width, widget.height),
painter: LineChartWidget(
bgColor: widget.bgColor,
xyColor: widget.xyColor,
showBaseline: widget.showBaseline,
dataList: widget.dataList,
maxYValue: widget.maxYValue,
yCount: widget.yCount,
columnSpace: widget.columnSpace,
paddingLeft: widget.paddingLeft,
paddingTop: widget.paddingTop,
paddingBottom: widget.paddingBottom,
markLineLength: widget.markLineLength,
polygonalLineColor: Colors.blue,
xOffset: xOffset,
xOffsetSet: (double xOffset) {
this.xOffset = xOffset;
},
),
),
);
}
}
void main() {
runApp(
MaterialApp(
home: Test(),
),
);
}
class Test extends StatelessWidget {
@override
Widget build(BuildContext context) {
Size size = MediaQuery.of(context).size;
return Scaffold(
appBar: AppBar(
title: Text("自定義折線圖"),
),
body: Container(
child: LineChart(
size.width,
300,
// bgColor: Colors.red,
xOffset: 10,
showBaseline: true,
maxYValue: 600,
yCount: 6,
dataList: [
ChartData("Data A", 100),
ChartData("Data B", 300),
ChartData("Data C", 200),
ChartData("Data D", 500),
ChartData("Data E", 450),
ChartData("Data F", 230),
ChartData("Data G", 270),
ChartData("Data H", 170),
],
),
),
);
}
}
複製程式碼