DEV Community

Cover image for Dropping Below the Widget Layer: Writing a RenderObject From Scratch
K M Shahriar Hossain
K M Shahriar Hossain

Posted on Originally published at devshakib.jumyn.com

Dropping Below the Widget Layer: Writing a RenderObject From Scratch

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, your StatelessWidget — all just descriptions, closer to a blueprint than a building.
  • The element tree is the bookkeeping layer. 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.
  • The render tree is where the actual work happens: measuring, positioning, painting, and hit-testing. This is 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.

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:

  1. Layout that depends on content. A 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.
  2. Real children. 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.
  3. Hit-testing beyond the bounding box. A 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.
  4. Intrinsic sizes. Ask a 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.

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 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.
  • You must not read 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> {}
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: 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.
  • 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.
  • 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 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.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.

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 (RenderSliverMultiBoxAdaptor territory, driven by a SliverChildBuilderDelegate) 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 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.

A checklist for shipping a RenderObject you won't regret

Before you consider it done, walk this list:

  1. Every setter guards for equality and calls the correct markNeedsLayout or markNeedsPaint. Layout for geometry, paint for appearance.
  2. performLayout sets size, and the size satisfies constraints. Run in debug; the asserts are your friend here, not your enemy.
  3. Children are laid out with the right parentUsesSize flag. true if your size or child positions depend on their sizes.
  4. Paint uses PaintingContext helpers and paintChild, passes needsCompositing honestly, and never touches child.paint directly.
  5. Hit-testing routes to children via the parent-data offsets, and non-rectangular shapes override hitTestSelf.
  6. Semantics are described for anything interactive you draw yourself.
  7. Intrinsics are implemented (computeMinIntrinsicWidth and friends) if a parent like IntrinsicHeight or Table might ever wrap you; otherwise they throw at runtime.
  8. 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 CustomPaint for pure drawing inside a size that is already decided.
  • Drop to 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.
  • Respect the contract: constraints go down, sizes come up, the parent sets position. Set size, satisfy constraints, and never lay out in paint or paint in layout.
  • Pick the right dirty flag: markNeedsLayout for geometry, markNeedsPaint for appearance — and guard your setters so rebuilds don't relayout the world.
  • Use the framework's helpersdefaultPaint, 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 RenderBox is 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.

Top comments (0)