Flutter自绘图表库技术方案(一):基础
站长
· 阅读数 4
背景
flutter 三方图表库大多数属性无法自定义,难以满足产品千奇百怪的需求,那我们就自己画一个。
图表定义
定义图表组件与基础参数
class CustomChart {
/// 图标组件的高度
final double chartHeight;
/// 期望的图表每个Item间隔宽度
final double? preferDividerWidth;
/// 期望的图表每个Item内容宽度
final double? preferContentWidth;
/// X轴列表,类型为List<T>
final List<T> xAxisList;
/// 图表系列列表,类型为List<BaseSeries<T>>
final List<BaseSeries<T>> seriesList;
/// 自定义X轴样式
final BaseXAxis<T>? customXAxis;
/// 自定义标题样式
final BaseTitle? customTitle;
/// 自定义左侧Y轴样式
final BaseYAxis? customY1Axis;
/// 自定义右侧Y轴样式
final BaseYAxis? customY2Axis;
/// 图表POP是否展示标题
final bool showIndicatorTitle;
/// 图表POP是否展示圆点
final bool showIndicatorPoint;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(color: backgroundColor),
child: FocusableGestureDetector(
isClickable: isClickable,
builder: (offset) => LayoutBuilder(
builder: (context, constraints) => CustomSingleChildLayout(
delegate: ChartBoxLayoutDelegate(size: Size(constraints.maxWidth, chartHeight)),
child: CustomPaint(
painter: ChartBoxPainter<T>(
offset: offset,
customContent: preferContentWidth,
xdcustomDivider: preferDividerWidth,
xAxisList: xAxisList,
seriesList: seriesList,
customXAxis: customXAxis,
customTitle: customTitle,
customY1Axis: customY1Axis,
customY2Axis: customY2Axis,
showIndicatorTitle: showIndicatorTitle,
showIndicatorPoint: showIndicatorPoint,
onRenderFinish: onRenderFinish,
),
),
),
),
),
);
}
}
图表样式(系列)
各种图表样式继承自BaseSeries类,如需实现新的图表样式,只需继承自该类并重新实现方法。
abstract class BaseSeries<T> {
/// 数值参考的Y轴,分别为Y1轴(left)或Y2轴(right)
final YAxisPosition yAxisPosition;
/// 系列数据
List<BaseSeriesItem<T>> get data;
/// 如果需要弹出POP指示器,需传入此参数作为该系列的标题
final ChartIndicatorTitle? indicatorTitle;
/// POP指示器标题的兜底值,一般为'<empty>'
final String name;
/// 绘制该系列的画笔采用的颜色值
final Color color;
BaseSeries({
this.yAxisPosition = YAxisPosition.left,
required this.color,
required this.name,
this.indicatorTitle,
});
/// 通过X轴的某一项获取对应的数据项
BaseSeriesItem<T>? itemAt(T title);
/// 通过X轴的某一项获取对应的数据项列表(散点图可能会包含多个值,所以返回列表)
List<BaseSeriesItem<T>> itemsAt(T title);
/// 获取该系列的数据在Y轴的范围,可以帮助确定Y轴的刻度
ValueRange bounds(List<T> xAxisList);
/// 绘制该系列的图表内容
draw(
Canvas canvas,
List<T> xAxisList,
ChartScale scale,
BaseYAxis yAxis,
Offset? offset,
);
/// 点击该系列的图表内容时,绘制在图表上添加阴影或高亮的效果,每个系列会重写此方法来自定义效果
void drawSelection(Canvas canvas, List<T> xAxisList, ChartScale scale, BaseYAxis yAxis, Offset? offset);
}
/// 图表系列数据项
abstract class BaseSeriesItem<T> {
/// X轴的标题
final T title;
/// Y轴的值
final double value;
final List<ChartIndicator> indicators;
final ChartIndicator? indicator;
BaseSeriesItem({required this.title, required this.value, this.indicators = const [], this.indicator});
}
ChartBoxPainter
@override
void paint(Canvas canvas, Size size) {
final startTime = DateTime.now().microsecondsSinceEpoch;
final containerRect = Rect.fromLTWH(0, 0, size.width, size.height);
final contentRect = Rect.fromLTRB(
customY1Axis?.width ?? 10,
customTitle?.height ?? 10,
size.width - (customY2Axis?.width ?? 10),
size.height - (customXAxis?.height ?? 0),
);
final xAxisScale = _getXAxisScale(containerRect, contentRect);
// 绘制Y轴
customY1Axis?.draw(canvas, xAxisScale, YAxisPosition.left);
customY2Axis?.draw(canvas, xAxisScale, YAxisPosition.right);
// 绘制标题
customTitle?.draw(canvas, xAxisScale);
// 绘制X轴
if (customXAxis != null) {
customXAxis?.drawBackground(canvas, xAxisList, xAxisScale);
customXAxis?.drawXAxis(canvas, xAxisList, xAxisScale, offset);
}
for (var series in seriesList) {
final yAxis = currentYAxis(series);
if (yAxis == null) {
continue;
}
// 绘制数据
series.draw(
canvas,
xAxisList,
xAxisScale,
yAxis,
offset,
);
// 绘制选中的部分的阴影
series.drawSelection(
canvas,
xAxisList,
xAxisScale,
yAxis,
offset,
);
}
// 绘制选中的点的信息
customXAxis?.drawIndicator(
canvas,
xAxisList,
seriesList,
xAxisScale,
offset,
showTitle: showIndicatorTitle,
showPoint: showIndicatorPoint,
);
final endTime = DateTime.now().microsecondsSinceEpoch;
if (offset == null) {
onRenderFinish?.call(endTime - startTime);
}
}
实战快速接入
画一个柱状图
- 定义数据
final xAxisList = <String>["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
final data = <BarSeriesItem<String>>[
BarSeriesItem(title: "1", value: 21),
BarSeriesItem(title: "2", value: 25),
BarSeriesItem(title: "3", value: 20),
BarSeriesItem(title: "4", value: 14),
BarSeriesItem(title: "5", value: 15),
BarSeriesItem(title: "6", value: 16),
BarSeriesItem(title: "7", value: 37),
BarSeriesItem(title: "8", value: 18),
BarSeriesItem(title: "9", value: 39),
BarSeriesItem(title: "10", value: 10),
BarSeriesItem(title: "11", value: 41),
BarSeriesItem(title: "12", value: 12),
];
final barSeries = BarSeries<String>(data: data, color: const Color(0x7FE5E5EB), name: "柱状图");
return CustomChart<String>(
maxYAxis: 100,
xAxisList: xAxisList,
seriesList: [
barSeries,
],
customXAxis: BaseXAxis(),
)
-
绘制 根据绘制流程如下
-
Y轴
// 绘制Y轴
customY1Axis?.draw(canvas, xAxisScale, YAxisPosition.left);
customY2Axis?.draw(canvas, xAxisScale, YAxisPosition.right);
- 标题
- X轴
if (customXAxis != null) {
customXAxis?.drawBackground(canvas, xAxisList, xAxisScale);
customXAxis?.drawXAxis(canvas, xAxisList, xAxisScale, offset);
}
- 数据
// 绘制数据
series.draw(
canvas,
xAxisList,
xAxisScale,
yAxis,
offset,
);
那其实画一个柱状图的关键我们已经知道了,传入的Y轴/X轴的draw方法是如何重写的?图表数据点的y(x)是如何绘制的? 再来看 BaseXAxis 的 draw方法
/// X轴数据,可以根据数据绘制X轴
drawXAxis(Canvas canvas, List<T> xAxisList, ChartScale scale, Offset? offset) {
final content = scale.xRect;
if (xAxisList.isEmpty) {
return;
}
canvas.drawLine(
Offset(content.left, content.top),
Offset(content.right, content.top),
Paint()
..color = const Color(0xFFDADBDB)
..strokeWidth = 0.5
..style = PaintingStyle.stroke,
);
for (int i = 0; i < xAxisList.length; i++) {
final xAxis = xAxisList[i];
final double x = content.left + i * scale.single + scale.content / 2;
if (xAxis is ui.Image) {
final image = xAxis;
Rect src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
Rect dst = Rect.fromLTWH(x - imageSize / 2, content.top + (content.height - imageSize) / 2, imageSize, imageSize);
canvas.drawImageRect(image, src, dst, Paint());
} else {
final textPainter = TextPainter(
text: TextSpan(text: ChartUtils.getAxisTitle(xAxis), style: const TextStyle(color: Color(0x80000022), fontSize: 12)),
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(canvas, Offset(x - textPainter.width / 2, content.top + 6));
}
}
}
同理 基础的y轴也是画一条线 重点在于BarSeries的绘制方法
@override
draw(ui.Canvas canvas, List<T> xAxisList, ChartScale scale, BaseYAxis yAxis, ui.Offset? offset) {
if (xAxisList.isEmpty) {
return;
}
final points = ChartUtils.getPoints(xAxisList, data, defaultYAxis);
if (points.isEmpty) {
return;
}
final ui.Rect content = scale.contentRect;
// 绘制柱状图
for (var i = 0; i < points.length; i++) {
final offset = ChartUtils.getOffset(scale, yAxis, i, points[i]);
ui.RRect rect = ui.RRect.fromLTRBAndCorners(
offset.dx - scale.content / 2,
offset.dy,
offset.dx + scale.content / 2,
content.bottom,
topLeft: barRadius.topLeft,
topRight: barRadius.topRight,
bottomLeft: barRadius.bottomLeft,
bottomRight: barRadius.bottomRight,
);
canvas.drawRRect(
rect,
ui.Paint()
..color = data[i].color ?? color
..style = ui.PaintingStyle.fill);
}
if (isShowTrendLine) {
const trendColor = ui.Color(0xFF2D76FF);
final offsets = points.mapIndexed((index, value) => ChartUtils.getOffset(scale, yAxis, index, value)).toList();
final linePath = getTrendLine(offsets, scale, yAxis);
canvas.drawPath(
getTrendLine(offsets, scale, yAxis),
ui.Paint()
..color = trendColor
..strokeWidth = 2
..style = ui.PaintingStyle.stroke
..strokeCap = ui.StrokeCap.round,
);
final shader = ui.Gradient.linear(ui.Offset(content.topCenter.dx, offsets.map((e) => e.dy).reduce(min)), content.bottomCenter,
[trendColor.withAlpha(0x26), trendColor.withAlpha(0x00)]);
final areaPath = linePath
..lineTo(offsets.last.dx, content.bottom)
..lineTo(offsets.first.dx, content.bottom)
..close();
canvas.drawPath(
areaPath,
ui.Paint()
..color = trendColor
..strokeWidth = 2
..strokeCap = ui.StrokeCap.round
..shader = shader,
);
}
}
曲线图
final xAxisList = <String>["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
final data = <LineSeriesItem<String>>[
LineSeriesItem(title: "1", value: 21),
LineSeriesItem(title: "2", value: 25),
LineSeriesItem(title: "3", value: 20),
LineSeriesItem(title: "4", value: 14),
LineSeriesItem(title: "5", value: 15),
LineSeriesItem(title: "6", value: 16),
LineSeriesItem(title: "7", value: 37),
LineSeriesItem(title: "8", value: 18),
LineSeriesItem(title: "9", value: 39),
LineSeriesItem(title: "10", value: 10),
LineSeriesItem(title: "11", value: 41),
LineSeriesItem(title: "12", value: 12),
];
final lineSeries = LineSeries<String>(data: data, color: const Color(0xFF2D76FF), defaultYAxis: null, name: "曲线图");
return WhaleChart<String>(
maxYAxis: 100,
xAxisList: xAxisList,
seriesList: [
lineSeries,
],
customXAxis: AutoStepXAxis(),
);
// #LineSeries
@override
draw(Canvas canvas,
List<T> xAxisList,
ChartScale scale,
BaseYAxis yAxis,
Offset? offset,) {
final pointsRange = ChartUtils.getPointsRange(xAxisList, data, defaultYAxis);
if (pointsRange.isEmpty) {
return;
}
final content = scale.contentRect;
final points = pointsRange.points;
final startIndex = pointsRange.startIndex;
final endIndex = pointsRange.endIndex;
List<Offset> results = [];
for (var i = 0; i < points.length; i++) {
results.add(ChartUtils.getOffset(scale, yAxis, i, points[i]));
}
final breakPoint = this.breakPoint;
final breakColor = this.breakColor ?? color.withAlpha(0x5A);
if (breakPoint != null && breakPoint >= startIndex && breakPoint <= endIndex) {
final breakPath = LinearLine.initPath(results, startIndex, endIndex);
final normalPath = LinearLine.initPath(results, startIndex, endIndex, breakIndex: breakPoint);
if (isDashLine == true) {
_drawDashedPath(
canvas,
breakPath,
4,
4,
Paint()
..color = breakColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.butt);
_drawDashedPath(
canvas,
normalPath,
4,
4,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.butt);
} else {
canvas.drawPath(
breakPath,
Paint()
..color = breakColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);
canvas.drawPath(
normalPath,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);
}
final shader = ui.Gradient.linear(
Offset(content.topCenter.dx, results.map((e) => e.dy).reduce(min)),
content.bottomCenter,
[color.withAlpha(0x26), color.withAlpha(0x00)],
);
final areaBreakPath = breakPath
..lineTo(results[endIndex].dx, content.bottom)..lineTo(results[startIndex].dx, content.bottom)
..close();
canvas.drawPath(
areaBreakPath,
Paint()
..color = color
..strokeWidth = 2
..strokeCap = StrokeCap.round
..shader = shader,
);
} else {
final path = LinearLine.initPath(results, startIndex, endIndex);
if (isDashLine == true) {
_drawDashedPath(
canvas,
path,
2,
2,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round);
} else {
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);
}
final shader = ui.Gradient.linear(
Offset(content.topCenter.dx, results.map((e) => e.dy).reduce(min)),
content.bottomCenter,
[color.withAlpha(0x26), color.withAlpha(0x00)],
);
final areaPath = path
..lineTo(results[endIndex].dx, content.bottom)..lineTo(results[startIndex].dx, content.bottom)
..close();
canvas.drawPath(
areaPath,
Paint()
..color = color
..strokeWidth = 2
..strokeCap = StrokeCap.round
..shader = shader,
);
}
if (isShowCircleNode == true) {
if (isShowTotalCircleNode == true) {
var circleIndex = ChartUtils.getDrawCircleIndex(xAxisList, scale, yAxisIsHide);
drawCircleNode(
startIndex,
endIndex,
scale,
yAxis,
points,
circleIndex,
canvas,
breakPoint,
breakColor,
offset,
);
}
if (needMaxAndMinCircleNode) {
var circleIndex = <int>[];
int maxValueIndex = 0;
int minValueIndex = 0;
double maxValue = 0;
double minValue = double.infinity;
// 跳过开头为0的数据
int startIndex = data.indexWhere((item) => item.value != 0);
if (startIndex != -1) {
for (int i = startIndex; i < data.length; i++) {
double curValue = data[i].value;
if (curValue > maxValue) {
maxValue = curValue;
maxValueIndex = i;
}
if (curValue < minValue) {
minValue = curValue;
minValueIndex = i;
}
}
circleIndex.add(maxValueIndex);
circleIndex.add(minValueIndex);
drawCircleNode(
startIndex,
endIndex,
scale,
yAxis,
points,
circleIndex,
canvas,
breakPoint,
breakColor,
offset,
);
}
}
drawSelectedCircleNode(
startIndex,
endIndex,
scale,
yAxis,
points,
canvas,
breakPoint,
breakColor,
offset,
);
}
if (maxAndMinValue?.isNotEmpty == true) {
drawMaxAndMinNode(canvas, scale, yAxis);
}
}
void _drawDashedPath(Canvas canvas,
Path path,
double a,
double b,
Paint paint,) {
final totalLength = path.computeMetrics().fold(0.0, (double prev, PathMetric metric) => prev + metric.length);
final double dashLength = a + b;
final double dashCount = (totalLength / dashLength).ceil().toDouble();
var start = 0.0;
for (var i = 0; i < dashCount; ++i) {
final metrics = path.computeMetrics(forceClosed: false);
final extract = metrics.elementAt(0).extractPath(start, start + a);
canvas.drawPath(extract, paint);
start += dashLength;
}
}
/// 画圆形节点的方法
///
/// [startIndex] 和 [endIndex] 分别表示要绘制的圆形节点在数据集中的开始和结束索引。
/// [scale] 为图表刻度尺,提供坐标转换功能。
/// [yAxis] 为图表的Y轴。
/// [points] 是一个包含数据点的列表。
/// [circleIndex] 是一个包含需要绘制圆形节点的数据点索引的列表。
/// [canvas] 是用于绘制图形的画布对象。
/// [breakPoint] 是一个可选参数,表示颜色改变的临界点,默认为100。
/// [breakColor] 是一个可选参数,表示临界点之后使用的颜色。
/// [offset] 是一个可选参数,表示当前点击的坐标位置。
///
/// 该方法遍历数据集,并根据 [circleIndex] 绘制指定的圆形节点。
/// 如果提供了 [breakPoint],则根据索引值绘制不同颜色的圆形节点。
/// 如果提供了 [offset],则绘制当前点击位置对应的圆形节点。
void drawCircleNode(int startIndex,
int endIndex,
ChartScale scale,
BaseYAxis yAxis,
List<double> points,
List<int> circleIndex,
ui.Canvas canvas,
int? breakPoint,
ui.Color breakColor,
ui.Offset? offset,) {
for (var i = startIndex; i >= 0 && i <= endIndex; i++) {
final offset = ChartUtils.getOffset(scale, yAxis, i, points[i]);
if (circleIndex.contains(i) == true) {
canvas.drawCircle(
offset,
2,
Paint()
..color = Colors.white
..style = PaintingStyle.fill);
canvas.drawCircle(
offset,
2,
Paint()
..color = (breakPoint ?? 100) >= i ? color : breakColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round);
}
}
}
/// 绘制选中状态下数据圆形节点
void drawSelectedCircleNode(int startIndex,
int endIndex,
ChartScale scale,
BaseYAxis yAxis,
List<double> points,
ui.Canvas canvas,
int? breakPoint,
ui.Color breakColor,
ui.Offset? offset,) {
final currentIndex = scale.getIndex(offset: offset) ?? defaultIndex;
if (currentIndex != null && currentIndex >= startIndex && currentIndex <= endIndex && currentIndex >= 0 && currentIndex < points.length) {
final offset = ChartUtils.getOffset(scale, yAxis, currentIndex, points[currentIndex]);
canvas.drawCircle(
offset,
3,
Paint()
..color = Colors.white
..style = PaintingStyle.fill);
canvas.drawCircle(
offset,
3,
Paint()
..color = (breakPoint ?? 100) >= currentIndex ? color : breakColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round);
}
}
@override
void drawSelection(Canvas canvas,
List<T> xAxisList,
ChartScale scale,
BaseYAxis yAxis,
Offset? offset,) {}
void drawMaxAndMinNode(Canvas canvas,
ChartScale scale,
BaseYAxis yAxis,) {
final maxAndMinValue = this.maxAndMinValue;
if (maxAndMinValue != null) {
for (int i = 0; i < maxAndMinValue.length; i++) {
var item = maxAndMinValue[i];
if (item != null) {
var offset = ChartUtils.getOffset(scale, yAxis, i, item.item2);
canvas.drawCircle(
offset,
3,
Paint()
..color = item.item3 == true ? const Color(0XFF2D76FF) : const Color(0XFFA0BDF4)
..style = PaintingStyle.fill);
}
}
}
}
散点图
final xAxisList = <String>["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
final data = <PointSeriesItem<String>>[
PointSeriesItem(title: "1", value: 21),
PointSeriesItem(title: "2", value: 25),
PointSeriesItem(title: "3", value: 20),
PointSeriesItem(title: "4", value: 14),
PointSeriesItem(title: "5", value: 15),
PointSeriesItem(title: "6", value: 16),
PointSeriesItem(title: "7", value: 37),
PointSeriesItem(title: "8", value: 18),
PointSeriesItem(title: "9", value: 39),
PointSeriesItem(title: "10", value: 10),
PointSeriesItem(title: "11", value: 41),
PointSeriesItem(title: "12", value: 12),
];
final pointSeries = PointSeries<String>(data: data, color: const Color(0x7F2D76FF), pointType: PointType.diamond, name: "散点图");
return WhaleChart<String>(
maxYAxis: 100,
xAxisList: xAxisList,
seriesList: [
pointSeries,
],
customXAxis: AutoStepXAxis(),
),
ps :后续将更新进阶内容,自定义图表样式