Learn when and how to write a custom Flutter RenderObject from scratch: RenderBox layout, the constraints contract, painting, hit testing, semantics, and slivers.
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.
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.
Padding, Row, your StatelessWidget — all just descriptions, closer to a blueprint than a building.Element instances are long-lived, hold state, decide what to reuse across rebuilds, and connect widgets to their render objects. You rarely touch these directly, but BuildContext is an element under the hood.RenderObject and its subclasses, chiefly RenderBox (the 2D box protocol) and RenderSliver (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.
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:
CustomPainter is handed a Size and 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 need performLayout, which painters don't have.CustomPaint can 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.GestureDetector around a CustomPaint gives 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.CustomPaint "how wide do you want to be given unlimited height?" and it shrugs. RenderBox exposes computeMinIntrinsicWidth and its siblings, which IntrinsicHeight, Table, and text baselines actually query.When two or more of those are true at once, stop stacking widgets. Write the render object.
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); }}Two rules trip up everyone the first time:
size inside performLayout, and it must satisfy constraints. 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.size from anywhere except during and after layout. Reading size in your own performLayout before 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> {}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.
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); }}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: true tells 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.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.defaultPaint and defaultHitTestChildren come 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 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; }}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.
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:
@overridevoid 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); }}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.
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:
@overridebool 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;}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.
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.
The framework enforces a strict separation of phases, and it does not forgive violations:
child.layout inside paint, 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.markNeedsLayout vs markNeedsPaint. If only appearance changed (a color, a shadow), call markNeedsPaint — cheap, paint-only. If size or position could change, call markNeedsLayout, which also implies a repaint. Calling markNeedsLayout for a color change works but relayouts the world for no reason. Calling markNeedsPaint when 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.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:
RenderSliverMultiBoxAdaptor territory, driven by a SliverChildBuilderDelegate) so off-screen children aren't built.RenderBox is 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.
Before you consider it done, walk this list:
markNeedsLayout or markNeedsPaint. Layout for geometry, paint for appearance.performLayout sets size, and the size satisfies constraints. Run in debug; the asserts are your friend here, not your enemy.parentUsesSize flag. true if your size or child positions depend on their sizes.PaintingContext helpers and paintChild, passes needsCompositing honestly, and never touches child.paint directly.hitTestSelf.computeMinIntrinsicWidth and friends) if a parent like IntrinsicHeight or Table might ever wrap you; otherwise they throw at runtime.matchesGoldenFile, and layout regressions are otherwise invisible until a designer notices in review.CustomPaint for pure drawing inside a size that is already decided.RenderBox only 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.size, satisfy constraints, and never lay out in paint or paint in layout.markNeedsLayout for geometry, markNeedsPaint for appearance — and guard your setters so rebuilds don't relayout the world.defaultPaint, defaultHitTestChildren, paintChild, needsCompositing — instead of reinventing them badly.RenderBox is simpler and correct.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.