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

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 in draw(_:) (iOS / Core Graphics) or onDraw (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.

Open the finished code →