Skip to main content

Custom widgets and render objects

Building a widget from scratch at the layer the framework itself uses: your own RenderBox with your own layout, painting, hit testing, parent data, layers, and semantics — plus precise control over what happens on each frame.

This is the deepest level of customisation Flutter offers. It assumes Rendering pipeline.

Contents

Pick the right level

There are four levels of customisation, and going deeper than you need costs maintainability for nothing.

LevelToolGives youCosts
1Compose existing widgetsEverything the framework already doesConstrained by what those widgets can express
2CustomPaintArbitrary drawingNo layout participation, no children, manual hit testing
3Fragment shaderPer-pixel GPU controlNo geometry, no layout, no children
4RenderObjectLayout, paint, hit test, children, layers, semantics, intrinsicsYou implement the protocol correctly, or subtle bugs appear

Go to level 4 when you need at least one of these, which levels 1 to 3 cannot provide:

  • A custom layout algorithm — children positioned by rules no existing widget expresses, with real participation in the constraint system.
  • Children you lay out and paint yourself.
  • Per-child configuration carried through the tree (ParentData), the way Expanded gives a flex factor to a Row.
  • Intrinsic sizing so ancestors can ask how big your widget wants to be.
  • Layout-affecting custom geometry, rather than painting inside a box someone else sized.
  • Direct layer control for compositing, or opting out of repaints.

If you only need to draw, stay at level 2 — see Custom painting. A CustomPaint is a render object already; writing your own only pays when you need the parts a painter does not have.

Anatomy of a render object widget

Three classes cooperate. The widget is immutable configuration, the render object is the long-lived mutable machine, and the framework connects them.

Widget base classFor
LeafRenderObjectWidgetNo children
SingleChildRenderObjectWidgetExactly one child
MultiChildRenderObjectWidgetA list of children

Each implements two methods:

@override
RenderX createRenderObject(BuildContext context) => RenderX(...);

@override
void updateRenderObject(BuildContext context, RenderX renderObject) {
renderObject
..fieldA = fieldA
..fieldB = fieldB;
}

createRenderObject runs once, when the element is first inflated. updateRenderObject runs on every rebuild where the element is reused, which is the normal case — so it must transfer every configuration field, or the widget will silently ignore a changed parameter.

A leaf render box

A precise tick-mark ruler: it sizes itself from the constraints, draws major/minor ticks at exact device-pixel positions, and repaints only when its value changes.

import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

class Ruler extends LeafRenderObjectWidget {
const Ruler({
super.key,
required this.value,
required this.pixelsPerUnit,
required this.colour,
this.height = 56,
});

final double value;
final double pixelsPerUnit;
final Color colour;
final double height;

@override
RenderRuler createRenderObject(BuildContext context) => RenderRuler(
value: value,
pixelsPerUnit: pixelsPerUnit,
colour: colour,
preferredHeight: height,
);

@override
void updateRenderObject(BuildContext context, RenderRuler renderObject) {
renderObject
..value = value
..pixelsPerUnit = pixelsPerUnit
..colour = colour
..preferredHeight = height;
}
}
class RenderRuler extends RenderBox {
RenderRuler({
required double value,
required double pixelsPerUnit,
required Color colour,
required double preferredHeight,
}) : _value = value,
_pixelsPerUnit = pixelsPerUnit,
_colour = colour,
_preferredHeight = preferredHeight;

// Each setter decides the minimum invalidation its change requires.
double get value => _value;
double _value;
set value(double newValue) {
if (_value == newValue) return;
_value = newValue;
markNeedsPaint(); // position of the marker only
markNeedsSemanticsUpdate();
}

double get pixelsPerUnit => _pixelsPerUnit;
double _pixelsPerUnit;
set pixelsPerUnit(double newValue) {
if (_pixelsPerUnit == newValue) return;
_pixelsPerUnit = newValue;
markNeedsPaint();
}

Color get colour => _colour;
Color _colour;
set colour(Color newValue) {
if (_colour == newValue) return;
_colour = newValue;
markNeedsPaint();
}

double get preferredHeight => _preferredHeight;
double _preferredHeight;
set preferredHeight(double newValue) {
if (_preferredHeight == newValue) return;
_preferredHeight = newValue;
markNeedsLayout(); // this one changes our size
}

final Paint _tickPaint = Paint()..style = PaintingStyle.stroke;

@override
Size computeDryLayout(BoxConstraints constraints) =>
constraints.constrain(Size(constraints.maxWidth, _preferredHeight));

@override
void performLayout() {
size = computeDryLayout(constraints);
}

@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas;

// Snap to physical pixels so 1px lines stay crisp.
final dpr = _pixelRatio;
double snap(double v) => (v * dpr).roundToDouble() / dpr;

_tickPaint
..color = _colour
..strokeWidth = 1 / dpr;

final first = (_value - size.width / 2 / _pixelsPerUnit).floorToDouble();
final last = (_value + size.width / 2 / _pixelsPerUnit).ceilToDouble();

for (var unit = first; unit <= last; unit++) {
final x = snap(offset.dx +
size.width / 2 +
(unit - _value) * _pixelsPerUnit);
final isMajor = unit % 5 == 0;
final length = isMajor ? size.height * 0.5 : size.height * 0.25;

canvas.drawLine(
Offset(x, offset.dy + size.height - length),
Offset(x, offset.dy + size.height),
_tickPaint,
);
}

// Centre marker
canvas.drawLine(
Offset(snap(offset.dx + size.width / 2), offset.dy),
Offset(snap(offset.dx + size.width / 2), offset.dy + size.height),
_tickPaint..strokeWidth = 2 / dpr,
);
}

double get _pixelRatio {
final owner = this.owner;
// devicePixelRatio via the pipeline owner's configuration, with a fallback
return owner == null ? 1.0 : (owner.rootNode is RenderView
? (owner.rootNode! as RenderView).configuration.devicePixelRatio
: 1.0);
}

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

Two details worth copying:

  • paint receives an offset. It is your position in the parent's coordinate space — add it to every coordinate. Forgetting it produces a widget that draws correctly only at the top-left of the screen.
  • Snapping coordinates to the device pixel grid is what separates a crisp hairline from a blurry two-pixel smear. Read the device pixel ratio from the view (MediaQuery.devicePixelRatioOf in the widget, passed down, is often simpler than reaching for it in the render object).

Invalidation

Precise control means invalidating the smallest thing that changed. Each method marks the render object dirty for one phase of the next frame:

CallRe-runsUse when
markNeedsPaint()Paint onlyAppearance changed, geometry did not
markNeedsLayout()Layout, then paintYour size, or a child's position, changed
markNeedsLayoutForSizedByParentChange()Layout, propagating to the parentsizedByParent itself changed
markNeedsCompositingBitsUpdate()Compositing decisionWhether you need a composited layer changed
markNeedsSemanticsUpdate()Semantics treeAccessible value or actions changed

Nothing happens immediately — the framework schedules a frame and processes dirty nodes in phase order. Calling markNeedsLayout when only the colour changed is a real and common performance bug: it drags layout and paint along for nothing.

Sizing and intrinsics

computeDryLayout reports the size you would take for given constraints without laying out children — the framework uses it for dry layout passes. Implement it, then have performLayout agree with it.

When your size depends only on the incoming constraints, say so:

@override
bool get sizedByParent => true;

@override
void performResize() {
size = constraints.constrain(Size(constraints.maxWidth, _preferredHeight));
}

With sizedByParent, sizing moves to performResize and performLayout only positions children. It also creates a relayout boundary, so your internal changes do not propagate upward.

Intrinsics answer "how wide do you want to be?" and are needed only if an ancestor asks (IntrinsicWidth, Table, an unbounded Row):

@override
double computeMinIntrinsicWidth(double height) => _minimumTickSpan;

@override
double computeMaxIntrinsicWidth(double height) => double.infinity;

@override
double computeMinIntrinsicHeight(double width) => _preferredHeight;

@override
double computeMaxIntrinsicHeight(double width) => _preferredHeight;

Intrinsics are expensive — they measure the subtree speculatively. Implement them, but do not build layouts that depend on them.

For text-like alignment, also override computeDistanceToActualBaseline so your widget lines up in a Row with CrossAxisAlignment.baseline.

One child

class RenderInset extends RenderBox
with RenderObjectWithChildMixin<RenderBox> {
RenderInset({required double amount}) : _amount = amount;

double get amount => _amount;
double _amount;
set amount(double value) {
if (_amount == value) return;
_amount = value;
markNeedsLayout();
}

@override
void performLayout() {
final child = this.child;
if (child == null) {
size = constraints.smallest;
return;
}

final inner = constraints.deflate(EdgeInsets.all(_amount));
// parentUsesSize: true means our layout depends on the child's size,
// so a change in the child must relayout us too.
child.layout(inner, parentUsesSize: true);
size = constraints.constrain(
Size(child.size.width + _amount * 2, child.size.height + _amount * 2),
);
}

@override
void paint(PaintingContext context, Offset offset) {
final child = this.child;
if (child != null) {
context.paintChild(child, offset + Offset(_amount, _amount));
}
}

@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
final child = this.child;
if (child == null) return false;
return result.addWithPaintOffset(
offset: Offset(_amount, _amount),
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) =>
child.hitTest(result, position: transformed),
);
}
}

parentUsesSize is the flag people miss. Passing false tells the framework your layout is independent of the child's size; if that is untrue, you get stale layout that only reproduces intermittently.

Many children and parent data

ParentData is how a parent stores per-child layout information on the child — the mechanism behind Expanded and Positioned.

class BadgeParentData extends ContainerBoxParentData<RenderBox> {
int priority = 0;
}

class RenderBadgeStrip extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, BadgeParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, BadgeParentData> {
RenderBadgeStrip({required double spacing}) : _spacing = spacing;

double get spacing => _spacing;
double _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}

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

@override
void performLayout() {
// Collect children, then lay out highest priority first.
final children = <RenderBox>[];
var child = firstChild;
while (child != null) {
children.add(child);
child = childAfter(child);
}
children.sort((a, b) => (b.parentData! as BadgeParentData)
.priority
.compareTo((a.parentData! as BadgeParentData).priority));

var x = 0.0;
var lineHeight = 0.0;
final childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);

for (final child in children) {
child.layout(childConstraints, parentUsesSize: true);
final data = child.parentData! as BadgeParentData;
data.offset = Offset(x, 0);
x += child.size.width + _spacing;
lineHeight = math.max(lineHeight, child.size.height);
}

size = constraints.constrain(
Size(math.max(0, x - _spacing), lineHeight),
);
}

@override
void paint(PaintingContext context, Offset offset) =>
defaultPaint(context, offset);

@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
defaultHitTestChildren(result, position: position);
}

The two mixins do the heavy lifting: ContainerRenderObjectMixin maintains the child linked list (firstChild, childAfter, childCount), and RenderBoxContainerDefaultsMixin supplies defaultPaint and defaultHitTestChildren, which honour each child's parentData.offset.

To let callers set the priority declaratively, add a ParentDataWidget:

class BadgePriority extends ParentDataWidget<BadgeParentData> {
const BadgePriority({
super.key,
required this.priority,
required super.child,
});

final int priority;

@override
void applyParentData(RenderObject renderObject) {
final data = renderObject.parentData! as BadgeParentData;
if (data.priority != priority) {
data.priority = priority;
renderObject.parent?.markNeedsLayout();
}
}

@override
Type get debugTypicalAncestorWidgetClass => BadgeStrip;
}

That is exactly how Expanded works, and the assertion behind "Incorrect use of ParentDataWidget" comes from debugTypicalAncestorWidgetClass.

Hit testing

Three methods, called from the top down:

@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) return false; // outside us entirely
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
return false;
}

@override
bool hitTestSelf(Offset position) => _interactiveRegion.contains(position);

Override hitTestSelf to accept taps on your painted content — return true for the whole box, or test a Path for a non-rectangular target. Return false where you want taps to fall through to whatever is behind.

Override hitTestChildren to route positions into children, undoing whatever transform you applied when painting. result.addWithPaintOffset, addWithPaintTransform, and addWithRawTransform do the inverse maths for you — use them rather than subtracting offsets by hand, or hit areas will drift out of alignment with what you drew.

Custom gestures

GestureDetector covers most needs. Write a recognizer when you need to control how the gesture competes in the arena — for example, a horizontal drag that must win against an enclosing vertical scroll view.

class PreciseDragRecognizer extends OneSequenceGestureRecognizer {
PreciseDragRecognizer({required this.onSlide, super.debugOwner});

final void Function(double delta) onSlide;

@override
void addAllowedPointer(PointerDownEvent event) {
startTrackingPointer(event.pointer, event.transform);
resolve(GestureDisposition.accepted); // claim it immediately
}

@override
void handleEvent(PointerEvent event) {
if (event is PointerMoveEvent) {
onSlide(event.delta.dx);
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
stopTrackingPointer(event.pointer);
}
}

@override
void didStopTrackingLastPointer(int pointer) {}

@override
String get debugDescription => 'precise drag';
}
RawGestureDetector(
gestures: {
PreciseDragRecognizer:
GestureRecognizerFactoryWithHandlers<PreciseDragRecognizer>(
() => PreciseDragRecognizer(onSlide: _onSlide),
(instance) => instance.onSlide = _onSlide,
),
},
child: const Ruler(...),
)

resolve(GestureDisposition.accepted) wins the arena at once; calling resolve(GestureDisposition.rejected) yields to competitors. Accepting eagerly is how you beat a parent scrollable, and also how you break it if you do it carelessly — accept only once you are confident the gesture is yours.

Layers and compositing

PaintingContext provides the standard effects, each of which pushes a layer:

@override
void paint(PaintingContext context, Offset offset) {
context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
(PaintingContext context, Offset offset) {
context.paintChild(child!, offset);
},
);
}

pushOpacity, pushTransform, pushClipRRect, and pushClipPath follow the same shape. Pass needsCompositing through — it tells the framework whether a real layer is required or the effect can be applied directly.

For a retained layer you manage yourself, keep the handle so the framework can reuse it across frames:

final LayerHandle<OpacityLayer> _opacityHandle =
LayerHandle<OpacityLayer>();

@override
void paint(PaintingContext context, Offset offset) {
_opacityHandle.layer = context.pushOpacity(
offset,
(_fade * 255).round(),
_paintContents,
oldLayer: _opacityHandle.layer,
);
}

@override
void dispose() {
_opacityHandle.layer = null; // releases the layer
super.dispose();
}

Two switches control your relationship with the compositor:

@override
bool get isRepaintBoundary => true; // own layer; repaints stay local

@override
bool get alwaysNeedsCompositing => true; // always needs a real layer

isRepaintBoundary is the render-object equivalent of wrapping yourself in a RepaintBoundary. Set it when you repaint frequently and independently of your surroundings — an animating gauge, a video surface. Each boundary costs a layer and memory, so it is a trade, not a free win.

Text inside a render object

There is no Text widget available to you, so drive TextPainter directly:

final TextPainter _labelPainter = TextPainter(
textDirection: TextDirection.ltr,
);

void _layoutLabel(String label, TextStyle style, double maxWidth) {
_labelPainter
..text = TextSpan(text: label, style: style)
..layout(maxWidth: maxWidth);
}

@override
void paint(PaintingContext context, Offset offset) {
_labelPainter.paint(context.canvas, offset + const Offset(8, 8));
}

@override
void dispose() {
_labelPainter.dispose();
super.dispose();
}

Call layout() before paint() or it throws, cache the result rather than re-laying out every frame, and dispose the painter. Take the TextStyle and TextDirection from the widget (resolved from DefaultTextStyle and Directionality in createRenderObject) rather than hard-coding them.

Frame-precise control

Frames run in phases, exposed as SchedulerPhase:

PhaseWhat runsYour hook
idleNothing scheduled
transientCallbacksAnimation tickersTicker, scheduleFrameCallback
midFrameMicrotasksMicrotasks from the above
persistentCallbacksBuild, layout, paint, compositing, semanticsaddPersistentFrameCallback
postFrameCallbacksCleanup, measurement, follow-up workaddPostFrameCallback

One tick per frame, with the exact elapsed time — the mechanism every animation in Flutter is built on:

class _GaugeState extends State<Gauge>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
Duration _last = Duration.zero;

@override
void initState() {
super.initState();
_ticker = createTicker(_onFrame)..start();
}

void _onFrame(Duration elapsed) {
final dt = (elapsed - _last).inMicroseconds / 1e6;
_last = elapsed;
_advanceSimulation(dt); // integrate with the real frame delta
_repaint.notify(); // a Listenable the render object listens to
}

@override
void dispose() {
_ticker.dispose();
super.dispose();
}
}

Using the ticker's elapsed rather than a fixed increment is what makes motion frame-rate independent — the same code behaves correctly at 60 Hz and 120 Hz.

Work after this frame is on screen:

SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
// Layout is final here: sizes and positions can be read safely.
_reportMeasuredExtent(context.size);
});

This is the correct place for anything that needs final geometry, or that would be illegal during build — showing a dialog, scrolling to a position, reading a GlobalKey's render object.

A single future frame, without a ticker:

SchedulerBinding.instance.scheduleFrameCallback((Duration timeStamp) {
_stepOnce();
});
SchedulerBinding.instance.scheduleFrame(); // ensure a frame is coming

Render on demand rather than continuously — the right pattern for an interactive but mostly static custom widget, and the main lever for battery:

void _invalidate() {
_needsRender = true;
SchedulerBinding.instance.ensureVisualUpdate(); // schedules a frame if idle
}

Deferred, low-priority work that yields to animation:

SchedulerBinding.instance.scheduleTask(
() => _precomputeTickLabels(),
Priority.idle,
);

Check the phase before invalidating, if you might be called mid-frame:

if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
markNeedsPaint();
} else {
SchedulerBinding.instance.addPostFrameCallback((_) => markNeedsPaint());
}

Calling markNeedsPaint during the paint phase, or markNeedsLayout during layout, is an error — the phase for that work has already passed.

For debugging motion, slow the whole scheduler down rather than editing durations:

import 'package:flutter/scheduler.dart';

timeDilation = 5.0; // everything animates five times slower

Measuring frames

To know whether your widget actually hits its budget, read the engine's own timings — these work in release builds, unlike DevTools:

SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
for (final timing in timings) {
final build = timing.buildDuration.inMicroseconds / 1000;
final raster = timing.rasterDuration.inMicroseconds / 1000;
final total = timing.totalSpan.inMicroseconds / 1000;
if (total > 16.7) {
debugPrint('janked: build ${build}ms raster ${raster}ms total ${total}ms');
}
}
});

buildDuration is UI-thread work, rasterDuration is GPU work — the same split DevTools shows, available to your own instrumentation. Wrap your expensive internals in timeline events so they appear in traces:

Timeline.timeSync('RenderRuler.performLayout', () {
// ...
});

See DevTools and performance.

Semantics

A custom render object is invisible to assistive technology until you describe it. This is not optional for a shipped widget.

@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config
..isSemanticBoundary = true
..label = 'Ruler'
..value = _value.toStringAsFixed(1)
..increasedValue = (_value + 1).toStringAsFixed(1)
..decreasedValue = (_value - 1).toStringAsFixed(1)
..textDirection = TextDirection.ltr
..onIncrease = () => _nudge(1)
..onDecrease = () => _nudge(-1);
}

Call markNeedsSemanticsUpdate() when any of those values change. For a multi-child render object, assembleSemanticsNode and visitChildrenForSemantics let you merge or split what is exposed. Verify with the Semantics debugger (showSemanticsDebugger: true on MaterialApp) and with a real screen reader.

Debugging support

Make your render object cooperate with the Inspector and with toStringDeep():

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DoubleProperty('value', value))
..add(DoubleProperty('pixelsPerUnit', pixelsPerUnit))
..add(ColorProperty('colour', colour))
..add(FlagProperty('isRepaintBoundary',
value: isRepaintBoundary, ifTrue: 'repaint boundary'));
}

Also honour the debug paint flag, so debugPaintSizeEnabled behaves as expected for your widget:

@override
void debugPaintSize(PaintingContext context, Offset offset) {
super.debugPaintSize(context, offset);
assert(() {
context.canvas.drawRect(
(offset & size).deflate(0.5),
Paint()
..style = PaintingStyle.stroke
..color = const Color(0xFF00FFFF),
);
return true;
}());
}

Wrapping debug-only code in assert(() { ... return true; }()) means it is tree shaken out of release builds entirely.

Lifecycle and disposal

@override
void attach(PipelineOwner owner) {
super.attach(owner);
_repaintNotifier.addListener(markNeedsPaint); // now in the tree
}

@override
void detach() {
_repaintNotifier.removeListener(markNeedsPaint);
super.detach();
}

@override
void dispose() {
_labelPainter.dispose();
_layerHandle.layer = null;
super.dispose();
}

attach and detach bracket the render object's membership in a live tree, and can happen more than once (a GlobalKey move). Subscribe in attach, unsubscribe in detach. dispose is final — release painters, layer handles, and images there. See Lifecycle.

Testing

Reach the render object directly from a widget test:

testWidgets('Ruler fills width and honours height', (tester) async {
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
width: 300,
child: Ruler(value: 0, pixelsPerUnit: 10, colour: Color(0xFF000000)),
),
),
),
);

final render = tester.renderObject<RenderRuler>(find.byType(Ruler));
expect(render.size, const Size(300, 56));
});

testWidgets('changing the value repaints without relayout', (tester) async {
await tester.pumpWidget(_app(value: 0));
final render = tester.renderObject<RenderRuler>(find.byType(Ruler));
final sizeBefore = render.size;

await tester.pumpWidget(_app(value: 12));

expect(render.size, sizeBefore);
expect(render.debugNeedsLayout, isFalse);
});

Worth testing: the size under several constraint shapes (tight, loose, unbounded), that a configuration change reaches the render object at all (the updateRenderObject omission), hit-test coordinates at a non-zero offset, and intrinsics if you implemented them. Golden tests are the practical way to cover painting — see Testing.

Pitfalls

  • Forgetting a field in updateRenderObject. The widget rebuilds, the parameter changes, nothing happens. The most common bug in custom render objects, and invisible until someone changes that parameter.
  • Ignoring offset in paint. Works at the origin, breaks everywhere else.
  • Over-invalidating. markNeedsLayout for a colour change costs a full layout pass every frame.
  • parentUsesSize: false when it does. Stale layout that reproduces intermittently.
  • Not respecting constraints. Setting a size outside constraints trips an assertion in debug and produces overlap in release. Always constraints.constrain(...).
  • performLayout disagreeing with computeDryLayout. Dry layout is used for speculative measurement; a mismatch causes layout that depends on measurement order.
  • Hit test not matching paint. Any transform applied in paint must be inverted in hitTestChildren — use the addWith... helpers.
  • Leaking TextPainters and layers. Dispose them.
  • No semantics. The widget is unusable with a screen reader.
  • Doing this at all when CustomPaint would do. Two hundred lines of protocol you now own forever, to draw something a painter could have drawn.

Next: Custom painting.