Flutter Custom UI: How to Use CustomPaint, RenderBox and Canvas to Build Any Design

Search for a command to run...

No comments yet. Be the first to comment.
The build-time binary trick that lets you ship a working feature while keeping the real logic unreadable.

TL;DR — This guide covers everything you need to rebrand a Flutter app for a new client: changing the app name, swapping the icon with flutter_launcher_icons, renaming the package ID with change_app_p

A Quick Story to Set the Scene Meet Lumi and Nyx. They share one Flutter app. Lumi prefers light mode — bright cards, cheerful blues, a UI that feels open. Nyx wants the opposite — calm grays, a dark background, something easier on the eyes at night....

There's a category of bug that only shows up in production apps — the kind that doesn't crash, doesn't throw an exception, and doesn't even look wrong on the surface. It just quietly does the wrong thing. One of the most common in security-sensitive ...

When Classes Start Misbehaving At some point in every Dart project, you run into a situation where your classes are being used in ways you never intended. Someone creates an object directly when there should only ever be one. Someone calls an interna...

Flutter's widget system is genuinely impressive. Stack, wrap, align, animate, react — it handles the vast majority of UI requirements without you ever needing to reach for anything lower-level. But eventually, you hit a wall. The design calls for something the widget tree just can't express cleanly: a fluid wave that responds to touch, a custom graph with precise control over every drawn element, a radial menu with non-standard layout logic.
When that happens, you need to go below the widget layer. Here's how.
Composing existing widgets is fast and reliable, but it has a ceiling. Situations where widget composition genuinely breaks down include:
For these, Flutter gives you three layers of increasing power — and complexity.
CustomPaint is Flutter's first escape hatch from the widget system. It hands you a Canvas and a Paint object and gets out of the way. You describe what to draw; Flutter draws it.
Here's a wave background as a practical example:
class WavePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blueAccent
..style = PaintingStyle.fill;
final path = Path()
..moveTo(0, size.height * 0.8)
..quadraticBezierTo(
size.width * 0.5,
size.height,
size.width,
size.height * 0.8,
)
..lineTo(size.width, 0)
..lineTo(0, 0)
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Using it in your widget tree:
class WaveHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: WavePainter(),
child: Container(height: 200),
);
}
}
shouldRepaint controls when Flutter redraws your painter — returning false here means it only draws once, which is fine for static shapes. For animated or interactive painters, you'd return true or compare old and new delegate values.
This approach handles any shape you can describe with a Path — curves, polygons, gradients, arbitrary fills. It's the right tool for anything visual that doesn't fit standard widget constraints.
When you need control over not just painting but also layout and hit testing, RenderBox is the next step. This is Flutter's rendering layer — the same level at which built-in widgets like Container and Text are actually implemented.
It sounds intimidating, but the structure is straightforward:
class MyRenderBox extends RenderBox {
@override
void performLayout() {
size = constraints.constrain(Size(200, 100));
}
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas;
final paint = Paint()..color = Colors.green;
canvas.drawRect(offset & size, paint);
}
}
Wrapping it as a widget:
class GreenBox extends LeafRenderObjectWidget {
@override
RenderObject createRenderObject(BuildContext context) {
return MyRenderBox();
}
}
Drop GreenBox() anywhere in your widget tree. It handles its own size and painting without delegating to any other widget.
You can also handle gestures at this layer:
@override
bool hitTestSelf(Offset position) => true;
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent) {
print("Touched at ${event.position}");
}
}
This level is appropriate when CustomPainter isn't enough — when your component needs to participate in layout, own its size, or handle input in ways that don't map cleanly to the gesture widgets above.
SceneBuilder sits at the very bottom of Flutter's rendering stack, below widgets and below RenderBox. It's how Flutter assembles the final frame before handing it to the GPU.
In practice, you'll rarely need this in a standard app. The cases where it makes sense are narrow:
It's worth knowing it exists, but for most production apps — even complex ones — CustomPainter or RenderBox will take you as far as you need to go.
A few things worth having in your toolkit when working at this level:
setState (or use a Listenable) to trigger repaints.| Situation | Reach for |
| Custom shapes, gradients, or visual effects | CustomPainter |
| Full control over layout, paint, and touch | RenderBox |
| Small tweaks to existing widget appearance | Compose or extend widgets |
| Graph-heavy or animated drawing | CustomPainter with AnimationController |
| Rendering without the widget system | SceneBuilder |
CustomPainter, driven by animation valuesRenderBox with custom hit testing for each nodeRenderBox for layout and CustomPainter for contentFlutter is, at its core, a canvas. The widget system is a convenient abstraction built on top of that canvas, and it's the right starting point for almost everything. But when your design genuinely requires it, stepping below widgets is not as scary as it looks — and the control you gain is well worth the extra complexity.
Build that wavy onboarding screen. Draw that custom chart. Flutter has the tools for it.