Skip to content
FlutterLearn

Advanced · Lesson 2 · 15 min read

The Rendering Pipeline and Custom Painting

Understand build, layout and paint, then draw exactly what you want with CustomPainter and custom render objects.

Updated July 31, 2026

What you will learn

  • Trace a frame from build through layout to paint and compositing
  • Draw with CustomPainter and Canvas efficiently
  • Implement custom layout with RenderBox when widgets are not enough
  • Use repaint boundaries and shouldRepaint correctly

Every frame Flutter renders goes through the same phases. Knowing them tells you exactly which phase your performance problem is in — and that determines the fix.

  1. Build — dirty widgets rebuild, producing new widget objects. The element tree reconciles them against the previous frame.
  2. Layout — a single walk down the render tree passing constraints, and back up returning sizes. One pass, which is why Flutter's layout is O(n).
  3. Paint — render objects record drawing commands into layers. Nothing is rasterised yet.
  4. Composite — layers are assembled into a scene.
  5. Rasterise — the engine (Impeller) turns the scene into pixels on the GPU.

CustomPainter

When no combination of widgets gives you the visual you need — a sparkline, a radial gauge, a signature pad, a custom chart — drop to the canvas.

lib/widgets/sparkline.dart
import 'package:flutter/material.dart';

class Sparkline extends StatelessWidget {
  const Sparkline({super.key, required this.values, required this.color});

  final List<double> values;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: CustomPaint(
        painter: _SparklinePainter(values: values, color: color),
        size: const Size.fromHeight(48),
      ),
    );
  }
}

class _SparklinePainter extends CustomPainter {
  _SparklinePainter({required this.values, required this.color});

  final List<double> values;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    if (values.length < 2) return;

    final maxValue = values.reduce((a, b) => a > b ? a : b);
    final minValue = values.reduce((a, b) => a < b ? a : b);
    final range = (maxValue - minValue).abs() < 0.0001 ? 1.0 : maxValue - minValue;

    final path = Path();
    for (var i = 0; i < values.length; i++) {
      final x = size.width * (i / (values.length - 1));
      final y = size.height * (1 - (values[i] - minValue) / range);
      i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
    }

    // Fill under the line
    final fillPath = Path.from(path)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();

    canvas.drawPath(
      fillPath,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [color.withValues(alpha: 0.28), color.withValues(alpha: 0)],
        ).createShader(Offset.zero & size),
    );

    canvas.drawPath(
      path,
      Paint()
        ..color = color
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2
        ..strokeCap = StrokeCap.round
        ..isAntiAlias = true,
    );
  }

  // Called on every rebuild — return false when nothing visual changed
  @override
  bool shouldRepaint(_SparklinePainter old) =>
      old.color != color || !listEquals(old.values, values);
}

Canvas essentials

Dart
// Shapes
canvas.drawRect(Rect.fromLTWH(0, 0, 100, 50), paint);
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)), paint);
canvas.drawCircle(const Offset(50, 50), 20, paint);
canvas.drawArc(rect, startAngle, sweepAngle, false, paint);

// Transform the canvas rather than recomputing every coordinate
canvas.save();
canvas.translate(size.width / 2, size.height / 2);
canvas.rotate(math.pi / 4);
canvas.drawRect(const Rect.fromLTWH(-25, -25, 50, 50), paint);
canvas.restore();   // always pair save/restore

// Text needs a TextPainter
final textPainter = TextPainter(
  text: const TextSpan(text: '72%', style: TextStyle(fontSize: 18)),
  textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(canvas, Offset(cx - textPainter.width / 2, cy));

Hit testing a custom painter

Dart
class _PieChartPainter extends CustomPainter {
  // ... paint() omitted

  // Restrict taps to the actual drawn shape, not the bounding box
  @override
  bool hitTest(Offset position) {
    final center = Offset(_lastSize.width / 2, _lastSize.height / 2);
    return (position - center).distance <= _radius;
  }
}

When you need a RenderObject

CustomPainter draws but cannot lay out children. When you need custom layout — a widget that positions its children by rules no existing widget implements — write a RenderBox.

Dart
/// Lays children out in a horizontal line, sizing each to a share of
/// the width proportional to its weight, without any intrinsic passes.
class WeightedRow extends MultiChildRenderObjectWidget {
  const WeightedRow({super.key, required this.weights, required super.children});

  final List<double> weights;

  @override
  RenderWeightedRow createRenderObject(BuildContext context) =>
      RenderWeightedRow(weights: weights);

  @override
  void updateRenderObject(BuildContext context, RenderWeightedRow renderObject) {
    renderObject.weights = weights;
  }
}

class RenderWeightedRow extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, _WeightedParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, _WeightedParentData> {
  RenderWeightedRow({required List<double> weights}) : _weights = weights;

  List<double> _weights;
  set weights(List<double> value) {
    if (listEquals(_weights, value)) return;
    _weights = value;
    markNeedsLayout();   // tell the pipeline what changed
  }

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! _WeightedParentData) {
      child.parentData = _WeightedParentData();
    }
  }

  @override
  void performLayout() {
    final total = _weights.fold<double>(0, (a, b) => a + b);
    var x = 0.0;
    var index = 0;
    var child = firstChild;

    while (child != null) {
      final share = constraints.maxWidth * (_weights[index] / total);
      child.layout(
        BoxConstraints.tightFor(width: share, height: constraints.maxHeight),
        parentUsesSize: true,
      );
      (child.parentData! as _WeightedParentData).offset = Offset(x, 0);
      x += share;
      index++;
      child = childAfter(child);
    }

    size = constraints.biggest;
  }

  @override
  void paint(PaintingContext context, Offset offset) =>
      defaultPaint(context, offset);

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
      defaultHitTestChildren(result, position: position);
}

The key discipline in a render object is calling the right invalidation method: markNeedsLayout() when size or position could change, markNeedsPaint() when only appearance changed, markNeedsSemanticsUpdate() when accessibility information changed. Calling markNeedsLayout for a colour change wastes a whole layout pass every frame.

Repaint boundaries

A RepaintBoundary puts its subtree on its own layer so repainting it does not repaint neighbours — and neighbours repainting does not repaint it. Wrap constantly animating or continuously repainting widgets in one.

Dart
// Enable in main() during debugging to see repaints as flashing colours
void main() {
  debugRepaintRainbowEnabled = true;
  runApp(const MyApp());
}

Boundaries are not free — each is an extra layer for the compositor. Add them where profiling shows unnecessary repaint, not everywhere by default. ListView already wraps its children in repaint boundaries for you.

Key takeaways

  • Build → layout → paint → composite → rasterise; identify which phase is slow before optimising.
  • CustomPainter draws; implement shouldRepaint honestly and hoist allocations out of paint.
  • Write a RenderBox only when you need custom layout, not just custom drawing.
  • In render objects, call the narrowest invalidation method the change requires.

Practice

Build a radial progress gauge

Draw a circular gauge with a track arc, a progress arc with a gradient, a rounded cap, and centred percentage text. Animate it with an AnimationController, implement shouldRepaint correctly, and confirm with the rainbow overlay that only the gauge repaints.

Show hints
  • Angles start at 3 o'clock — subtract π/2 to start at the top.
  • Build the Paint and TextPainter once in the constructor, not inside paint.