A designer on my team once handed me a “simple” gallery: variable-height cards packed like a Pinterest board, tappable, with a staggered fade-in as they landed. I burned two days forcing it out of Wrap, then GridView, then a CustomScrollView with a delegate I bent into a pretzel. Every version was wrong on the last row, janky on scroll, or quietly O(n²). Then I stopped fighting the widget tree and wrote about 120 lines of RenderBox. It laid out correctly on the first try, painted in one pass, and I have not touched it since.
That is the pattern I want to talk about. Widgets and CustomPaint cover the easy 90% of Flutter’s rendering story, and most of us live there for entire careers without a scratch. But the real leverage — the stuff the built-ins genuinely cannot express — lives one layer down, in the render tree. This post is about when to write a custom RenderObject in Flutter, and exactly how to do it, without breaking the framework’s layout and painting contracts on the way down.
The three trees: widget, element, and render
Flutter runs three trees in parallel, and understanding the split is the whole game. If you have ever wondered what actually happens between calling build() and seeing pixels, this is it.
- The widget tree is your configuration. Widgets are immutable, cheap, thrown away and rebuilt constantly.
Padding,Row, yourStatelessWidget— all just descriptions, closer to a blueprint than a building. - The element tree is the bookkeeping layer.
Elementinstances are long-lived, hold state, decide what to reuse across rebuilds, and connect widgets to their render objects. You rarely touch these directly, butBuildContextis an element under the hood. - The render tree is where the actual work happens: measuring, positioning, painting, and hit-testing. This is
RenderObjectand its subclasses, chieflyRenderBox(the 2D box protocol) andRenderSliver(the scrollable viewport protocol).
Most developers never leave the widget tree because they don’t have to. Row is a widget wrapping a RenderFlex. Padding wraps a RenderPadding. Opacity wraps a RenderOpacity. Every layout primitive you use is a thin widget over a render object someone at Google already wrote. You compose those primitives and the framework does the rest — that is composition doing its job.
The reason to go lower is that composition has a ceiling. When your layout depends on measuring children against each other, when you need real hit-testing on non-rectangular shapes, when intrinsic sizes matter, or when you are redoing the same expensive layout math every frame because the widget layer left you no cheaper path — that is the render tree calling. Not before. I have watched engineers reach for a custom render object the way some people reach for a rewrite: as a way to feel productive while avoiding the boring composition that would have shipped yesterday. Resist that. A custom RenderObject is a scalpel, not a hammer.
When CustomPaint stops being enough
CustomPaint is the escape hatch everyone reaches for first, and for good reason. Custom drawing, a CustomPainter, done. I use it constantly for charts, progress rings, signature pads, and decorative flourishes. If your problem is “draw pixels inside a box whose size is already decided,” CustomPaint is the correct tool and you should not write a RenderObject. Don’t over-engineer a solved problem.
It stops being enough the moment any of these show up:
-
Layout that depends on content. A
CustomPainteris handed aSizeand paints inside it. It cannot say “I want to be exactly as tall as the tallest thing I contain.” If your widget’s size is a function of what’s inside it, you needperformLayout, which painters don’t have. -
Real children.
CustomPaintcan take a single child, but that is the ceiling. If you need to lay out and paint an arbitrary list of child widgets — position them, size them, let them handle their own gestures — a painter can’t hold them. Painters draw; they don’t parent. -
Hit-testing beyond the bounding box. A
GestureDetectoraround aCustomPaintgives you a rectangle. If you drew a hexagonal button or a pie chart and taps in the dead corners shouldn’t count, the painter has no say in hit-testing. -
Intrinsic sizes. Ask a
CustomPaint“how wide do you want to be given unlimited height?” and it shrugs.RenderBoxexposescomputeMinIntrinsicWidthand its siblings, whichIntrinsicHeight,Table, and text baselines actually query.
When two or more of those are true at once, stop stacking widgets. Write the render object.
Anatomy of a RenderBox: the constraints contract
The heart of Flutter layout is one sentence, and it’s worth tattooing somewhere: constraints go down, sizes go up, and the parent sets position.
A parent hands each child a BoxConstraints — min/max width and min/max height. The child must pick a size that satisfies those constraints and report it back. The parent then decides where to place the child. A child never picks its own position, and it never sees its siblings. That decoupling is exactly what makes Flutter layout single-pass and fast: each box is visited once, top-down for constraints and bottom-up for sizes.
A minimal RenderBox implements a handful of methods. The load-bearing one is performLayout:
class RenderSquare extends RenderBox {
@override
void performLayout() {
// Read the constraints the parent gave us, pick a size, report it.
final double side = constraints.constrainWidth(200);
size = Size(side, side);
}
@override
void paint(PaintingContext context, Offset offset) {
final paint = Paint()..color = const Color(0xFF2962FF);
context.canvas.drawRect(offset & size, paint);
}
}
Enter fullscreen mode Exit fullscreen mode
Two rules trip up everyone the first time:
- You must set
sizeinsideperformLayout, and it must satisfyconstraints. Return a size outside the given min/max and the framework asserts in debug and misbehaves in release.constraints.constrain(desiredSize)clamps for you, so lean on it. - You must not read
sizefrom anywhere except during and after layout. Readingsizein your ownperformLayoutbefore you’ve assigned it is a classic own-goal, and the assert message that catches it (RenderBox was not laid out) is one you will learn to recognize.
If your box takes children, you don’t subclass RenderBox raw — you mix in ContainerRenderObjectMixin and RenderBoxContainerDefaultsMixin, and you attach a ParentData object to each child to stash its offset. The parent data is where “the parent sets position” physically lives.
class FlowParentData extends ContainerBoxParentData<RenderBox> {}
Enter fullscreen mode Exit fullscreen mode
ContainerBoxParentData already carries an offset field. That offset is the child’s position relative to the parent, written by the parent during layout and read back during paint and hit-test. This is the spine of everything that follows.
Building a real one: a custom masonry layout
Let me build the thing that sent me down here in the first place — a masonry / column-flow layout. The rule: N columns of fixed width, and each child drops into whichever column is currently shortest. The built-ins can’t express this because GridView assumes uniform cell heights and Wrap flows in rows, not balanced columns.
Here’s the render object. It’s the whole point of the post, so read it slowly.
class RenderMasonry extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, FlowParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, FlowParentData> {
RenderMasonry({required int columns, required double gap})
: _columns = columns,
_gap = gap;
int _columns;
set columns(int value) {
if (_columns == value) return;
_columns = value;
markNeedsLayout();
}
double _gap;
set gap(double value) {
if (_gap == value) return;
_gap = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! FlowParentData) {
child.parentData = FlowParentData();
}
}
@override
void performLayout() {
final double totalGap = _gap * (_columns - 1);
final double columnWidth =
(constraints.maxWidth - totalGap) / _columns;
// Track the running height of each column.
final columnHeights = List<double>.filled(_columns, 0.0);
final childConstraints = BoxConstraints(
minWidth: columnWidth,
maxWidth: columnWidth,
);
RenderBox? child = firstChild;
while (child != null) {
child.layout(childConstraints, parentUsesSize: true);
// Find the shortest column.
int target = 0;
for (int i = 1; i < _columns; i++) {
if (columnHeights[i] < columnHeights[target]) target = i;
}
final double dx = target * (columnWidth + _gap);
final double dy = columnHeights[target];
(child.parentData as FlowParentData).offset = Offset(dx, dy);
columnHeights[target] += child.size.height + _gap;
child = childAfter(child);
}
final double tallest =
columnHeights.reduce((a, b) => a > b ? a : b);
size = constraints.constrain(
Size(constraints.maxWidth, tallest),
);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
Enter fullscreen mode Exit fullscreen mode
A few things worth pointing at, because they’re the difference between “works” and “works and doesn’t fight the framework”:
-
child.layout(childConstraints, parentUsesSize: true)is how the parent measures a child.parentUsesSize: truetells the framework that my layout depends on the child’s resulting size, so if that child relays out, I need to relay out too. Get this flag wrong and you get stale layouts that only fix themselves on the next unrelated rebuild — a genuinely nasty bug to chase, because it looks intermittent. - I set tight width constraints (
minWidth == maxWidth) so each card fills its column exactly, but I leave height unconstrained, so cards size themselves to their content. That asymmetry is the whole “masonry” effect in one line. -
defaultPaintanddefaultHitTestChildrencome from the mixins and do the right thing: they walk children, apply each one’s parent-data offset, and paint/hit-test in order. Don’t reinvent them.
The widget wrapper
The widget is boring on purpose — it’s a MultiChildRenderObjectWidget that creates and updates the render object:
class Masonry extends MultiChildRenderObjectWidget {
const Masonry({
super.key,
this.columns = 2,
this.gap = 8,
required super.children,
});
final int columns;
final double gap;
@override
RenderMasonry createRenderObject(BuildContext context) =>
RenderMasonry(columns: columns, gap: gap);
@override
void updateRenderObject(BuildContext context, RenderMasonry ro) {
ro
..columns = columns
..gap = gap;
}
}
Enter fullscreen mode Exit fullscreen mode
Note that the setters call markNeedsLayout(). updateRenderObject fires on every rebuild, but the equality guards inside the setters make sure we only actually relay out when a value truly changed. That’s the render tree’s version of a rebuild optimization, and it matters more here because layout is expensive. Skipping those guards is the most common performance regression I see in home-grown render objects — every parent rebuild silently marks the whole subtree dirty.
Painting with the layer system and PaintingContext
Naive painting means drawing straight onto context.canvas. That’s fine for opaque shapes. But the moment you need clipping, opacity, transforms, or repaint isolation, you should go through PaintingContext, because that’s what talks to Flutter’s layer system and, ultimately, the compositor thread.
The distinction that matters in practice: context.canvas draws into the current layer, while methods like context.pushClipRect, context.pushOpacity, and context.pushLayer create new composited layers the GPU can handle cheaply. If you want a subtree to repaint independently of its parent, you push it into its own layer. This is exactly why RepaintBoundary exists — it’s a render object that forces a fresh layer so a repaint on one side doesn’t smear across the whole screen and re-rasterize everything.
If I wanted my masonry cards clipped to rounded corners without wrapping each one in a ClipRRect widget, I’d do it in paint:
@override
void paint(PaintingContext context, Offset offset) {
RenderBox? child = firstChild;
while (child != null) {
final childOffset =
offset + (child.parentData as FlowParentData).offset;
context.pushClipRRect(
needsCompositing,
childOffset,
Offset.zero & child.size,
RRect.fromRectAndRadius(
Offset.zero & child.size,
const Radius.circular(12),
),
(ctx, off) => ctx.paintChild(child!, off),
);
child = childAfter(child);
}
}
Enter fullscreen mode Exit fullscreen mode
Two things I learned the slow way here. First, needsCompositing is a real signal — pass it through, don’t hardcode true, because forcing compositing everywhere allocates layers you didn’t need and quietly costs you memory and frame time. Second, context.paintChild is not optional sugar. It’s how the framework knows whether a child needs its own layer and wires the tree together correctly. Calling child.paint directly bypasses that machinery and will bite you when the child is itself a RepaintBoundary or needs compositing.
Hit-testing, gestures, and routing taps to children
A render object that lays out and paints but doesn’t hit-test is a picture, not a widget. Taps have to find their way to your children, and Flutter walks the render tree in reverse paint order to route them.
For the common case, the mixin default is correct, but it helps to see the shape of the contract you’re implementing:
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (size.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
Enter fullscreen mode Exit fullscreen mode
The contract: return true if the point hit you or a descendant, and if so, add yourself to the result. defaultHitTestChildren handles the child walk, translating the position by each child’s parent-data offset — the same offset you wrote during layout, now read in reverse. This is why getting the offset right in performLayout pays off three times: layout, paint, and hit-test all lean on it.
For non-rectangular shapes, override hitTestSelf and do the geometry yourself. A pie-chart segment, for instance, would test the angle and radius of position against the wedge before accepting the tap — that is the whole reason you dropped below GestureDetector‘s rectangle in the first place.
Semantics: don’t ship a render object screen readers can’t see
Don’t skip semantics. A custom render object is invisible to screen readers unless you describe it. For a container that just positions children — like the masonry above — the children carry their own semantics and you get accessibility for free. But if you draw interactive things yourself, implement describeSemanticsConfiguration and, where the visual order differs from child order, override visitChildrenForSemantics.
On one client project we shipped a custom chart that was completely opaque to accessibility tooling until we added semantics — a real bug, not a nice-to-have, and in some markets a legal compliance requirement. Treat semantics as part of “done,” not a stretch goal.
Two framework invariants you must not break
The framework enforces a strict separation of phases, and it does not forgive violations:
-
Never lay out during paint, and never paint during layout. The phases are separate for a reason — the compositor and the layout pipeline run on different assumptions. If you find yourself calling
child.layoutinsidepaint, you’ve made a mistake the framework can’t recover from cleanly, and you’ll usually see it as an assertion or a corrupted frame. -
markNeedsLayoutvsmarkNeedsPaint. If only appearance changed (a color, a shadow), callmarkNeedsPaint— cheap, paint-only. If size or position could change, callmarkNeedsLayout, which also implies a repaint. CallingmarkNeedsLayoutfor a color change works but relayouts the world for no reason. CallingmarkNeedsPaintwhen geometry actually changed gives you a stale, broken layout. Pick correctly every time; this single decision is most of what separates a smooth render object from a janky one.
RenderBox vs. Sliver: know which door you need
Here’s the trap I fell into on that first gallery: I reached for a custom RenderBox when I actually needed a custom sliver.
The distinction is about scrolling. A RenderBox works in the 2D box protocol — it’s laid out once against BoxConstraints and it’s fully realized in memory. A RenderSliver works in the viewport protocol: it’s laid out against SliverConstraints that tell it how much has scrolled past, how much viewport is left, and in which direction. Slivers can lay out lazily — only building the children currently near the visible region — which is why an infinite ListView doesn’t build a million widgets and blow your memory budget.
Rule of thumb:
- If your custom layout lives inside a scroll view and could contain thousands of items, you probably want a custom sliver (
RenderSliverMultiBoxAdaptorterritory, driven by aSliverChildBuilderDelegate) so off-screen children aren’t built. - If your layout is a bounded, fully-visible region — a card, a widget, a fixed gallery of a few dozen items — a
RenderBoxis right and far simpler.
My masonry above is a RenderBox. That’s fine for a screen’s worth of cards. If it needed to scroll through ten thousand images without building them all, I’d have to rewrite it as a sliver adaptor that only lays out the visible window — and lazy masonry is genuinely one of the harder objects to write, because column heights depend on children you haven’t measured yet. The honest advice: don’t reach for it until a RenderBox version has actually shown you a performance problem. Premature slivers are their own tar pit.
A checklist for shipping a RenderObject you won’t regret
Before you consider it done, walk this list:
-
Every setter guards for equality and calls the correct
markNeedsLayoutormarkNeedsPaint. Layout for geometry, paint for appearance. -
performLayoutsetssize, and the size satisfiesconstraints. Run in debug; the asserts are your friend here, not your enemy. -
Children are laid out with the right
parentUsesSizeflag.trueif your size or child positions depend on their sizes. -
Paint uses
PaintingContexthelpers andpaintChild, passesneedsCompositinghonestly, and never toucheschild.paintdirectly. -
Hit-testing routes to children via the parent-data offsets, and non-rectangular shapes override
hitTestSelf. - Semantics are described for anything interactive you draw yourself.
-
Intrinsics are implemented (
computeMinIntrinsicWidthand friends) if a parent likeIntrinsicHeightorTablemight ever wrap you; otherwise they throw at runtime. -
You wrote a golden test. Render objects are close to pure functions of constraints and children — they’re unusually easy to pin down with
matchesGoldenFile, and layout regressions are otherwise invisible until a designer notices in review.
Key takeaways
-
Stay in widgets for anything you can compose, and use
CustomPaintfor pure drawing inside a size that is already decided. -
Drop to
RenderBoxonly when layout depends on content, when you need to parent and position arbitrary children, when hit-testing must be non-rectangular, or when intrinsic sizes matter. -
Respect the contract: constraints go down, sizes come up, the parent sets position. Set
size, satisfyconstraints, and never lay out in paint or paint in layout. -
Pick the right dirty flag:
markNeedsLayoutfor geometry,markNeedsPaintfor appearance — and guard your setters so rebuilds don’t relayout the world. -
Use the framework’s helpers —
defaultPaint,defaultHitTestChildren,paintChild,needsCompositing— instead of reinventing them badly. -
Reach for a sliver only when the layout scrolls and could hold thousands of lazily-built children; otherwise a
RenderBoxis simpler and correct. - Ship it accessible and tested: describe semantics for interactive drawing, and lock the layout down with a golden test.
Dropping below the widget layer is not a flex, it’s a tool with a narrow, sharp use case. The masonry object that started all this is still running in production, unchanged, doing in 120 lines what three layers of widget hacks couldn’t. That’s the trade: a steeper wall to climb, and a much shorter one to maintain once you’re over it.
Originally published at devshakib.jumyn.com. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at pub.dev/publishers/jumyn.com.