#6 — Custom RenderObjects vs CustomPainter: When Composition Isn't Enough
Table of Contents
01 The Core Question: The Limits of Composition
In Flutter, "everything is a widget." You compose rows, columns, containers, and gestures to build UI. For 95% of a Flutter developer's career, this is entirely sufficient. But in a senior-level interview at a company pushing mobile boundaries (think CRED's wildly custom UI, or Swiggy's highly performant map overlays), they aren't testing your ability to stack widgets.
They are testing your ability to recognize the exact moment when composing existing widgets stops being enough, and what the two escape hatches are.
When the standard library falls short, you have to drop down into the rendering layer. But dropping down blindly is a red flag. You need a mental model to decide between CustomPainter (the paintbrush) and a custom RenderObject (the physics engine). Let's visualize this decision hierarchy:
02 CustomPainter: Drawing Without Layout Control
The first escape hatch is CustomPainter, accessed via the CustomPaint widget. Interviewers want to hear a specific definition: CustomPainter draws inside an EXISTING RenderObject's paint phase using a Canvas.
It gives you a low-level Canvas and a Size. That's it. It has absolutely zero control over layout (sizing and positioning) or hit-testing (gesture detection). It only draws within the space that the normal widget tree has already allocated for it.
The dashed area represents the canvas. You can draw anywhere inside it, but you cannot change its size.
Right tool for: Line charts, decorative wave graphics, drawing a signature pad, or adding complex visual flair over/under existing child widgets.
class WavePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// We are given 'size'. We cannot change it.
final paint = Paint()..color = Colors.blue;
canvas.drawCircle(Offset(size.width / 2, size.height / 2), 50, paint);
}
@override
bool shouldRepaint(covariant WavePainter oldDelegate) {
return false;
}
}
03 shouldRepaint(): The Optimization Trap
If you look at the snippet above, you'll see shouldRepaint(). This is a massive interview trap. Candidates often hardcode it to return true; to avoid thinking about state. What is the consequence?
If you always return true, Flutter will execute your paint() method every single frame the parent repaints, regardless of whether your visual output actually needed to change. Canvas drawing is CPU intensive. Over-repainting a complex chart will drop frames.
You must return false when the data driving your painting hasn't changed. For example, if you pass a progress variable into your painter, you should do:
return oldDelegate.progress != this.progress;
04 Custom RenderBox: The Ultimate Escape Hatch
If CustomPainter is just drawing inside a pre-sized box, what do you do when you need to decide the size of the box, or arrange multiple children dynamically? You subclass RenderBox.
A custom RenderBox gives you God-mode control over the three core phases of the rendering pipeline: performLayout(), paint(), and hitTest().
The contract is strict: you must implement performLayout() to figure out your own size based on the constraints passed down from your parent. If you have children, you must measure them first, and then position them by setting their ParentData.offset.
Instead of shouldRepaint(), you manage invalidation manually by calling markNeedsLayout() (if size/position changes) or markNeedsPaint() (if only visuals change). Calling the wrong one over-invalidates the tree.
class RenderMyCustomLayout extends RenderBox with RenderObjectWithChildMixin {
// 1. You control sizing & child positioning
@override
void performLayout() {
if (child != null) {
child!.layout(constraints, parentUsesSize: true);
size = child!.size; // Determine own size based on child
} else {
size = constraints.smallest;
}
}
// 2. You control painting natively
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
context.paintChild(child!, offset);
}
}
// 3. You control Hit-Testing (Gestures)
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
// Custom logic to intercept or redirect taps
return defaultHitTestChildren(result, position: position);
}
}
😌 Honest Take: The "Never Done It" Reality
Most senior candidates will never write a custom RenderObject in production, and that's completely fine. Flutter's widget library is incredibly robust. The interview signal isn't "have you shipped one?" The signal is "do you know it exists and can you reason about WHEN you'd reach for it?"
It is deeply respectable to tell an interviewer: "I haven't needed to write a custom RenderObject from scratch myself, but here is exactly when I would reach for one..." Honesty beats bluffing every time.
05 The Decision Rule: When Composition Fails
So, we arrive at the pivotal question interviewers are listening for: "When would you NOT just compose existing widgets?"
The definitive answer: When composition would require multiple layout passes or "measure-then-decide" logic that Flutter's single-pass constraint model cannot express cleanly.
Imagine this concrete requirement: "Render a piece of text. Then, render a colored box that is exactly twice as wide as whatever width the text ended up being."
If you try to compose this with Row, Column, or Container, you hit a wall. Flutter passes constraints down. To make the box twice the width of the text, you need to know the text's size before you pass constraints to the box. You'd be forced into ugly, frame-lagging workarounds using LayoutBuilder or post-frame callbacks.
A custom RenderBox solves this elegantly. In its performLayout(), it can measure the text child first, store the resulting width, multiply it by two, pass that exact width as a tight constraint to the box child, and then finally determine its own overall size. All in a single pass.
🎙️ Senior-Level Sample Answer
Interviewer: "Why not just use Stacks, Align, and Containers for everything? When would you actually build a custom RenderObject?"
You: "I'd reach for a custom RenderBox when I hit the limits of Flutter's single-pass layout system. If my layout requires a 'measure-then-decide' flow—for instance, if child B needs its constraints calculated based on the final measured size of child A—standard widgets struggle without hacky post-frame callbacks. A custom RenderObject lets me override performLayout(), measure child A first, use its size to generate strict constraints for child B, and lay them both out cleanly in one render pass. It's also vital if I need non-rectangular hit-testing, which standard widgets don't support well."
06 The Decision Framework Summarized
If you get flustered in an interview, fall back on this mental checklist. Ask yourself these questions in this exact order:
- Can I build this with Rows, Stacks, and Containers? If yes, do it. Stop here.
- Do I just need to draw something weird? (e.g., a bezier curve graph, a dotted line). Does the layout just fit in a standard bounding box? If yes, use
CustomPainter. - Do I need to calculate child sizes based on other children dynamically, or do I need circular hit-testing? If yes, write a custom
RenderBox.
07 The Layout Protocol Returns
To tie this perfectly back to Post #5: writing a custom RenderBox is not inventing a new physics engine. You are still abiding by the fundamental Flutter Layout Protocol: Constraints go down, sizes go up, parent sets position.
By subclassing RenderBox, you are simply implementing that protocol yourself instead of relying on Flex (Row/Column) to do it for you. You receive constraints via performLayout(), you pass constraints down to your children, you wait for them to return their sizes, and you set your own size. The rules never change; you just took the steering wheel.
🧠 Interviewer's Follow-Up
Prepare for these pivots after you explain your reasoning:
- Q: "What happens if you call markNeedsLayout() instead of markNeedsPaint() when only a color changes?"
A: You force the engine to recalculate sizes and positions for that node (and potentially its parents), wasting CPU cycles when only the GPU repaint phase was needed. - Q: "How does CustomPainter handle hit-testing for individual paths?"
A: It doesn't. CustomPainter has no concept of hit-testing individual painted shapes natively. You'd need a custom RenderBox or manual tap-coordinate math to check if an Offset falls inside a specific Path.
✅ Quick Self-Check
Before moving on to the next post, can you confidently answer this out loud: Why can't you size a parent widget directly inside a CustomPainter? (Answer: Because painting happens after layout. The RenderObject's size is already locked in before the Canvas is even provided to your painter.)

0 Comments