news 2026/5/31 17:52:48

Flutter---折线图(自己绘制)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter---折线图(自己绘制)

效果图

代码实现步骤

1.定义文本控制器和焦点控制器

2.定义动画

3.定义默认的数据点

4.定义图标的相应配置

5.定义坐标范围

6.初始化操作

7.销毁操作

8.取消焦点的方法

9.添加数据点的方法

10.删除最后一个数据点的方法

11.清除所有数据点的方法

12.生成随机数据的方法

13.UI架构(标题栏,输入点区域,控制按钮区域,图表标题,自定义折线图,数据统计)

14.定义底部栏的子项

15.自定义绘制折线图的类

认识自定义折线图的类

数据流

数据源 (points) ↓ 坐标转换 (_convertToCanvas) ↓ 动画处理 (animationValue) ↓ 分层绘制 (网格→坐标轴→阴影→折线→数据点) ↓ 画布输出 (Canvas)

坐标

画布坐标系 (Canvas Coordinate System) 原点(0,0) ──────→ X轴正方向 │ │ ↓ Y轴正方向 数据坐标系 (Data Coordinate System) 原点(minX,minY) ───→ X轴正方向 │ ↑ Y轴正方向 │ 需要转换:数据Y轴向上,画布Y轴向下

核心代码--自定义的绘制类

class _LineChartPainter extends CustomPainter { final List<Offset> points; final double minX; final double maxX; final double minY; final double maxY; final double padding; final Color axisColor; final double axisWidth; final Color lineColor; final double lineWidth; final Color pointColor; final double pointRadius; final int xGridLines; final int yGridLines; final double animationValue; //构造函数 _LineChartPainter({ required this.points, // 数据点列表 required this.minX, // X轴最小值 required this.maxX, // X轴最大值 required this.minY, // Y轴最小值 required this.maxY, // Y轴最大值 required this.padding, // 图表内边距 required this.axisColor, // 坐标轴颜色 required this.axisWidth, // 坐标轴宽度 required this.lineColor, // 折线颜色 required this.lineWidth, // 折线宽度 required this.pointColor, // 数据点颜色 required this.pointRadius, // 数据点半径 required this.xGridLines, // X轴网格线数量 required this.yGridLines, // Y轴网格线数量 required this.animationValue, // 动画进度值(0-1) }); @override void paint(Canvas canvas, Size size) { //网格线画笔 final Paint gridPaint = Paint() ..color = Colors.grey[200]! ..strokeWidth = 0.5 ..style = PaintingStyle.stroke; //坐标轴画笔 final Paint axisPaint = Paint() ..color = axisColor ..strokeWidth = axisWidth ..style = PaintingStyle.stroke; //只描边 //折线画笔 final Paint linePaint = Paint() ..color = lineColor.withOpacity(0.8) ..strokeWidth = lineWidth ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round;//线头圆角 //数据点画笔 final Paint pointPaint = Paint() ..color = pointColor ..style = PaintingStyle.fill; //实心填充 //阴影区域画笔 final Paint shadowPaint = Paint() ..color = lineColor.withOpacity(0.1) ..style = PaintingStyle.fill; // 绘制背景 final Rect chartArea = Rect.fromLTWH( padding, //左 padding, //上 size.width - 2 * padding, //宽度 size.height - 2 * padding, //高度 ); // 绘制网格 _drawGrid(canvas, size, gridPaint); // 绘制坐标轴 _drawAxes(canvas, size, axisPaint); // 绘制坐标轴标签 _drawAxisLabels(canvas, size); // 如果有数据点,绘制阴影区域和折线 if (points.length > 1) { // 转换所有点为画布坐标 final List<Offset> canvasPoints = points.map((point) { return _convertToCanvas(point, size); }).toList(); // 根据动画值计算要绘制的点数 final int visiblePoints = (points.length * animationValue).ceil(); final List<Offset> visibleCanvasPoints = canvasPoints.sublist(0, visiblePoints.clamp(0, canvasPoints.length)); // 绘制阴影区域 if (visibleCanvasPoints.length > 1) { final Path shadowPath = Path(); shadowPath.moveTo(visibleCanvasPoints.first.dx, chartArea.bottom); for (final point in visibleCanvasPoints) { shadowPath.lineTo(point.dx, point.dy); } shadowPath.lineTo(visibleCanvasPoints.last.dx, chartArea.bottom); shadowPath.close(); canvas.drawPath(shadowPath, shadowPaint); } // 绘制折线(带动画) if (visibleCanvasPoints.length > 1) { final Path linePath = Path(); linePath.moveTo(visibleCanvasPoints.first.dx, visibleCanvasPoints.first.dy); for (int i = 1; i < visibleCanvasPoints.length; i++) { final current = visibleCanvasPoints[i]; final previous = visibleCanvasPoints[i - 1]; // 使用贝塞尔曲线使线条更平滑 final controlPoint1 = Offset( previous.dx + (current.dx - previous.dx) / 2, previous.dy, ); final controlPoint2 = Offset( current.dx - (current.dx - previous.dx) / 2, current.dy, ); linePath.cubicTo( controlPoint1.dx, controlPoint1.dy, controlPoint2.dx, controlPoint2.dy, current.dx, current.dy, ); } canvas.drawPath(linePath, linePaint); } // 绘制数据点(带缩放动画) for (int i = 0; i < visibleCanvasPoints.length; i++) { final point = visibleCanvasPoints[i]; final double pointAnimation = min(1.0, animationValue * 2 - i * 0.1); if (pointAnimation > 0) { // 绘制点 canvas.drawCircle( point, pointRadius * pointAnimation, pointPaint, ); // 绘制点的光晕效果 canvas.drawCircle( point, pointRadius * 1.5 * pointAnimation, Paint() ..color = pointColor.withOpacity(0.2 * pointAnimation) ..style = PaintingStyle.fill, ); // 绘制点的标签 final textPainter = TextPainter( text: TextSpan( text: "(${points[i].dx.toInt()}, ${points[i].dy.toStringAsFixed(1)})", style: const TextStyle( color: Colors.black87, fontSize: 10, fontWeight: FontWeight.w500, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(point.dx - textPainter.width / 2, point.dy - 20), ); } } } else if (points.length == 1) { // 只有一个点时 final Offset canvasPoint = _convertToCanvas(points.first, size); canvas.drawCircle(canvasPoint, pointRadius, pointPaint); } else { // 没有数据点时显示提示 final textPainter = TextPainter( text: const TextSpan( text: "暂无数据,请添加数据点", style: TextStyle( color: Colors.grey, fontSize: 14, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset( size.width / 2 - textPainter.width / 2, size.height / 2 - textPainter.height / 2, ), ); } } // 绘制网格 void _drawGrid(Canvas canvas, Size size, Paint paint) { final double chartWidth = size.width - 2 * padding; final double chartHeight = size.height - 2 * padding; // 水平网格线 for (int i = 0; i <= yGridLines; i++) { final double y = padding + (chartHeight / yGridLines) * i; canvas.drawLine( Offset(padding, y), Offset(size.width - padding, y), paint, ); } // 垂直网格线 for (int i = 0; i <= xGridLines; i++) { final double x = padding + (chartWidth / xGridLines) * i; canvas.drawLine( Offset(x, padding), Offset(x, size.height - padding), paint, ); } } // 绘制坐标轴 void _drawAxes(Canvas canvas, Size size, Paint paint) { // X轴 canvas.drawLine( Offset(padding, size.height - padding), Offset(size.width - padding, size.height - padding), paint, ); // Y轴 canvas.drawLine( Offset(padding, padding), Offset(padding, size.height - padding), paint, ); // X轴箭头 canvas.drawLine( Offset(size.width - padding, size.height - padding), Offset(size.width - padding - 8, size.height - padding - 4), paint, ); canvas.drawLine( Offset(size.width - padding, size.height - padding), Offset(size.width - padding - 8, size.height - padding + 4), paint, ); // Y轴箭头 canvas.drawLine( Offset(padding, padding), Offset(padding - 4, padding + 8), paint, ); canvas.drawLine( Offset(padding, padding), Offset(padding + 4, padding + 8), paint, ); } // 绘制坐标轴标签 void _drawAxisLabels(Canvas canvas, Size size) { final double chartWidth = size.width - 2 * padding; final double chartHeight = size.height - 2 * padding; // X轴标签 for (int i = 0; i <= xGridLines; i++) { final double xValue = minX + (maxX - minX) / xGridLines * i; final double x = padding + (chartWidth / xGridLines) * i; final textPainter = TextPainter( text: TextSpan( text: xValue.toStringAsFixed(1), style: const TextStyle( color: Colors.black87, fontSize: 10, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(x - textPainter.width / 2, size.height - padding + 5), ); } // Y轴标签 for (int i = 0; i <= yGridLines; i++) { final double yValue = maxY - (maxY - minY) / yGridLines * i; final double y = padding + (chartHeight / yGridLines) * i; final textPainter = TextPainter( text: TextSpan( text: yValue.toStringAsFixed(1), style: const TextStyle( color: Colors.black87, fontSize: 10, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(padding - textPainter.width - 10, y - textPainter.height / 2), ); } // 坐标轴标题 final textX = TextPainter( text: const TextSpan( text: "X轴", style: TextStyle( color: Colors.black87, fontSize: 12, fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, )..layout(); final textY = TextPainter( text: const TextSpan( text: "Y轴", style: TextStyle( color: Colors.black87, fontSize: 12, fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, )..layout(); textX.paint( canvas, Offset(size.width - padding - textX.width / 2, size.height - padding + 20), ); textY.paint( canvas, Offset(padding - 30, padding - textY.height / 2 -20), ); } // 坐标转换 Offset _convertToCanvas(Offset point, Size size) { // 线性映射:数据坐标 → 画布坐标 // 公式:画布坐标 = 起点 + (数据值 - 最小值) / 范围 × 可用空间 final double width = size.width - 2 * padding; final double height = size.height - 2 * padding; final double x = padding + (point.dx - minX) / (maxX - minX) * width; final double y = size.height - padding - (point.dy - minY) / (maxY - minY) * height; // 注意Y轴需要翻转:画布原点在左上角,数学原点在左下角 return Offset(x, y); } @override bool shouldRepaint(_LineChartPainter oldDelegate) { return oldDelegate.points != points || //数据变化时重绘 oldDelegate.animationValue != animationValue; //动画变化时重绘 } }

代码实例

import 'dart:math'; import 'package:flutter/material.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin { //文本控制器 final TextEditingController _xController = TextEditingController(); final TextEditingController _yController = TextEditingController(); final FocusNode _xFocusNode = FocusNode(); final FocusNode _yFocusNode = FocusNode(); // 动画 late AnimationController _animationController; //动画控制器 late Animation<double> _animation; //创建动画值 // 数据点 List<Offset> points = [ const Offset(0, 1), const Offset(1, 2), const Offset(2, 3), const Offset(3, 1.5), const Offset(4, 4), const Offset(5, 2.5), const Offset(6, 3.5), const Offset(7, 2), const Offset(8, 5), const Offset(9, 3), const Offset(10, 4), ]; // 图表配置 final double padding = 40.0; //图标内边距 final double axisWidth = 1.5; //坐标轴线条粗细 final Color axisColor = Colors.grey; //坐标轴颜色 final Color lineColor = Colors.blue; //折线颜色 final Color pointColor = Colors.blueAccent; //数据点颜色 final double lineWidth = 2.5; //折线粗细 final double pointRadius = 4.0; //数据点半径 final int xGridLines = 10; //x轴方向网格线数量 final int yGridLines = 10; //y轴方向网格线数量 // 坐标范围 final double minX = 0; final double maxX = 10; final double minY = 0; final double maxY = 6; @override void initState() { super.initState(); // 初始化动画 _animationController = AnimationController( duration: const Duration(milliseconds: 1500), //动画时长1.5秒 vsync: this, ); _animation = CurvedAnimation( parent: _animationController, curve: Curves.easeInOut, //动画效果 ); // 开始动画 _animationController.forward(); } @override void dispose() { _animationController.dispose(); _xController.dispose(); _yController.dispose(); _xFocusNode.dispose(); _yFocusNode.dispose(); super.dispose(); } //===============================取消焦点的方法================================= void _unfocusAll(){ _xFocusNode.unfocus(); _yFocusNode.unfocus(); } //==============================添加数据点的方法================================== void _addPoint() { final double? x = double.tryParse(_xController.text); final double? y = double.tryParse(_yController.text); if (x != null && y != null && x >= minX && x <= maxX && y >= minY && y <= maxY) { setState(() { //添加新点 points.add(Offset(x, y)); // 按x坐标排序 points.sort((a, b) => a.dx.compareTo(b.dx)); }); // 重置输入和动画 _xController.clear(); _yController.clear(); _animationController.reset(); _animationController.forward(); _unfocusAll(); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("请输入 ${minX}-${maxX} 的X和 ${minY}-${maxY} 的Y"), backgroundColor: Colors.red, ), ); _unfocusAll(); } } //=================================删除最后一个点=================================== void _removeLastPoint() { if (points.isNotEmpty) { setState(() { points.removeLast(); }); _animationController.reset(); //重置动画到开始状态 _animationController.forward();//启动动画 } } //=======================================清除所有点========================= void _clearAll() { setState(() { points.clear(); }); _animationController.reset(); //重置动画到开始状态 _animationController.forward();//启动动画 _unfocusAll(); } //===================================生成随机数据============================= void _generateRandomData() { setState(() { points.clear();//清空已有数据点 final Random random = Random(); //创建随机数生成器 //循环11次,生成11个点 for (int i = 0; i <= 10; i++) { final double x = i.toDouble(); //x轴坐标 final double y = random.nextDouble() * (maxY - minY) + minY; //随机y坐标 points.add(Offset(x, y)); //将点添加到列表 } //按x坐标升序排序 points.sort((a, b) => a.dx.compareTo(b.dx)); }); _animationController.reset(); //重置动画到开始状态 _animationController.forward();//启动动画 _unfocusAll(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("自定义动画折线图"), backgroundColor: Colors.blue, foregroundColor: Colors.white, ), body: Column( children: [ // 输入区域 Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ Expanded( child: TextField( controller: _xController, focusNode: _xFocusNode, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: "X坐标", border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: _yController, focusNode: _yFocusNode, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: "Y坐标", border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), const SizedBox(width: 12), ElevatedButton.icon( onPressed: _addPoint, label: const Text("添加"), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15), ), ), ], ), ), // 控制按钮区域 Padding( padding: const EdgeInsets.symmetric(horizontal: 5.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ OutlinedButton.icon( onPressed: _removeLastPoint, icon: const Icon(Icons.remove), label: const Text("删除最后的点",style: TextStyle(fontSize: 12),), ), OutlinedButton.icon( onPressed: _clearAll, icon: const Icon(Icons.delete), label: const Text("清空",style: TextStyle(fontSize: 12),), style: OutlinedButton.styleFrom( foregroundColor: Colors.red, ), ), ElevatedButton.icon( onPressed: _generateRandomData, icon: const Icon(Icons.shuffle), label: const Text("随机数据",style: TextStyle(fontSize: 12),), style: ElevatedButton.styleFrom( backgroundColor: Colors.green, foregroundColor: Colors.white, ), ), ], ), ), const SizedBox(height: 20), // 图表标题 Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "折线图", style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), //动态显示动画进度的文本 AnimatedBuilder( animation: _animationController, //监听的动画控制器 builder: (context, child) { //构建函数,当动画值变化时调用 return Text( //返回要显示的组件 "动画进度: ${(_animation.value * 100).toStringAsFixed(0)}%", //_animation.value:获取当前动画值 style: TextStyle( color: Colors.blue, fontSize: 14, fontWeight: FontWeight.w500, ), ); }, ), ], ), ), const SizedBox(height: 10), // 自定义折线图 Expanded( child: Padding( padding: const EdgeInsets.all(16.0), child: Container( decoration: BoxDecoration( color: Colors.grey[50], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[300]!), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.2), blurRadius: 8, offset: const Offset(0, 4), ), ], ), child: LayoutBuilder( builder: (context, constraints) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return CustomPaint( painter: _LineChartPainter( //自定义绘制器 //传递各种参数 points: points, minX: minX, maxX: maxX, minY: minY, maxY: maxY, padding: padding, axisColor: axisColor, axisWidth: axisWidth, lineColor: lineColor, lineWidth: lineWidth, pointColor: pointColor, pointRadius: pointRadius, xGridLines: xGridLines, yGridLines: yGridLines, animationValue: _animation.value, ), size: Size(constraints.maxWidth, constraints.maxHeight), //指定绘制区域大小 ); }, ); }, ), ), ), ), // 数据统计 Container( padding: const EdgeInsets.all(16.0), color: Colors.grey[100], child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildStatCard("数据点数", points.length.toString(), Icons.data_array), _buildStatCard("X范围", "$minX - $maxX", Icons.horizontal_rule), _buildStatCard("Y范围", "$minY - $maxY", Icons.vertical_align_center), _buildStatCard( "平均值", points.isNotEmpty ? (points.map((p) => p.dy).reduce((a, b) => a + b) / points.length).toStringAsFixed(2) : "0", Icons.bar_chart, ), //points.map((p) => p.dy)提取所有y坐标 //reduce((a, b) => a + b 计算总和 ], ), ), ], ), ); } //================================底部栏的子项==================================== Widget _buildStatCard(String title, String value, IconData icon) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Column( children: [ Icon(icon, size: 20, color: Colors.blue), const SizedBox(height: 4), Text( value, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), Text( title, style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ], ), ); } } //===================================自定义折线图画笔================================= class _LineChartPainter extends CustomPainter { final List<Offset> points; final double minX; final double maxX; final double minY; final double maxY; final double padding; final Color axisColor; final double axisWidth; final Color lineColor; final double lineWidth; final Color pointColor; final double pointRadius; final int xGridLines; final int yGridLines; final double animationValue; //构造函数 _LineChartPainter({ required this.points, // 数据点列表 required this.minX, // X轴最小值 required this.maxX, // X轴最大值 required this.minY, // Y轴最小值 required this.maxY, // Y轴最大值 required this.padding, // 图表内边距 required this.axisColor, // 坐标轴颜色 required this.axisWidth, // 坐标轴宽度 required this.lineColor, // 折线颜色 required this.lineWidth, // 折线宽度 required this.pointColor, // 数据点颜色 required this.pointRadius, // 数据点半径 required this.xGridLines, // X轴网格线数量 required this.yGridLines, // Y轴网格线数量 required this.animationValue, // 动画进度值(0-1) }); @override void paint(Canvas canvas, Size size) { //网格线画笔 final Paint gridPaint = Paint() ..color = Colors.grey[200]! ..strokeWidth = 0.5 ..style = PaintingStyle.stroke; //坐标轴画笔 final Paint axisPaint = Paint() ..color = axisColor ..strokeWidth = axisWidth ..style = PaintingStyle.stroke; //只描边 //折线画笔 final Paint linePaint = Paint() ..color = lineColor.withOpacity(0.8) ..strokeWidth = lineWidth ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round;//线头圆角 //数据点画笔 final Paint pointPaint = Paint() ..color = pointColor ..style = PaintingStyle.fill; //实心填充 //阴影区域画笔 final Paint shadowPaint = Paint() ..color = lineColor.withOpacity(0.1) ..style = PaintingStyle.fill; // 绘制背景 final Rect chartArea = Rect.fromLTWH( padding, //左 padding, //上 size.width - 2 * padding, //宽度 size.height - 2 * padding, //高度 ); // 绘制网格 _drawGrid(canvas, size, gridPaint); // 绘制坐标轴 _drawAxes(canvas, size, axisPaint); // 绘制坐标轴标签 _drawAxisLabels(canvas, size); // 如果有数据点,绘制阴影区域和折线 if (points.length > 1) { // 转换所有点为画布坐标 final List<Offset> canvasPoints = points.map((point) { return _convertToCanvas(point, size); }).toList(); // 根据动画值计算要绘制的点数 final int visiblePoints = (points.length * animationValue).ceil(); final List<Offset> visibleCanvasPoints = canvasPoints.sublist(0, visiblePoints.clamp(0, canvasPoints.length)); // 绘制阴影区域 if (visibleCanvasPoints.length > 1) { final Path shadowPath = Path(); shadowPath.moveTo(visibleCanvasPoints.first.dx, chartArea.bottom); for (final point in visibleCanvasPoints) { shadowPath.lineTo(point.dx, point.dy); } shadowPath.lineTo(visibleCanvasPoints.last.dx, chartArea.bottom); shadowPath.close(); canvas.drawPath(shadowPath, shadowPaint); } // 绘制折线(带动画) if (visibleCanvasPoints.length > 1) { final Path linePath = Path(); linePath.moveTo(visibleCanvasPoints.first.dx, visibleCanvasPoints.first.dy); for (int i = 1; i < visibleCanvasPoints.length; i++) { final current = visibleCanvasPoints[i]; final previous = visibleCanvasPoints[i - 1]; // 使用贝塞尔曲线使线条更平滑 final controlPoint1 = Offset( previous.dx + (current.dx - previous.dx) / 2, previous.dy, ); final controlPoint2 = Offset( current.dx - (current.dx - previous.dx) / 2, current.dy, ); linePath.cubicTo( controlPoint1.dx, controlPoint1.dy, controlPoint2.dx, controlPoint2.dy, current.dx, current.dy, ); } canvas.drawPath(linePath, linePaint); } // 绘制数据点(带缩放动画) for (int i = 0; i < visibleCanvasPoints.length; i++) { final point = visibleCanvasPoints[i]; final double pointAnimation = min(1.0, animationValue * 2 - i * 0.1); if (pointAnimation > 0) { // 绘制点 canvas.drawCircle( point, pointRadius * pointAnimation, pointPaint, ); // 绘制点的光晕效果 canvas.drawCircle( point, pointRadius * 1.5 * pointAnimation, Paint() ..color = pointColor.withOpacity(0.2 * pointAnimation) ..style = PaintingStyle.fill, ); // 绘制点的标签 final textPainter = TextPainter( text: TextSpan( text: "(${points[i].dx.toInt()}, ${points[i].dy.toStringAsFixed(1)})", style: const TextStyle( color: Colors.black87, fontSize: 10, fontWeight: FontWeight.w500, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(point.dx - textPainter.width / 2, point.dy - 20), ); } } } else if (points.length == 1) { // 只有一个点时 final Offset canvasPoint = _convertToCanvas(points.first, size); canvas.drawCircle(canvasPoint, pointRadius, pointPaint); } else { // 没有数据点时显示提示 final textPainter = TextPainter( text: const TextSpan( text: "暂无数据,请添加数据点", style: TextStyle( color: Colors.grey, fontSize: 14, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset( size.width / 2 - textPainter.width / 2, size.height / 2 - textPainter.height / 2, ), ); } } // 绘制网格 void _drawGrid(Canvas canvas, Size size, Paint paint) { final double chartWidth = size.width - 2 * padding; final double chartHeight = size.height - 2 * padding; // 水平网格线 for (int i = 0; i <= yGridLines; i++) { final double y = padding + (chartHeight / yGridLines) * i; canvas.drawLine( Offset(padding, y), Offset(size.width - padding, y), paint, ); } // 垂直网格线 for (int i = 0; i <= xGridLines; i++) { final double x = padding + (chartWidth / xGridLines) * i; canvas.drawLine( Offset(x, padding), Offset(x, size.height - padding), paint, ); } } // 绘制坐标轴 void _drawAxes(Canvas canvas, Size size, Paint paint) { // X轴 canvas.drawLine( Offset(padding, size.height - padding), Offset(size.width - padding, size.height - padding), paint, ); // Y轴 canvas.drawLine( Offset(padding, padding), Offset(padding, size.height - padding), paint, ); // X轴箭头 canvas.drawLine( Offset(size.width - padding, size.height - padding), Offset(size.width - padding - 8, size.height - padding - 4), paint, ); canvas.drawLine( Offset(size.width - padding, size.height - padding), Offset(size.width - padding - 8, size.height - padding + 4), paint, ); // Y轴箭头 canvas.drawLine( Offset(padding, padding), Offset(padding - 4, padding + 8), paint, ); canvas.drawLine( Offset(padding, padding), Offset(padding + 4, padding + 8), paint, ); } // 绘制坐标轴标签 void _drawAxisLabels(Canvas canvas, Size size) { final double chartWidth = size.width - 2 * padding; final double chartHeight = size.height - 2 * padding; // X轴标签 for (int i = 0; i <= xGridLines; i++) { final double xValue = minX + (maxX - minX) / xGridLines * i; final double x = padding + (chartWidth / xGridLines) * i; final textPainter = TextPainter( text: TextSpan( text: xValue.toStringAsFixed(1), style: const TextStyle( color: Colors.black87, fontSize: 10, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(x - textPainter.width / 2, size.height - padding + 5), ); } // Y轴标签 for (int i = 0; i <= yGridLines; i++) { final double yValue = maxY - (maxY - minY) / yGridLines * i; final double y = padding + (chartHeight / yGridLines) * i; final textPainter = TextPainter( text: TextSpan( text: yValue.toStringAsFixed(1), style: const TextStyle( color: Colors.black87, fontSize: 10, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(padding - textPainter.width - 10, y - textPainter.height / 2), ); } // 坐标轴标题 final textX = TextPainter( text: const TextSpan( text: "X轴", style: TextStyle( color: Colors.black87, fontSize: 12, fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, )..layout(); final textY = TextPainter( text: const TextSpan( text: "Y轴", style: TextStyle( color: Colors.black87, fontSize: 12, fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, )..layout(); textX.paint( canvas, Offset(size.width - padding - textX.width / 2, size.height - padding + 20), ); textY.paint( canvas, Offset(padding - 30, padding - textY.height / 2 -20), ); } // 坐标转换 Offset _convertToCanvas(Offset point, Size size) { // 线性映射:数据坐标 → 画布坐标 // 公式:画布坐标 = 起点 + (数据值 - 最小值) / 范围 × 可用空间 final double width = size.width - 2 * padding; final double height = size.height - 2 * padding; final double x = padding + (point.dx - minX) / (maxX - minX) * width; final double y = size.height - padding - (point.dy - minY) / (maxY - minY) * height; // 注意Y轴需要翻转:画布原点在左上角,数学原点在左下角 return Offset(x, y); } @override bool shouldRepaint(_LineChartPainter oldDelegate) { return oldDelegate.points != points || //数据变化时重绘 oldDelegate.animationValue != animationValue; //动画变化时重绘 } }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/20 23:51:11

acbDecrypter:快速解密游戏音频文件的终极解决方案

acbDecrypter&#xff1a;快速解密游戏音频文件的终极解决方案 【免费下载链接】acbDecrypter 项目地址: https://gitcode.com/gh_mirrors/ac/acbDecrypter 想要提取游戏中的背景音乐和音效吗&#xff1f;acbDecrypter是专为游戏音频解密设计的开源工具&#xff0c;能够…

作者头像 李华
网站建设 2026/5/31 6:09:48

OpenCore Legacy Patcher完整指南:让旧Mac焕发新生的终极方案

你是否曾经为手中的旧款Mac无法升级到最新系统而深感惋惜&#xff1f;看着性能依旧强劲的硬件&#xff0c;却被Apple官方无情"抛弃"&#xff0c;这种无奈想必每个Mac老用户都深有体会。今天&#xff0c;我们将为你揭晓这个革命性工具的完整使用方案。 【免费下载链接…

作者头像 李华
网站建设 2026/5/19 23:56:58

KeymouseGo鼠标键盘自动化操作宝典:解放双手的智能助手

KeymouseGo鼠标键盘自动化操作宝典&#xff1a;解放双手的智能助手 【免费下载链接】KeymouseGo 类似按键精灵的鼠标键盘录制和自动化操作 模拟点击和键入 | automate mouse clicks and keyboard input 项目地址: https://gitcode.com/gh_mirrors/ke/KeymouseGo 你是否曾…

作者头像 李华
网站建设 2026/5/29 4:23:50

你写的每一行代码都在投票:开发者如何用开源贡献参与AGI治理

一、引言&#xff1a;当你的代码成为文明协议 2024年10月&#xff0c;Meta开源Llama-3-70B模型后&#xff0c;全球开发者提交了超过1.2万次Pull Request。其中一位巴西工程师的贡献改变了历史进程&#xff1a;他修复了模型在葡萄牙语语境下对“家庭责任”的误判——当用户询问…

作者头像 李华
网站建设 2026/5/21 11:51:54

5个强力技巧:掌握Diaphora二进制差异分析工具

副标题&#xff1a;从零开始学习逆向工程中的程序差异分析技术&#xff0c;提升程序分析效率 【免费下载链接】diaphora Diaphora, the most advanced Free and Open Source program diffing tool. 项目地址: https://gitcode.com/gh_mirrors/di/diaphora Diaphora&…

作者头像 李华
网站建设 2026/5/21 10:38:12

KeymouseGo终极指南:简单上手的桌面自动化神器

你是否每天被重复的鼠标点击和键盘输入折磨得筋疲力尽&#xff1f;&#x1f629; 想要彻底解放双手&#xff0c;让电脑自动完成那些枯燥的任务&#xff1f;KeymouseGo就是你的救星&#xff01;这款强大的桌面自动化工具能够记录并重放你的所有操作&#xff0c;让你从此告别重复…

作者头像 李华