I used Flutter's canvas.drawLine to draw a line chart. However, you can see every single line if you zoom in, which looks bad.
The same is true when using canvas.drawPoints.
The only solution left seems to draw a custom path. However, I'm really struggling to do this.
Do you have any suggestions to draw a pixel perfect line chart?
(I can't use any charting libraries like fl_chart.)
You need to use the Path.lineTo instead, the StrokeJoin says that joining lines is only available for a path.
class PaintChart extends CustomPainter {
final List<Offset> points;
const PaintChart(this.points);
@override
void paint(Canvas canvas, Size size) {
final bounds = Offset.zero & size;
final path = Path();
final paint = Paint()
..strokeWidth = 8
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round;
canvas.drawRect(
Offset.zero & size,
Paint()..color = Colors.blueAccent,
);
path.moveTo(0, bounds.height / 2);
for (final point in points) {
path.lineTo(
point.dx,
point.dy + bounds.height / 2,
);
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(PaintChart old) => false;
}
And to test it in the pad.
import 'package:flutter/material.dart';
import 'dart:math';
void main() => runApp(App());
class App extends StatelessWidget {
App({super.key});
final points = List<Offset>.generate(24, (i) {
return Offset(i.toDouble() * 25, sin(i) * 50);
});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.grey,
body: Center(
child: SizedBox(
width: 600,
height: 300,
child: CustomPaint(
painter: PaintChart(points),
))),
),
);
}
}
This example is the same as the other one but interactive and with colors, essentially, if you want to keep the lines contiguous and paint them you can use the path as a mask for a shader or you can overlay segments of the same path with different paints.
import 'package:flutter/material.dart';
import 'dart:math';
void main() => runApp(const App());
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _App();
}
class _App extends State<App> {
double amplitude = 40;
double frequency = 40;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.grey,
body: Center(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Amplitude'),
Slider(
min: 20,
max: 200,
value: amplitude,
label: amplitude.round().toString(),
onChanged: (double value) => setState(
() => amplitude = value,
),
),
const Text('Frequency'),
Slider(
min: 10,
max: 200,
value: frequency,
label: frequency.round().toString(),
onChanged: (double value) => setState(
() => frequency = value,
),
)
],
),
WavePlot(
width: 600,
height: 300,
points: List<Offset>.generate(1000, (i) {
return Offset(
i.toDouble(),
amplitude * sin(i / frequency),
);
}),
),
],
),
),
),
);
}
}
class WavePlot extends StatefulWidget {
final double width;
final double height;
// The points are in Cartesian and the values should be
// scaled here because the size of the WavePlot is now known
final List<Offset> points;
const WavePlot({
super.key,
required this.width,
required this.height,
required this.points,
});
@override
State<WavePlot> createState() => _WavePlot();
}
class _WavePlot extends State<WavePlot> {
double selection_lo = 0.2;
double selection_hi = 0.8;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: widget.width,
height: widget.height,
child: CustomPaint(
painter: PaintWavePlot(
widget.points,
selection_lo,
selection_hi,
),
),
),
SizedBox(
// 50 is just a random padding offset, the
// correct values is in the [Theme] somewhere
width: widget.width + 50,
child: RangeSlider(
values: RangeValues(
selection_lo,
selection_hi,
),
onChanged: (values) {
setState(() {
selection_lo = values.start;
selection_hi = values.end;
});
},
),
),
],
);
}
}
class PaintWavePlot extends CustomPainter {
final List<Offset> points;
final double selection_lo;
final double selection_hi;
const PaintWavePlot(
this.points,
this.selection_lo,
this.selection_hi,
);
@override
void paint(Canvas canvas, Size size) {
final bounds = Offset.zero & size;
// Painting only needs to be done in the line
canvas.saveLayer(bounds, Paint());
// The path line is a mask for the other layers
final line_path = Path();
line_path.moveTo(0, bounds.height / 2);
for (final point in points) {
line_path.lineTo(
point.dx,
point.dy + bounds.height / 2,
);
}
canvas.drawPath(
line_path,
Paint()
..strokeWidth = 8
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round,
);
canvas.drawRect(
bounds,
Paint()
..blendMode = BlendMode.srcIn
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.green,
Colors.greenAccent,
Colors.redAccent,
Colors.red,
],
stops: [0.0, 0.5, 0.5, 1.0],
).createShader(bounds),
);
canvas.drawRect(
Rect.fromLTRB(
size.width * selection_lo,
0,
size.width * selection_hi,
size.height,
),
Paint()
..color = Colors.blue
..blendMode = BlendMode.srcIn,
);
// Apply the blended drawings to the screen
canvas.restore();
canvas.drawRect(
bounds,
Paint()
..strokeWidth = 6
..color = Colors.black87
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round,
);
final plot_paint = Paint()
..strokeWidth = 3
..color = Colors.black87
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round;
canvas.drawLine(
Offset(0, bounds.height / 2),
Offset(bounds.width, bounds.height / 2),
plot_paint,
);
canvas.drawLine(
Offset(bounds.width * selection_lo, 0),
Offset(bounds.width * selection_lo, bounds.height),
plot_paint,
);
canvas.drawLine(
Offset(bounds.width * selection_hi, 0),
Offset(bounds.width * selection_hi, bounds.height),
plot_paint,
);
}
@override
bool shouldRepaint(PaintWavePlot old) => true;
}