背景
flutter 三方图表库大多数属性无法自定义,难以满足产品千奇百怪的需求,那我们就自己画一个。
图表定义
定义图表组件与基础参数
dart
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类,如需实现新的图表样式,只需继承自该类并重新实现方法。
dart
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
ini
@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);
}
}
实战快速接入
画一个柱状图
- 定义数据
php
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轴
scss
// 绘制Y轴
customY1Axis?.draw(canvas, xAxisScale, YAxisPosition.left);
customY2Axis?.draw(canvas, xAxisScale, YAxisPosition.right);
- 标题
- X轴
scss
if (customXAxis != null) {
customXAxis?.drawBackground(canvas, xAxisList, xAxisScale);
customXAxis?.drawXAxis(canvas, xAxisList, xAxisScale, offset);
}
- 数据
scss
// 绘制数据
series.draw(
canvas,
xAxisList,
xAxisScale,
yAxis,
offset,
);
那其实画一个柱状图的关键我们已经知道了,传入的Y轴/X轴的draw方法是如何重写的?图表数据点的y(x)是如何绘制的? 再来看 BaseXAxis 的 draw方法
ini
/// 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的绘制方法
ini
@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,
);
}
}
曲线图
php
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(),
);
散点图
php
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(),
),