Link

Single Child Render Objects

In this post, we will implement a basic single-child render box from scratch.


SingleChildRenderObjectWidget

To keep it simple, all this widget will do is lay itself out like a square:

/// A container that sizes itself as a square depending on the largest dimension
/// of the child, centering it.
class Square extends SingleChildRenderObjectWidget {
  Square({
    Key key,
    Widget child,
  }) : super(
    key: key,
    child: child,
  );

  @override
  RenderObject createRenderObject(BuildContext context) => RenderSquare();
}

RenderBox

Now we create the RenderBox implementation and mixin RenderObjectWithChildMixin for convenience:

class RenderSquare extends RenderBox
  with RenderObjectWithChildMixin<RenderBox> {
  ...

The core of the render layer is layout and paint, RenderObjects implement their layout logic in the performLayout method:

  @override
  void performLayout() {
    if (child == null) {
      // RenderObjects must have a size after layout, and that size
      // be within the constraints provided to it.
      //
      // Since there is no child, just use the smallest allowed.
      size = constraints.smallest;
    } else {
      // If a RenderObject has a child, it must be layed out at
      // least once.
      //
      // The constraints parameter tells the child what the upper
      // and lower bounds of its size can be, just like how we
      // handle incoming constraints to RenderSquare. The child's
      // size is forced to fit these constraints, even if it leads
      // to overpainting or other layout problems.
      //
      // A child's size can only be used by us if it has been
      // layed out AND parentUsesSize is true. In situations where
      // our layout does not depend on the size of the child, the
      // parentUsesSize argument can be false.
      child.layout(constraints, parentUsesSize: true);

      // Now that the child has been layed out, we can grab its size.
      final childSize = child.size;

      // Calculate the width of our square by taking the maximum of
      // the child's width and height.
      final width = max(childSize.width, childSize.height);

      // Size ourselves to the closest size that still fits within
      // the constraints given by our parent.
      size = constraints.constrain(Size.square(width));

      // Each RenderObject has a `parentData` field that is managed
      // by its parent, this is initialized when the child is mounted
      // with our `setupParentData` method. The default implementation
      // for `RenderBox.setupParentData` initializes the child's
      // parentData field with a BoxParentData.
      //
      // In this case, we use BoxParentData to give the child a
      // paint offset, this offset can then read by other methods
      // like `paint` and `applyPaintTransform`.
      final parentData = child.parentData as BoxParentData;

      // Center the child vertically and horizontally into our size.
      parentData.offset = Offset(
        (size.width - childSize.width) / 2,
        (size.height - childSize.height) / 2,
      );
    }
  }

Finally, implement the paint method:

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      // Paint always happens after the layout phase is complete,
      // so we can safely access the parentData from before.
      final parentData = child.parentData as BoxParentData;

      // We call PaintingContext.paintChild to paint the child,
      // you can paint a specific child either once or not at all
      // per frame.
      context.paintChild(child, parentData.offset + offset);
    }
  }

Result

When combined with ClipOval and Container, this creates a perfectly circular bubble that matches the size of its child:

Live demo: https://dartpad.dartlang.org/40622dd3145144d1535e9dab0ee5ea63


Add effects

What if our RenderObject needs some extra properties?

First, add the property to the Widget, in this case an opacity:

  final double opacity;
  
  Square({
    Key key,
    Widget child,
    this.opacity = 1.0,
  }) : super(
    key: key,
    child: child,
  );

Then modify createRenderObject so that it constructs RenderSquare with opacity:

  @override
  RenderObject createRenderObject(BuildContext context) =>
    RenderSquare(opacity: opacity);

In order for RenderSquare to update when the widget configuration changes, implement the updateRenderObject method:

  /// This function is called when a new widget instance
  /// is provided from a rebuild.
  @override
  void updateRenderObject(BuildContext context, RenderSquare renderObject) {
    renderObject.opacity = opacity;
  }

In RenderSquare, implement the opacity property:

  RenderSquare({
    double opacity = 1.0,
  }) : _opacity = opacity;

  double _opacity;
  double get opacity => _opacity;
  set opacity(double value) {
    // Do an early return if the value is the same, this prevents
    // any redundant repaints or relayouts.
    //
    // This kind of early return is actually one of the most
    // important optimizations in the render layer.
    if (_opacity == value) return;

    // Set the real opacity value.
    _opacity = value;

    // Here we notify the framework of changes to our
    // configuration, in this case the update is purely visual
    // so we call `markNeedsPaint`.
    //
    // If this property affects layout we would call
    // `markNeedsLayout` instead, which does a relayout + repaint.
    markNeedsPaint();
  }

Finally, this property can be used in paint to change the opacity of the child:

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      // Paint always happens after the layout phase is complete,
      // so we can safely access the parentData from before.
      final parentData = child.parentData as BoxParentData;

      // Opacity layers use an 8 bit alpha, rather than a float.
      //
      // This function just clamps, scales, and rounds a 0 - 1
      // opacity value to what the layer expects.
      final alpha = Color.getAlphaFromOpacity(opacity);

      // RenderObjects do not just paint to a canvas like you would
      // expect from a low level graphics library, instead they build
      // a tree of `Layer`s.
      //
      // Because many widgets do need a canvas to paint on, the
      // `PaintingContext` interface provides a way for many objects
      // to opportunistically share the same CanvasLayer, which stops
      // recording when you push your own layer, e.g. OpacityLayer.
      //
      // The side effect of this opportunistic sharing is that the
      // canvas it gives you must be restored before painting a child,
      // and before you return from `paint`.
      //
      // These layers can contain external textures such as native
      // views, they are also used as a way to efficiently update
      // specific features without repainting e.g. simple offsets
      // with OffsetLayer.
      //
      // This means in order to apply any paint effects to a child,
      // we have to push a layer underneath it. In this case, we push
      // an OpacityLayer.
      context.pushOpacity(offset, alpha, (context, offset) {
        // Everything painted in the following callback will have an
        // opacity effect.
        context.paintChild(child, parentData.offset + offset);
      });
    }
  }

  /// Because our paint function pushes a layer during paint, we need
  /// to set this to true.
  ///
  /// Alternatively if the compositing is conditional, we could compute
  /// that condition here and call `markNeedsCompositingBitsUpdate`
  /// in set:opacity when it changes value.
  ///
  /// See the implementation of `RenderOpacity` for more details.
  @protected
  bool get alwaysNeedsCompositing => true;

Final result

The final result is a square that also changes its opacity:

Live demo: https://dartpad.dartlang.org/0d12f9092860c0793e57eb3b9cad2926