In Flutter, why might a CustomPaint draw nothing or spill outside its area, and how do size, clipping and save/restore fix it?
answer
- no child: size defaults to zero
- constraints.constrain(size)
- paint is not clipped for you
- clipRect(Offset.zero & size)
- unbalanced save throws in debug
basics
~20 sWithout 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 sA 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
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.
Explain constrain(size) under loose versus tight constraints, why overflow is not clipped for you, and how save and restore scope transforms and clips.
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.
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.