skip to content

In Flutter, why might a CustomPaint draw nothing or spill outside its area, and how do size, clipping and save/restore fix it?

level: middleimportance: should knowfreq 35%

answer

  1. no child: size defaults to zero
  2. constraints.constrain(size)
  3. paint is not clipped for you
  4. clipRect(Offset.zero & size)
  5. unbalanced save throws in debug

basics

~20 s

Without a child, CustomPaint asks for its size argument, Size.zero by default, so loose constraints give the painter an empty box. Drawing outside size is not reliably clipped, so clip to Offset.zero & size, and keep save and restore balanced.

solid answer

~50 s

A childless `CustomPaint` asks for its `size` argument, which defaults to `Size.zero`, constrained by its parent. Inside a `Column` or `Center`, the constraints are loose, so the box really is zero by zero and the painter draws into nothing. Give it a `size`, a child, or tight constraints from `SizedBox` or `SizedBox.expand`; note that `Expanded` in a `Column` only tightens the height, so the width can still be zero. The reverse problem is drawing **outside** the box: the docs say such operations may be ignored, clipped or not clipped, so data that can overflow should be preceded by `canvas.clipRect(Offset.zero & size)`, or a `Path` passed to `clipPath`. Transforms and clips should sit between `canvas.save()` and `canvas.restore()`. `RenderCustomPaint` wraps each painter in its own save and restore, and in debug mode it throws if the painter left extra saves open.

go deeper

for a junior

Remember that a CustomPaint without a child defaults to a zero size, and that you must give it room with a size argument or a SizedBox.

for a middle

Explain constrain(size) under loose versus tight constraints, why overflow is not clipped for you, and how save and restore scope transforms and clips.

for a senior

Build painters that clamp or clip data defensively, use the debug save-count check as a guard, and keep clip usage deliberate on hot paths.

for a principal

Standardise small painter conventions, such as sizing wrappers, clipping rules and review checks, so custom graphics behave predictably across screens.

## Symptom 1: nothing appears A developer writes a painter, puts `CustomPaint(painter: SparklinePainter(data))` inside a `Column`, and sees nothing. The painter's `paint` may even run, with `size` equal to `Size(0.0, 0.0)`. The cause is layout. `CustomPaint` without a child uses `RenderCustomPaint.computeSizeForNoChild`, which returns `constraints.constrain(preferredSize)`, where `preferredSize` is the widget's `size` argument. That argument **defaults to `Size.zero`**. Under **loose** constraints (a `Column`'s cross axis, a `Center`, a `Row`'s main axis) zero is allowed, so the box is zero by zero. Ways to give the painter room: | Approach | Result | |---|---| | `CustomPaint(size: const Size(200, 48), ...)` | asks for that size, within constraints | | wrap in `SizedBox(width: 200, height: 48, child: CustomPaint(...))` | tight constraints force the size | | `SizedBox.expand(child: CustomPaint(...))` | fills the parent's maximum constraints, which must be bounded | | `Expanded(child: CustomPaint(size: Size.infinite, ...))` in a `Column` | `Expanded` fixes the height; `Size.infinite` is constrained down to the maximum width | | give `CustomPaint` a `child` | the box takes the child's size | A trap in that table: `Expanded` makes only the **main axis** tight. In a `Column` with the default cross-axis alignment the width stays loose, so a plain `Expanded(child: CustomPaint(...))` gets the full height and **zero width**. Under constraints that are tight on both axes, the `size` default does not matter: the constraints win. ## Symptom 2: the drawing spills outside The opposite bug: a chart's line shoots past the edge of its card because one value is out of range. The `CustomPainter.paint` docs are explicit: paint operations should stay inside the given area, and operations outside the bounds **may be silently ignored, clipped, or not clipped**. `CustomPaint` does not add a clip for you. Fixes: 1. **Clip first.** Call `canvas.clipRect(Offset.zero & size)` at the start of `paint`. `Offset.zero & size` builds a `Rect` from the origin with the box's size. 2. **Clip to a shape.** Build a `Path` (for example a rounded rectangle via `addRRect`) and call `canvas.clipPath(path)` to confine drawing to it; `clipRRect` is a shortcut for rounded rectangles. 3. **Clamp the data.** Scale values into the box so nothing is drawn outside in the first place, which avoids clip work entirely. How expensive different clips are, especially anti-aliased path clips, is a raster-performance question; the rule here is correctness: never rely on unclipped overflow. ## save and restore The canvas keeps a **stack** of transform and clip states: - `canvas.save()` pushes the current state; - `canvas.translate`, `rotate`, `scale`, `clipRect`, `clipPath` modify the current state; - `canvas.restore()` pops back to the saved state. A typical pattern draws a rotated needle on a gauge: ```dart canvas.save(); canvas.translate(size.width / 2, size.height / 2); canvas.rotate(angle); canvas.drawLine(Offset.zero, Offset(0, -size.height / 2), needlePaint); canvas.restore(); ``` `RenderCustomPaint` itself calls `canvas.save()` before invoking each painter and restores afterwards. In **debug mode** it checks the save count, and if your painter called `save()` or `saveLayer()` more times than `restore()`, it throws an error naming the painter and the imbalance. The docs warn that unbalanced saves can affect all later painting on the canvas "with potentially hilarious but confusing results". ## Building shapes with Path Anything more complex than rectangles and circles is a `Path`: - start with `moveTo`, extend with `lineTo`, `quadraticBezierTo`, `cubicTo` or `arcTo`, and finish with `close` for a closed shape; - add whole primitives with `addRect`, `addRRect` and `addOval`; - `fillType` decides how overlapping sub-paths fill: `PathFillType.nonZero` by default, or `evenOdd` to punch holes; - `path.contains(offset)` answers whether a point lies inside, which is handy in `hitTest`. A path can be drawn (`drawPath`), used as a clip (`clipPath`), or both: clip a chart's plot area to a rounded path, then draw grid lines that are cut cleanly at its edge. ## A checklist for a new painter - Give the `CustomPaint` a real size: `size`, a child, or tight constraints. - Log or assert `size` once while developing; a zero size explains a blank painter instantly. - Clip or clamp so drawing stays within `Offset.zero & size`. - Pair every `save` with a `restore`. - Remember that `Paint` defaults to `PaintingStyle.fill`; an outline needs `style = PaintingStyle.stroke` and a `strokeWidth`.

  • A childless CustomPaint is wrapped in Expanded inside a Column and is still invisible. Why?
    `Expanded` forces a tight height, but the `Column`'s cross axis stays loose unless `crossAxisAlignment` is `stretch`, so the width falls back to `size.width`, zero by default. Pass `size: Size.infinite`, which is constrained to the maximum width, wrap the painter in `SizedBox.expand`, or stretch the column.
  • A painter's outline is drawn as a filled shape. What was forgotten?
    `Paint.style` defaults to `PaintingStyle.fill`, so `drawPath` or `drawRect` fills the shape. For an outline set `style = PaintingStyle.stroke` and a `strokeWidth`; a stroke width of 0 draws a hairline.

saying these in an interview costs you the question

  • A childless CustomPaint automatically fills the space its parent offers.
  • CustomPaint clips everything the painter draws to its bounds.
  • An extra canvas.save() without restore is harmless.
  • Paint draws outlines by default.
  • CustomPaint's size argument overrides tight constraints from the parent.