Tutorial · 15 min · intermediate
Charts on the native canvas
Flutter's CustomPainter API running on Core Graphics and android.graphics.Canvas — a bar chart, a scrubbable sparkline, and a gradient card.
What you'll build — running on device.
Sometimes no widget fits and you need to draw — charts, rings, sparklines.
DartNative ships Flutter’s CustomPaint/CustomPainter API, but the
display list your painter emits is executed by the platform’s own 2D
renderer: Core Graphics on iOS, android.graphics.Canvas on Android. No
Skia, no extra engine in your binary. You’ll focus on three chart stages
— a labelled bar chart, a slider-scrubbable sparkline, and a card of
gradient shaders — from the playground’s CustomPaint demo, which the
finished code carries whole: fifteen more painter sections (paths, the
full paragraph pipeline, image ops, animated text) share the same screen
and are worth exploring.
What you need
- A project from Your first DartNative app
Step 1 — A painter and a canvas
A CustomPainter is a class with one job: given a Canvas and a Size,
issue draw calls. You hand an instance to a CustomPaint widget and the
framework calls paint(canvas, size) when the canvas needs content. If
you’ve written one in Flutter, this is that API — Paint, Path,
Rect, Offset, the same contract.
One current constraint: give CustomPaint a concrete size rather than
letting layout decide. Painters write absolute coordinates, so the app
derives the width from the screen:
final canvasWidth = MediaQuery.of(context).size.width - 32;
final canvasSize = Size(canvasWidth, 220);
CustomPaint(
size: canvasSize,
painter: _BarChartPainter(),
),
That’s the whole wiring. Everything from here on happens inside painters.
Step 2 — The bar chart: drawRect + drawText
The bar-chart painter draws seven bars from a static list of (label, ratio)
pairs. Shapes are drawRect with a filled Paint; the interesting part is
the labels, because on this canvas text is a first-class op — no
TextPainter ceremony for a chart label:
final barPaint = Paint()
..style = PaintingStyle.fill
..color = const Color(0xFF0A84FF); // iOS systemBlue
for (var i = 0; i < n; i++) {
final (label, ratio) = _data[i];
final left = chartArea.left + i * (barWidth + barGap);
final height = chartArea.height * ratio;
final top = chartArea.bottom - height;
canvas.drawRect(
Rect.fromLTWH(left, top, barWidth, height),
barPaint,
);
// Value above the bar — center-aligned at the bar's mid-x.
final pct = (ratio * 100).round();
canvas.drawText(
'$pct%',
Offset(left + barWidth / 2, top - 16),
fontSize: 11,
color: kTextPrimary,
fontWeight: 600,
textAlign: 1, // center
);
// Axis label — center-aligned under the bar.
canvas.drawText(
label,
Offset(left + barWidth / 2, chartArea.bottom + 8),
fontSize: 11,
color: kTextSecondary,
textAlign: 1, // center
);
}
(kTextPrimary, kTextSecondary, and later kChipBg come from the
playground’s shared UI kit — home/demo_ui.dart, copied alongside the
screen.)
drawText is the single-line text op — it lowers to
NSAttributedString.draw on iOS and Canvas.drawText on Android, so
labels get the platform’s own text rendering. textAlign: 1 centers the
string on the offset you pass, which is exactly what axis labels want.
(For wrapped, multi-style text there’s the full ParagraphBuilder +
canvas.drawParagraph pipeline, metrics included.)
Step 3 — An honest shouldRepaint
shouldRepaint is the painter’s performance contract: after a widget
rebuild, the framework asks the new painter whether its output differs
from the old one’s. Answer honestly and static charts become free.
The bar chart’s data never changes, so it answers false — it encodes
one display list, ever, no matter how often the tree around it
rebuilds:
@override
bool shouldRepaint(_BarChartPainter oldDelegate) => false;
The sparkline is the dynamic case. It takes a progress value and draws
the first progress * count points bright, the rest dimmed — and its
shouldRepaint compares inputs:
/// Sparkline driven by [progress] (0..1). Shows the first
/// `progress * count` data points; the rest are dimmed.
class _SparklinePainter extends CustomPainter {
final double progress;
const _SparklinePainter({required this.progress});
@override
bool shouldRepaint(_SparklinePainter oldDelegate) =>
oldDelegate.progress != progress;
}
Wire it to a Slider and scrub:
Slider(
value: _slider,
min: 0,
max: 1,
activeColor: const Color(0xFF0A84FF),
inactiveColor: kChipBg,
onChanged: (v) => setState(() => _slider = v),
),
Every drag tick calls setState, which rebuilds every CustomPaint on
the screen — but only the sparkline re-encodes, because only its answer
to shouldRepaint is true. The bar chart and the gradient card sit
untouched in their native layers. That’s the rule worth internalizing:
return false for static data, compare inputs for dynamic data, and the
canvas costs exactly as many encodes as it has real changes.
Step 4 — Gradient shaders
Gradients arrive through Paint.shader, with factories that mirror
dart:ui’s — the as ui import makes the call sites read exactly like
Flutter code:
import 'package:dartnative/canvas.dart' as ui;
The gradient painter fills three rounded bars with linear gradients (the last one three-colored) and a swatch with a radial one:
final paint = Paint()
..style = PaintingStyle.fill
..shader = ui.Gradient.linear(
Offset(barRect.left, barRect.top),
Offset(barRect.right, barRect.top),
gradients[i],
);
canvas.drawRRect(
RRect.fromRectAndRadius(barRect, 10),
paint,
);
final radialPaint = Paint()
..style = PaintingStyle.fill
..shader = ui.Gradient.radial(
swatchRect.center,
swatchSize / 2,
const [
Color(0xFFFFFFFF),
Color(0xFFFF9F0A),
Color(0xFFFF453A),
],
const [0.0, 0.55, 1.0],
);
On iOS these lower to CGGradient; on Android to
android.graphics.LinearGradient / RadialGradient. The optional fourth
argument is the stop list ([0.0, 0.55, 1.0] holds the white center
tight, then eases to red). One documented edge: gradient strokes fall
back to the Paint’s solid colour — the painter draws a hairline stroke
over the swatch precisely to show that. And since the gradient data is
fixed, this painter also returns false from shouldRepaint: shaders on
the wire, still one encode.
Beyond what these three painters use, the op surface covers the drawing
you actually do: drawLine, drawCircle, drawPath (with
moveTo/lineTo/cubicTo/arcTo), drawArc, drawImage,
drawShadow — the demo screen’s other sections exercise most of it.
Why this is native
A Flutter chart ships Skia (or Impeller) to rasterize it. Here the same painter code emits a binary display list via
BinaryCanvasRecorder, and the native view replays the bytes indraw(_:)(iOS / Core Graphics) oronDraw(Android /android.graphics.Canvas) — the renderers the OS itself draws with. Your charts get platform-quality antialiasing and text rendering, your binary gains zero rendering engines, and a static chart costs one encode for its whole life.
The finished code
In the public repo — dn create ., dn run. The screen is a
byte-identical copy of the playground’s CustomPaint demo (with the
playground’s shared UI kit alongside it, also verbatim) under a thin
main.dart: when the playground screen improves, this tutorial inherits
it by copying the files again.