Created
May 26, 2024 09:51
-
-
Save CharlVS/15a93b45be728607a3e32012e1d268ff to your computer and use it in GitHub Desktop.
Custom Dart Charts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'dart:math'; | |
import 'dart:async'; | |
void main() { | |
runApp(const MyApp()); | |
} | |
class MyApp extends StatefulWidget { | |
const MyApp({super.key}); | |
@override | |
_MyAppState createState() => _MyAppState(); | |
} | |
class _MyAppState extends State<MyApp> { | |
List<ChartData> data1 = []; | |
List<ChartData> data2 = []; | |
@override | |
void initState() { | |
for (int i = 0; i < 10; i++) { | |
data1.add(ChartData(x: i.toDouble(), y: Random().nextDouble())); | |
data2.add(ChartData(x: i.toDouble(), y: Random().nextDouble())); | |
} | |
super.initState(); | |
Timer.periodic(const Duration(seconds: 2), (timer) { | |
setState(() { | |
// Update the data points with some random values for animation | |
// data1.forEach((element) { | |
// element.y = Random().nextDouble(); | |
// }); | |
// data2.forEach((element) { | |
// element.y = Random().nextDouble(); | |
// }); | |
// data1 = data1.map((element) { | |
// return ChartData(x: element.x, y: Random().nextDouble()); | |
// }).toList(); | |
data2 = data2.map((element) { | |
return ChartData( | |
x: element.x, | |
// // Move the x by a random amount to the left or right | |
// element.x + (Random().nextDouble() - 0.5), | |
y: Random().nextDouble()); | |
}).toList(); | |
// 50% chance to add a new data point | |
if (Random().nextBool()) { | |
data2.add(ChartData(x: data2.last.x + 1, y: Random().nextDouble())); | |
data1.add(ChartData(x: data1.last.x + 1, y: Random().nextDouble())); | |
// Random x point somewhere between the first and last x points and | |
// then insert it at the index in the list where it should be | |
// final newX = Random().nextDouble() * (data2.last.x - data2.first.x) + | |
// data2.first.x; | |
// data2.insert(data1.indexWhere((element) => element.x > newX), | |
// ChartData(x: newX, y: Random().nextDouble())); | |
} | |
}); | |
}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
home: Scaffold( | |
appBar: AppBar(title: const Text('Custom Line Chart with Animation')), | |
body: Padding( | |
padding: const EdgeInsets.all(32), | |
child: CustomLineChart( | |
domainExtent: const GraphExtent.tight(), | |
elements: [ | |
// ChartGridLines( | |
// isVertical: true, | |
// count: 5, | |
// // labelBuilder: null, | |
// ), | |
// ChartGridLines( | |
// isVertical: true, | |
// count: 4, | |
// // labelBuilder: null, | |
// ), | |
ChartGridLines( | |
isVertical: false, | |
count: 5, | |
// labelBuilder: (value) => value.toString(), | |
), | |
ChartAxisLabels( | |
isVertical: true, | |
count: 5, | |
labelBuilder: (value) => value.toStringAsFixed(2), | |
), | |
ChartAxisLabels( | |
isVertical: false, | |
count: 5, | |
labelBuilder: (value) => value.toStringAsFixed(2), | |
), | |
ChartDataSeries(data: data1, color: Colors.blue), | |
ChartDataSeries( | |
data: data2, | |
color: Colors.red, | |
lineType: LineType.bezier, | |
), | |
], | |
tooltipBuilder: (context, dataPoints) { | |
return Container( | |
padding: const EdgeInsets.all(8), | |
decoration: BoxDecoration( | |
color: Colors.black, | |
borderRadius: BorderRadius.circular(4.0), | |
), | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: dataPoints.map((data) { | |
return Row( | |
mainAxisAlignment: MainAxisAlignment.start, | |
children: [ | |
const Icon(Icons.circle, color: Colors.white), | |
Text( | |
'(${data.x}, ${data.y})', | |
style: const TextStyle( | |
color: Colors.white, fontSize: 12), | |
), | |
const SizedBox(height: 36), | |
], | |
); | |
}).toList(), | |
), | |
); | |
}, | |
), | |
), | |
), | |
); | |
} | |
} | |
class ChartData { | |
final double x; | |
final double y; | |
ChartData({required this.x, required this.y}); | |
} | |
enum LineType { straight, bezier } | |
class ChartDataSeries extends ChartElement { | |
final List<ChartData> data; | |
final Color color; | |
final LineType lineType; | |
ChartDataSeries({ | |
required this.data, | |
required this.color, | |
this.lineType = LineType.straight, | |
}); | |
ChartDataSeries animateTo( | |
ChartDataSeries newDataSeries, double animationValue) { | |
List<ChartData> interpolatedData = []; | |
for (int i = 0; i < min(data.length, newDataSeries.data.length); i++) { | |
double oldY = data[i].y; | |
double newY = newDataSeries.data[i].y; | |
double interpolatedY = oldY + (newY - oldY) * animationValue; | |
double oldX = data[i].x; | |
double newX = newDataSeries.data[i].x; | |
double interpolatedX = oldX + (newX - oldX) * animationValue; | |
interpolatedData.add(ChartData( | |
x: interpolatedX, | |
y: interpolatedY, | |
)); | |
} | |
return ChartDataSeries( | |
data: interpolatedData, | |
color: color, | |
lineType: lineType, | |
); | |
} | |
@override | |
void paint(Canvas canvas, Size size, ChartDataTransform transform, | |
double animation) { | |
Paint linePaint = Paint() | |
..color = color | |
..strokeWidth = 2.0 | |
..style = PaintingStyle.stroke; | |
Paint pointPaint = Paint() | |
..color = color | |
..style = PaintingStyle.fill; | |
Path path = Path(); | |
bool first = true; | |
if (lineType == LineType.straight) { | |
for (var point in data) { | |
double x = transform.transformX(point.x); | |
double y = transform.transformY(point.y); | |
if (first) { | |
path.moveTo(x, y); | |
first = false; | |
} else { | |
path.lineTo(x, y); | |
} | |
canvas.drawCircle( | |
Offset(x, y), 4.0, pointPaint); // Increased point size | |
} | |
} else if (lineType == LineType.bezier) { | |
if (data.isNotEmpty) { | |
path.moveTo( | |
transform.transformX(data[0].x), transform.transformY(data[0].y)); | |
for (int i = 0; i < data.length - 1; i++) { | |
double x1 = transform.transformX(data[i].x); | |
double y1 = transform.transformY(data[i].y); | |
double x2 = transform.transformX(data[i + 1].x); | |
double y2 = transform.transformY(data[i + 1].y); | |
// Control points for the cubic Bezier curve | |
double controlPointX1 = x1 + (x2 - x1) / 3; | |
double controlPointY1 = y1; | |
double controlPointX2 = x1 + 2 * (x2 - x1) / 3; | |
double controlPointY2 = y2; | |
path.cubicTo(controlPointX1, controlPointY1, controlPointX2, | |
controlPointY2, x2, y2); | |
canvas.drawCircle( | |
Offset(x1, y1), 4.0, pointPaint); // Increased point size | |
} | |
canvas.drawCircle( | |
Offset(transform.transformX(data.last.x), | |
transform.transformY(data.last.y)), | |
4.0, | |
pointPaint); // Draw last point | |
} | |
} | |
canvas.drawPath(path, linePaint); | |
} | |
} | |
class ChartDataTransform { | |
final double minX, maxX, minY, maxY; | |
final double width, height; | |
ChartDataTransform({ | |
required this.minX, | |
required this.maxX, | |
required this.minY, | |
required this.maxY, | |
required this.width, | |
required this.height, | |
}); | |
double transformX(double x) => (x - minX) / (maxX - minX) * width; | |
double transformY(double y) => height - (y - minY) / (maxY - minY) * height; | |
double invertX(double dx) => minX + (dx / width) * (maxX - minX); | |
double invertY(double dy) => minY + (1 - dy / height) * (maxY - minY); | |
} | |
abstract class ChartElement { | |
void paint( | |
Canvas canvas, Size size, ChartDataTransform transform, double animation); | |
} | |
class ChartGridLines extends ChartElement { | |
final bool isVertical; | |
final int count; | |
ChartGridLines({required this.isVertical, required this.count}); | |
@override | |
void paint(Canvas canvas, Size size, ChartDataTransform transform, | |
double animation) { | |
Paint gridPaint = Paint() | |
..color = Colors.grey.withOpacity(0.2) | |
..strokeWidth = 1.0; | |
if (isVertical) { | |
for (double i = 0; i <= count; i++) { | |
double x = i * size.width / count; | |
canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint); | |
} | |
} else { | |
for (double i = 0; i <= count; i++) { | |
double y = i * size.height / count; | |
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); | |
} | |
} | |
} | |
} | |
class ChartAxisLabels extends ChartElement { | |
final bool isVertical; | |
final int count; | |
final String Function(double value) labelBuilder; | |
final double reservedExtent; | |
ChartAxisLabels({ | |
required this.isVertical, | |
required this.count, | |
required this.labelBuilder, | |
this.reservedExtent = 30.0, // Default reserved extent for labels | |
}); | |
@override | |
void paint(Canvas canvas, Size size, ChartDataTransform transform, | |
double animation) { | |
if (isVertical) { | |
for (double i = 0; i <= count; i++) { | |
double y = i * size.height / count; | |
TextPainter textPainter = TextPainter( | |
text: TextSpan( | |
text: labelBuilder(transform.invertY(y)), | |
style: const TextStyle(color: Colors.grey, fontSize: 10)), | |
textDirection: TextDirection.ltr, | |
); | |
textPainter.layout(); | |
if (i == 0 || (y - textPainter.height / 2) >= reservedExtent * i) { | |
textPainter.paint(canvas, | |
Offset(-textPainter.width - 5, y - textPainter.height / 2)); | |
} | |
} | |
} else { | |
for (double i = 0; i <= count; i++) { | |
double x = i * size.width / count; | |
TextPainter textPainter = TextPainter( | |
text: TextSpan( | |
text: labelBuilder(transform.invertX(x)), | |
style: const TextStyle(color: Colors.grey, fontSize: 10)), | |
textDirection: TextDirection.ltr, | |
); | |
textPainter.layout(); | |
if (i == 0 || (x - textPainter.width / 2) >= reservedExtent * i) { | |
textPainter.paint( | |
canvas, Offset(x - textPainter.width / 2, size.height + 5)); | |
} | |
} | |
} | |
} | |
} | |
class GraphExtent { | |
final bool auto; | |
final double padding; | |
final double? min; | |
final double? max; | |
const GraphExtent({ | |
this.auto = true, | |
this.padding = 0.1, | |
this.min, | |
this.max, | |
}); | |
const GraphExtent.tight() : this(auto: true, padding: 0.0); | |
} | |
class CustomLineChart extends StatefulWidget { | |
final List<ChartElement> elements; | |
final Duration animationDuration; | |
final Widget Function(BuildContext, List<ChartData>) tooltipBuilder; | |
final GraphExtent domainExtent; | |
final GraphExtent rangeExtent; | |
const CustomLineChart({ | |
super.key, | |
required this.elements, | |
required this.tooltipBuilder, | |
this.animationDuration = const Duration(milliseconds: 500), | |
this.domainExtent = const GraphExtent(auto: true, padding: 0.1), | |
this.rangeExtent = const GraphExtent(auto: true, padding: 0.1), | |
}); | |
@override | |
_CustomLineChartState createState() => _CustomLineChartState(); | |
} | |
class _CustomLineChartState extends State<CustomLineChart> | |
with SingleTickerProviderStateMixin { | |
OverlayEntry? _tooltipOverlay; | |
Offset? _hoverPosition; | |
List<Offset>? _highlightedPoints; | |
List<Color> _highlightedColors = []; | |
late AnimationController _controller; | |
late Animation<double> _animation; | |
List<ChartElement> oldElements = []; | |
List<ChartElement> currentElements = []; | |
double minX = double.infinity; | |
double maxX = double.negativeInfinity; | |
double minY = double.infinity; | |
double maxY = double.negativeInfinity; | |
late Animation<double> minXAnimation; | |
late Animation<double> maxXAnimation; | |
late Animation<double> minYAnimation; | |
late Animation<double> maxYAnimation; | |
@override | |
void initState() { | |
super.initState(); | |
_controller = | |
AnimationController(vsync: this, duration: widget.animationDuration); | |
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); | |
_controller.addListener(() { | |
setState(() { | |
// Rebuild to reflect animation progress | |
}); | |
}); | |
_controller.addStatusListener((status) { | |
if (status == AnimationStatus.completed) { | |
setState(() { | |
oldElements = List.from(widget.elements); | |
}); | |
} | |
}); | |
oldElements = List.from(widget.elements); | |
currentElements = List.from(widget.elements); | |
_updateDomainRange(); | |
_controller.forward(); | |
} | |
@override | |
void didUpdateWidget(covariant CustomLineChart oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.elements != widget.elements) { | |
setState(() { | |
oldElements = List.from(currentElements); | |
currentElements = List.from(widget.elements); | |
_controller.reset(); | |
_updateDomainRange(); | |
_controller.forward(); | |
}); | |
} else { | |
_updateDomainRange(); | |
} | |
} | |
void _updateDomainRange() { | |
double newMinX = double.infinity; | |
double newMaxX = double.negativeInfinity; | |
double newMinY = double.infinity; | |
double newMaxY = double.negativeInfinity; | |
for (var element in widget.elements) { | |
if (element is ChartDataSeries) { | |
for (var dataPoint in element.data) { | |
double xValue = dataPoint.x; | |
if (xValue < newMinX) newMinX = xValue; | |
if (xValue > newMaxX) newMaxX = xValue; | |
if (dataPoint.y < newMinY) newMinY = dataPoint.y; | |
if (dataPoint.y > newMaxY) newMaxY = dataPoint.y; | |
} | |
} | |
} | |
if (widget.domainExtent.auto) { | |
double domainPaddingValue = | |
(newMaxX - newMinX) * widget.domainExtent.padding; | |
newMinX -= domainPaddingValue; | |
newMaxX += domainPaddingValue; | |
} else { | |
newMinX = widget.domainExtent.min ?? newMinX; | |
newMaxX = widget.domainExtent.max ?? newMaxX; | |
} | |
if (widget.rangeExtent.auto) { | |
double rangePaddingValue = | |
(newMaxY - newMinY) * widget.rangeExtent.padding; | |
newMinY -= rangePaddingValue; | |
newMaxY += rangePaddingValue; | |
} else { | |
newMinY = widget.rangeExtent.min ?? newMinY; | |
newMaxY = widget.rangeExtent.max ?? newMaxY; | |
} | |
minXAnimation = | |
Tween<double>(begin: minX, end: newMinX).animate(_controller); | |
maxXAnimation = | |
Tween<double>(begin: maxX, end: newMaxX).animate(_controller); | |
minYAnimation = | |
Tween<double>(begin: minY, end: newMinY).animate(_controller); | |
maxYAnimation = | |
Tween<double>(begin: maxY, end: newMaxY).animate(_controller); | |
minX = newMinX; | |
maxX = newMaxX; | |
minY = newMinY; | |
maxY = newMaxY; | |
} | |
void _showTooltip( | |
BuildContext context, Offset position, List<ChartData> dataPoints) { | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
_hideTooltip(); | |
final RenderBox? renderBox = context.findRenderObject() as RenderBox?; | |
final Size? size = renderBox?.size; | |
if (size != null) { | |
double left = position.dx + 10; | |
double top = position.dy - 30; | |
// Ensure tooltip stays within bounds | |
if (left + 100 > size.width) { | |
left = size.width - 100; | |
} | |
if (top < 0) { | |
top = 0; | |
} | |
_tooltipOverlay = OverlayEntry( | |
builder: (context) => Positioned( | |
left: left, | |
top: top, | |
child: Material( | |
color: Colors.transparent, | |
child: widget.tooltipBuilder(context, dataPoints), | |
), | |
), | |
); | |
Overlay.of(context).insert(_tooltipOverlay!); | |
} | |
}); | |
} | |
void _hideTooltip() { | |
if (_tooltipOverlay != null) { | |
_tooltipOverlay!.remove(); | |
_tooltipOverlay = null; | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
return LayoutBuilder( | |
builder: (context, constraints) { | |
Size size = Size(constraints.maxWidth, constraints.maxHeight); | |
ChartDataTransform transform = ChartDataTransform( | |
minX: minXAnimation.value, | |
maxX: maxXAnimation.value, | |
minY: minYAnimation.value, | |
maxY: maxYAnimation.value, | |
width: size.width, | |
height: size.height, | |
); | |
return Container( | |
color: Colors.black.withOpacity(0.05), // Background color | |
child: GestureDetector( | |
onPanUpdate: (details) { | |
setState(() { | |
_hoverPosition = details.localPosition; | |
}); | |
}, | |
child: MouseRegion( | |
onHover: (details) { | |
final localPosition = details.localPosition; | |
List<ChartData> highlightedData = []; | |
_highlightedPoints = []; | |
_highlightedColors = []; | |
for (var element in widget.elements) { | |
if (element is ChartDataSeries) { | |
for (var point in element.data) { | |
double x = transform.transformX(point.x); | |
double y = transform.transformY(point.y); | |
if ((Offset(x, y) - localPosition).distance < 10) { | |
highlightedData.add(point); | |
_highlightedPoints!.add(Offset(x, y)); | |
_highlightedColors.add(element.color); | |
} | |
} | |
} | |
} | |
if (highlightedData.isNotEmpty) { | |
_showTooltip(context, localPosition, highlightedData); | |
} else { | |
_hideTooltip(); | |
} | |
setState(() { | |
_hoverPosition = localPosition; | |
}); | |
}, | |
onExit: (details) { | |
_hideTooltip(); | |
setState(() { | |
_hoverPosition = null; | |
_highlightedPoints = null; | |
_highlightedColors = []; | |
}); | |
}, | |
child: AnimatedBuilder( | |
animation: _animation, | |
builder: (context, child) { | |
List<ChartElement> animatedElements = []; | |
for (int i = 0; i < currentElements.length; i++) { | |
if (currentElements[i] is ChartDataSeries && | |
oldElements[i] is ChartDataSeries) { | |
animatedElements.add((oldElements[i] as ChartDataSeries) | |
.animateTo(currentElements[i] as ChartDataSeries, | |
_animation.value)); | |
} else { | |
animatedElements.add(currentElements[i]); | |
} | |
} | |
return CustomPaint( | |
size: size, | |
painter: _LineChartPainter( | |
elements: animatedElements, | |
transform: transform, | |
highlightedPoints: _highlightedPoints, | |
highlightedColors: _highlightedColors, | |
animation: _animation.value, | |
), | |
); | |
}, | |
), | |
), | |
), | |
); | |
}, | |
); | |
} | |
@override | |
void dispose() { | |
_hideTooltip(); | |
_controller.dispose(); | |
super.dispose(); | |
} | |
} | |
class _LineChartPainter extends CustomPainter { | |
final List<ChartElement> elements; | |
final ChartDataTransform transform; | |
final List<Offset>? highlightedPoints; | |
final List<Color> highlightedColors; | |
final double animation; | |
_LineChartPainter({ | |
required this.elements, | |
required this.transform, | |
required this.highlightedPoints, | |
required this.highlightedColors, | |
required this.animation, | |
}); | |
@override | |
void paint(Canvas canvas, Size size) { | |
for (var element in elements) { | |
element.paint(canvas, size, transform, animation); | |
} | |
if (highlightedPoints != null) { | |
for (int i = 0; i < highlightedPoints!.length; i++) { | |
var point = highlightedPoints![i]; | |
var color = highlightedColors[i]; | |
Paint highlightPaint = Paint() | |
..color = color | |
..style = PaintingStyle.fill; | |
canvas.drawCircle( | |
point, 6.0, highlightPaint); // Fixed size for highlight | |
} | |
} | |
} | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) { | |
return true; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment