devShakib

Dropping Below the Widget Layer: Writing a RenderObject From Scratch

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.

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.

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:

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);  }}

Two rules trip up everyone the first time:

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.

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);  }}

A few things worth pointing at, because they're the difference between "works" and "works and doesn't fight the framework":

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;  }}

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:

@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.

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:

@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.

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:

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:

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:

Key takeaways

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.