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:
Custom layout algorithm that nothing in
package:flutter/renderingcovers. I needed a word-cloud-style wrap that broke lines on whitespace rather than at any width, with leading-tightened spacing for visual balance.Wrapcouldn't do it because it doesn't know about text.Flowcould, but the per-child positioning math became its own hell. A 200-lineRenderBoxwas faster.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.
Performance on huge child counts. A telemetry dashboard with 3,000 sparklines rebuilt every second. Each sparkline as a widget plus a
CustomPaintchild meant 12,000RenderObjectinstances in the tree, plus the diffing cost. Replacing the whole thing with a singleRenderObjectthat holds the 3,000 points as aFloat64Listdropped 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.
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), andparentUsesSize: truemeans we promise to readchild.sizeafterwards. 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 saidBoxConstraints.tight(Size(400, 300)), we get exactly that, no matter how many tags we have.- The
cursorX > 0guard 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.
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:
- Store the tap callback as a property on the render object (passed in from the widget via
updateRenderObject). - In the widget's gesture handling, look up the nearest
RenderObjectfrom theBuildContextand compare. - 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?
CustomPaintwith apainter:callback. Done. - Custom layout for a small, fixed number of children?
Flowwith aFlowDelegate. Done in 30 lines. - Custom hit-test on a child you don't control? Wrap the child in a
Stackwith an overlayGestureDetector. 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.
Comments
Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.