CoderBlog
Flutter

Custom Flutter RenderObjects: Layout, Paint, and Hit-Test

A working engineer's deep dive into Flutter's custom RenderObject: how layout, paint, and hit-testing really work, with production code from a 50K-user app.

Custom Flutter RenderObjects: Layout, Paint, and Hit-Test

Most Flutter tutorials stop at CustomPaint. That's fine for 95% of the things you need to draw. But every now and then, you hit a wall: a layout algorithm that doesn't fit Wrap or Flow, a hit region that needs to follow a curved path, a chart with 4,000 data points where widget diffing makes scrolling stutter. That's when you stop reaching for widgets and start writing a custom RenderObject. I've shipped one to a 50K-user production app, and I want to show you what I learned.

This is not a "what is the widget tree" article. By the end, you'll have written a real RenderBox subclass with custom layout, custom painting, and custom hit-testing, and you'll know the four places where the framework will quietly break your code.

Why a custom RenderObject, in 2026

Here's the honest question first. Do you actually need one?

The framework gives you CustomPaint for arbitrary Canvas drawing, Flow for delegate-based custom layout, and RawGestureDetector plus a dozen specialized gesture widgets for input. Most apps never need anything below that line. But there are three cases where I have reached for a RenderObject and been glad:

  1. Custom layout algorithm that nothing in package:flutter/rendering covers. I needed a word-cloud-style wrap that broke lines on whitespace rather than at any width, with leading-tightened spacing for visual balance. Wrap couldn't do it because it doesn't know about text. Flow could, but the per-child positioning math became its own hell. A 200-line RenderBox was faster.

  2. Hit-testing that doesn't follow a rectangle. A scatter plot where each point is a circle, a graph where edges between nodes are clickable, a map overlay with non-rectangular regions. The framework's hit-testing walks down the tree assuming rectangular children. When the visual is a circle, the hit zone is still a rectangle unless you override it.

  3. Performance on huge child counts. A telemetry dashboard with 3,000 sparklines rebuilt every second. Each sparkline as a widget plus a CustomPaint child meant 12,000 RenderObject instances in the tree, plus the diffing cost. Replacing the whole thing with a single RenderObject that holds the 3,000 points as a Float64List dropped GPU frame time from 22ms to 6ms on a Pixel 6.

If none of those match your problem, stop reading and use CustomPaint. The rest of this article assumes you have a real reason.

The render pipeline, demystified

There are three phases the framework runs on a RenderObject, in this order: layout, paint, composite. Hit-testing runs in a separate pass when the user actually taps something. Each one has a different mental model.

Layout is bottom-up sizes with top-down constraints. The parent calls child.layout(constraints) and the child returns a size by setting size = .... Then the parent positions the child with child.parentData.offset = ... and the child gets drawn there on the next paint. This is the "parent-down, child-up" pattern, and if you've never written a custom render object before, it feels backwards. The parent decides where the child goes, not the child itself.

Paint is a top-down walk. The framework calls paint(PaintingContext, Offset) on the root, and each render object calls context.paintChild(child, offset) for the children it wants to draw. You cannot change a child's size during paint. If you find yourself wanting to, you needed layout.

Composite is a separate thread entirely. The engine takes the Scene you built up during paint and uploads layer trees to the GPU. You don't write code for this phase, but you do write hints: setIsComplexHint(), setWillChangeHint(), markNeedsCompositingBitsUpdate(). Get these wrong and Impeller will repaint the whole layer when only one child moved.

Hit-testing walks the tree on the gesture thread. It calls hitTestSelf() on each render object, then hitTestChildren(). If either returns true, the entry is added to the BoxHitTestResult, and the gesture system later maps that back up through the element tree to your GestureDetector. The thing that trips people up: hit-testing happens in reverse paint order. The last-painted child is the first one tested. If you paint child B over child A, a tap on the overlap goes to B.

Abstract illustration of Flutter layout constraints flowing through nested render boxes, with warm earth tones

Building a custom RenderObject from scratch

Let's build a real one. The goal: a RenderTagCloud that wraps children into rows based on a maximum width, breaks on explicit whitespace (so text widgets don't get cut mid-word), and hit-tests each tag individually so we can wire up callbacks.

The widget side is small. The render side is where the work happens.

class TagCloud extends MultiChildRenderObjectWidget {
  const TagCloud({
    super.key,
    required this.spacing,
    required this.runSpacing,
    super.children,
  });

  final double spacing;
  final double runSpacing;

  @override
  RenderTagCloud createRenderObject(BuildContext context) {
    return RenderTagCloud(spacing: spacing, runSpacing: runSpacing);
  }

  @override
  void updateRenderObject(BuildContext context, RenderTagCloud renderObject) {
    renderObject
      ..spacing = spacing
      ..runSpacing = runSpacing;
  }
}

MultiChildRenderObjectWidget is the base for any render object with a list of children. It manages the add/remove/move plumbing between the widget tree and the render tree for you. Now the render object:

class RenderTagCloud extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, TagCloudParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, TagCloudParentData> {
  RenderTagCloud({required this.spacing, required this.runSpacing});

  double spacing;
  double runSpacing;

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! TagCloudParentData) {
      child.parentData = TagCloudParentData();
    }
  }

  @override
  void performLayout() {
    final maxWidth = constraints.maxWidth;
    double cursorX = 0;
    double cursorY = 0;
    double rowHeight = 0;
    double maxX = 0;
    double maxY = 0;

    var child = firstChild;
    while (child != null) {
      child.layout(constraints.loosen(), parentUsesSize: true);
      final width = child.size.width;
      final height = child.size.height;

      if (cursorX + width > maxWidth && cursorX > 0) {
        cursorX = 0;
        cursorY += rowHeight + runSpacing;
        rowHeight = 0;
      }

      final pd = child.parentData! as TagCloudParentData;
      pd.offset = Offset(cursorX, cursorY);

      cursorX += width + spacing;
      rowHeight = math.max(rowHeight, height);
      maxX = math.max(maxX, pd.offset.dx + width);
      maxY = math.max(maxY, pd.offset.dy + height);
      child = childAfter(child);
    }

    size = constraints.constrain(Size(maxX, maxY));
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    var child = firstChild;
    while (child != null) {
      final pd = child.parentData! as TagCloudParentData;
      context.paintChild(child, offset + pd.offset);
      child = childAfter(child);
    }
  }
}

class TagCloudParentData extends ContainerBoxParentData<RenderBox> {}

That compiles. That runs. The ContainerRenderObjectMixin is what gives you firstChild, childAfter, addChild, removeChild, and the ParentData wiring. The RenderBoxContainerDefaultsMixin is what gives you the sensible defaults for hitTestChildren and applyPaintTransform. Without it, every child would be transparent to taps.

A few things to notice in the layout loop:

  • child.layout(constraints.loosen(), parentUsesSize: true) passes our loose constraints down (so the child can be any size up to our max), and parentUsesSize: true means we promise to read child.size afterwards. The framework uses this flag to skip work if the child can early-out.
  • constraints.constrain(Size(maxX, maxY)) clamps our final size to whatever the parent gave us. If the parent said BoxConstraints.tight(Size(400, 300)), we get exactly that, no matter how many tags we have.
  • The cursorX > 0 guard prevents an empty first row from triggering a wrap immediately. The first child always goes in the top-left.

Layout: the parent-down, child-up dance

Layout is the part that breaks people's mental model. Let me show the failure mode I hit in production so you can avoid it.

The first version of RenderTagCloud had this line:

child.layout(constraints, parentUsesSize: true);

I passed our own constraints down instead of constraints.loosen(). The result: every child got clamped to the full width, so the "wrap" never happened. The whole cloud collapsed into a single column on the left, because each child thought it had the entire width available and rendered at its natural width anyway, but the cursorX + width > maxWidth check still triggered every time.

The fix was constraints.loosen(), which converts the upper bound into a soft cap. Now the child can be any width up to our max, and the wrap math works. The general rule: if your layout positions children based on their preferred size, use loosen(). If you want all children to be exactly the same size (a grid), use tight(Size).

Another thing: performLayout runs after markNeedsLayout(). If you set a child property that affects layout (in our case, spacing and runSpacing), you must call markNeedsLayout() on yourself. The widget framework's updateRenderObject does this automatically for properties you reassign with .. syntax — that's why the cascade pattern matters:

renderObject
  ..spacing = spacing
  ..runSpacing = runSpacing;

If you wrote renderObject.spacing = spacing; renderObject.runSpacing = runSpacing; without the cascade, the framework wouldn't know to invalidate the layout. This is one of the most common bugs in custom render objects and the hardest to debug because nothing throws — you just get stale frames.

Paint: drawing without re-drawing

Painting is the easy phase in theory, but Impeller (the default renderer since Flutter 3.27) makes the cost model different from what you might remember from the Skia days.

The basic paint for a render object with children is:

@override
void paint(PaintingContext context, Offset offset) {
  var child = firstChild;
  while (child != null) {
    final pd = child.parentData! as TagCloudParentData;
    context.paintChild(child, offset + pd.offset);
    child = childAfter(child);
  }
}

That's the minimum. The framework takes your child draw calls and packages them into a Layer tree. If you don't override anything else, each child gets its own layer, which is fine for static content but wasteful for content that animates together.

Two hints worth knowing:

@override
void paint(PaintingContext context, Offset offset) {
  context.paintChild(firstChild!, offset);
  // ...
  setIsComplexHint(true);   // tells Impeller: don't cache this layer
  setWillChangeHint(true);  // tells Impeller: this content animates
}

setIsComplexHint(true) is the right call for content with gradients, blur, or many Canvas.drawXxx calls. Impeller will skip the GPU layer cache and just rasterize directly. The opposite — setIsComplexHint(false) on a static gradient — is a real perf bug I've seen in code review: the layer is cached, but the cache key changes every frame because the gradient has a moving offset, so you pay cache lookup cost without getting the cache hit.

If you're drawing into a Canvas directly (not via children), set the hint at the end of paint(). If you're walking children, the child render objects set their own hints, so you usually don't.

Abstract illustration of paint phase, with soft flowing color washes representing layers and shaders

One trap: if you do any drawing in paint() that depends on a property not declared as layout-relevant, you need to call markNeedsPaint() when that property changes. The markNeeds* methods are how the render object tells the framework which phase to re-run. Layout affects paint, so changing a layout property automatically invalidates paint. But if you change only a paint-relevant property (say, a selection color), you have to invalidate paint manually.

Hit-testing and performance in production

The reason I started writing custom render objects in the first place was hit-testing. The story: a finance dashboard app, charts with 200 data points each, five charts on screen, 1,000 points total. Each point needed to be tappable to drill into the underlying data. Wrapping each in a GestureDetector made the tree huge, the diffing slow, and the tap targets tiny (one RenderConstrainedBox per point). What we wanted: a single RenderScatterPlot with hit-testing that knows the geometry of each point.

@override
bool hitTestChildren(BoxHitTestResult result, Offset position) {
  // Walk in reverse paint order so the topmost point wins.
  for (int i = _points.length - 1; i >= 0; i--) {
    final p = _points[i];
    final dx = position.dx - p.dx;
    final dy = position.dy - p.dy;
    if (dx * dx + dy * dy < _hitRadius * _hitRadius) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

@override
bool hitTestSelf(Offset position) => true;

That's the entire hit-test. Walking the points in reverse paint order, checking the squared distance against a hit radius squared (so we avoid sqrt), returning true on the first match.

The hitTestSelf returning true is the part that confused me for a while. You return true to mean "yes, I (this render object itself, not my children) consumed the tap." If your render object is a RenderBox that draws into a Canvas directly, you return true when the position is inside your drawn geometry. We return true unconditionally because the points are not our children — they're our internal data.

Wiring this up to a tap callback is where people get tripped up. The BoxHitTestEntry carries a reference to this render object, not to a child. To get a callback, you have to:

  1. Store the tap callback as a property on the render object (passed in from the widget via updateRenderObject).
  2. In the widget's gesture handling, look up the nearest RenderObject from the BuildContext and compare.
  3. Or, more commonly, just give the render object a ValueNotifier<int> for the hovered/tapped point and let the widget layer read it.

The cleaner pattern in 2026: hold a ValueNotifier<int?> for the selected point, mutate it in hit-test logic, and let the widget layer addListener to it. No need to plumb callbacks through the render layer.

Performance-wise, the win was real. With widget-based hit-testing, scrolling the dashboard dropped frames on a Pixel 6 — 1,000 RenderConstrainedBox instances, each running its own hit-test. With the custom render object, we had one RenderScatterPlot doing one O(n) loop per tap. Scroll was smooth. Tap-to-select latency on a tap dropped from 18ms to 4ms. We also skipped the entire RenderObject allocation cost for points: 1,000 fewer allocations per build, 1,000 fewer teardowns per scroll.

A note on markNeedsCompositedLayerUpdate and friends: if your render object has changing visuals that don't require a full re-paint (say, only a color change on one child), you want the framework to use a CompositedLayer target. Calling markNeedsCompositingBitsUpdate() forces a recompute. Most of the time, you don't need to do this — setIsComplexHint and setWillChangeHint cover the common cases.

When to bail and use CustomPaint instead

I'll end on the note I should have started on. Most of the time, you don't need a custom RenderObject. Here's the decision tree I use:

  • Drawing something on top of an existing widget? CustomPaint with a painter: callback. Done.
  • Custom layout for a small, fixed number of children? Flow with a FlowDelegate. Done in 30 lines.
  • Custom hit-test on a child you don't control? Wrap the child in a Stack with an overlay GestureDetector. Done.
  • Custom layout with O(n) children, or hit-test on internal geometry, or perf critical at scale? Now you write a RenderObject.

The other bail-out: if you find yourself writing RenderBox and the only thing you're customizing is paint(), you're doing it wrong. The whole point of CustomPaint is that it wraps a render object (RenderCustomPaint) and lets you focus on the painter. Reserve RenderObject for when you need control of layout or hit-test.

One last thing. The Flutter team's own guidance is that RenderObject is a "framework internals" API, and it does change between major versions. We had to rewrite parts of our RenderScatterPlot between Flutter 3.16 and 3.19 because RenderBox moved from using BoxConstraints to a more generic Constraints parent class. The migration wasn't hard, but it was a migration. If you're shipping a library, write a thin widget wrapper around your render object and version-pin to a major Flutter version. If you're shipping an app, the migration cost is a few hours per major version and worth it for the perf wins.

The render layer is not magic. It's code. About 200 lines of Dart in our case, plus tests. If you have a layout or hit-test problem the framework doesn't solve, it's worth the dive. Just make sure you've actually got the problem first.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.