skip to content

In Flutter, what are the three jobs of a custom RenderBox, and which method does it override for each?

level: juniorimportance: nice to knowfreq 22%

answer

  1. constraints down, size up
  2. performLayout sets size
  3. paint(PaintingContext, Offset)
  4. hitTestSelf and hitTestChildren
  5. setters mark what is stale

basics

~10 s

A RenderBox lays out, paints and hit-tests. It sets its size from the parent's BoxConstraints in performLayout, draws in paint(PaintingContext, Offset), and answers pointer hits through hitTestSelf and hitTestChildren.

solid answer

~40 s

A `RenderBox` is a render object that follows the **box protocol**: its parent hands it `BoxConstraints` and it must choose a `size` inside them. **Layout** happens in `performLayout()`, which reads `constraints`, lays out any children, positions them and sets `size`. **Painting** happens in `paint(PaintingContext context, Offset offset)`, where `offset` is the box's top-left corner in the canvas's coordinates, so everything is drawn relative to it. **Hit testing** goes through `hitTest`, which checks the point is inside `size` and then asks `hitTestChildren` and `hitTestSelf`; a leaf that should receive taps returns `true` from `hitTestSelf`. Around those, property setters call `markNeedsLayout` or `markNeedsPaint` so the pipeline knows which job to redo.

code

dart · 59 lines
dart
import 'package:flutter/rendering.dart';

class RenderRuler extends RenderBox {
  RenderRuler({
    required double lengthMm,
    required double pixelsPerMm,
    required Color tickColor,
  })  : _lengthMm = lengthMm,
        _pixelsPerMm = pixelsPerMm,
        _tickColor = tickColor;

  static const double _height = 32;

  double _lengthMm;
  set lengthMm(double value) {
    if (value == _lengthMm) return;
    _lengthMm = value;
    markNeedsLayout(); // changes the size
  }

  double _pixelsPerMm;
  set pixelsPerMm(double value) {
    if (value == _pixelsPerMm) return;
    _pixelsPerMm = value;
    markNeedsLayout(); // changes the size and tick spacing
  }

  Color _tickColor;
  set tickColor(Color value) {
    if (value == _tickColor) return;
    _tickColor = value;
    markNeedsPaint(); // same geometry, new pixels
  }

  @override
  Size computeDryLayout(BoxConstraints constraints) =>
      constraints.constrain(Size(_lengthMm * _pixelsPerMm, _height));

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

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

  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    final Paint tick = Paint()
      ..color = _tickColor
      ..strokeWidth = 1;
    for (var mm = 0; mm * _pixelsPerMm <= size.width; mm++) {
      final double x = offset.dx + mm * _pixelsPerMm;
      final double h = mm % 10 == 0 ? 20 : (mm % 5 == 0 ? 14 : 8);
      canvas.drawLine(Offset(x, offset.dy), Offset(x, offset.dy + h), tick);
    }
  }
}

go deeper

for a junior

Recall the three jobs and their methods: performLayout sets size, paint draws at the given offset, and hitTestSelf or hitTestChildren answer taps.

for a middle

Explain constraints down and size up, why paint uses the offset, and how setters choose markNeedsLayout or markNeedsPaint.

for a senior

Implement all three jobs consistently, including hit-testing children with their offsets, and justify when a custom box beats composition.

for a principal

Treat custom render objects as a maintained API surface: decide who owns them, how they are tested, and when composition is good enough.

## What a RenderBox is Widgets describe; **render objects** do the work. `RenderObject` is the general base class, and **`RenderBox`** is the subclass for the everyday two-dimensional **box protocol**: a parent passes `BoxConstraints` (minimum and maximum width and height), and the child picks a `Size` that satisfies them. Almost every render object behind `Padding`, `SizedBox`, `Row` or `Text` is a `RenderBox`. Writing one yourself means taking over three jobs that composed widgets normally do for you. ## Job 1: layout Override **`performLayout()`**. Inside it: 1. Read `constraints`, the `BoxConstraints` the parent gave you. 2. Lay out each child by calling `child.layout(childConstraints, parentUsesSize: true)` if you need to read its size. 3. Position each child by writing its `parentData` offset. 4. Set **`size`**, which must satisfy `constraints`; `constraints.constrain(desired)` clamps a desired size into range. If the size depends **only** on the constraints, you can instead set `sizedByParent` to `true` and compute the size in `computeDryLayout`. Forgetting to set a size is an error: a box that was never given a size cannot be painted or hit-tested. ## Job 2: painting Override **`paint(PaintingContext context, Offset offset)`**: - `context.canvas` is the `Canvas` to draw on; do not keep it across calls to other `PaintingContext` methods, since it may be replaced. - `offset` is where this box's top-left corner sits in that canvas, so every coordinate is `offset + local`. - Children are painted with `context.paintChild(child, offset + childOffset)`. The canvas is shared with other render objects, which is why drawing at `Offset.zero` instead of at `offset` paints in the wrong place. ## Job 3: hit testing When a pointer goes down, the framework calls **`hitTest(result, position: ...)`** from the root down. `RenderBox.hitTest` already checks that `position` is inside `size` and then calls: - **`hitTestChildren(result, position: ...)`**, which should forward to children, adjusting the position by each child's offset; - **`hitTestSelf(position)`**, which defaults to `false`; return `true` if this box itself should receive the event. If either returns `true`, the box adds itself to the result and the event is delivered to it and its ancestors along the path. ## How the jobs depend on each other The three jobs are not independent, and the order matters: 1. **Layout comes first.** Paint needs `size` and child positions, so paint runs only after layout is clean. 2. **Hit testing needs layout, not paint.** The framework documents that hit testing may run on a box that was never painted: a child of a fully transparent `Opacity` is still hit-tested even though it is not painted. So hit-test logic must use layout results, never values computed during `paint`. 3. **Paint must not change layout.** Setting `size` or calling `markNeedsLayout` from `paint` is an error; paint only records drawing commands. A beginner's ruler that computes its tick positions inside `paint` and then uses them in `hitTestSelf` breaks rule 2: before the first paint, or when painting is skipped, the tap test reads stale or empty data. Compute geometry in `performLayout` and let both paint and hit testing read it. ## Keeping the jobs up to date A render object keeps its own copy of every value it uses. Setters compare the new value, store it, and mark the job that is now stale: | Property affects | Setter calls | Next frame redoes | |---|---|---| | Size or child positions | `markNeedsLayout()` | layout, then paint | | Only appearance | `markNeedsPaint()` | paint only | ## The woodworking ruler A woodworking app draws an on-screen ruler with millimetre, half-centimetre and centimetre ticks. As a `RenderRuler`: - `performLayout` sets `size` to the ruler length times pixels per millimetre, clamped to the constraints; - `paint` draws each tick at `offset.dx + mm * pixelsPerMm`; - `hitTestSelf` returns `true`, so a tap anywhere on the ruler reaches the gesture handling above it; - `lengthMm` and `pixelsPerMm` setters call `markNeedsLayout`, the `tickColor` setter calls `markNeedsPaint`. The widget that creates and updates `RenderRuler`, and the drawing API on `Canvas` itself, are covered in neighbouring topics.

  • Why must a RenderBox's paint method draw relative to the offset argument?
    Because the canvas belongs to a layer shared by many render objects, and `offset` is where this box's top-left corner lies in that canvas. Drawing at local coordinates without adding `offset` would paint the ruler at the layer's origin, on top of whatever else lives there.
  • What does RenderBox.hitTest already do before it calls hitTestChildren and hitTestSelf?
    It checks that the position lies inside the box's `size`; outside, it returns `false` without asking either method. Inside, if `hitTestChildren` or `hitTestSelf` returns `true`, it adds a `BoxHitTestEntry` for this box to the result and returns `true`.

A carpenter fitting a shelf into an alcove: the alcove's size range is given (constraints), the carpenter decides the shelf size and where brackets go (layout), finishes and varnishes it (paint), and answers whether a pressed point is on the shelf (hit test).

saying these in an interview costs you the question

  • A RenderBox sets its size in paint once it knows what it drew.
  • The paint offset can be ignored because each box has its own canvas.
  • hitTestSelf returns true by default, so every box is tappable.
  • A RenderBox may choose any size regardless of its constraints.
  • Changing a colour should call markNeedsLayout.