How to Create a Flutter Widget Using a RenderObject

There is plenty of documentation on how to build a Flutter application and more importantly the significance of widgets. Most articles you’ll read about widgets talk about what a StatelessWidget is and what a StatefulWidget is and how you can inherit from these to create your own widgets. However, what happens when you want to create a widget that perhaps does some custom painting, or can’t easily be represented by a combination of existing widgets?

One option is that you add your custom painting to the widget hierarchy. For example in this StackOverflow posting the author, Collin Jackson, creates a class called ProgressPainter that inherits from CustomPainter. An instance of the ProgressPainter is added to the widget hierarchy in order to paint directly to the canvas.

An alternative is to create a class that inherits from RenderObject in order to encapsulate the painting as part of the rendering of a widget. This type of encapsulation is used by the built in Flutter widgets. The example we’ll walk through in a second actually mirrors the code for the Opacity widget, a built in Flutter widget that allows you to control the opacity of child widgets.

What’s a Widget, Element and RenderObject

Before we get into the example, it’s worth understanding the relationship between a widget and its corresponding RenderObject. There are a couple of other posts that are worth a quick read that cover the difference between a Widget, an Element and a RenderObject:

As you start to get familiar with Flutter, you’ll find yourself defining what you want to appear on the screen using a mixture of widgets. Each time you use a widget you’re setting properties on it or nesting other widgets within it. You use widgets to describe what the visual hierarchy should look like at a point in time. You can think of widgets holding configuration information about the visual hierarchy, or being a template for the visual hierarchy.

When your application runs and an instance of a widget is created it’s associated with an Element. Where widgets are immutable and may be recreated based on changing state of the application, Elements mutate based on the widget that they’re associated with. Elements combine in a tree structure to define the current layout of the application.

You’d think that if an Element defines the current layout of the application it would be what draws, or renders, each widget. This role actually resides with the RenderObject which is attached to a RenderObjectElement, a sub-class of Element (in contrast to a ComponentElement which is predominantly responsible for composition of other Elements).

Element and RenderObject By Example

I’m sure at this point this is all sounding very theoretical, so let’s walk through a basic example. After creating a new Flutter project, simple_widgets, I’ve stripped back all the default source code to a minimum app that simply displays Hello World! in the Center of the screen. In the following code we can see that the structure of the app uses a Center widget with a Text widget nested via the child property.

void main() => runApp(
      Center(
        child: Text(
          'Hello World!',
          textDirection: TextDirection.ltr,
        ),
      ),
    );

In VS Code if you press Ctrl+Alt+D the Dart DevTools will be displayed attached to the currently running Flutter app. As you can see from the left tree on the following image, our App is indeed made up a Center with Text nested within it. However, if we look on the right side we can see that there’s actually an additional node on the tree, RichText. The RichText node is actually a nested widget that is returned by the build method of the Text widget and actually does the heavy lifting for the Text widget.

What we’re really seeing in the Dart DevTools are actually the elements that have been created and correspond to what’s being displayed on the screen. To see this clearly you can click the “Debug Paint” button (to the right of the clock button in the toolbar of the Dart DevTools) and then select the RichText node in the right tree. In the running app you’ll see markers similar to the following image identifying the element that’s being displayed on the screen.

If a Widget is the template, or cookie-cutter, and an Element is the instantiation of a widget, the question is where does all the work get done to display content on the screen. This work happens within a RenderObject, or in most cases a derivative of the RenderObject class. In the case of the RichText widget, it creates a RenderParagraph which is solely responsible for rendering out the text associated with the RichText widget.

As you can see from the earlier image taken from the Dev DartTools, the RenderParagraph includes properties such as constraints and size, that are common to all RenderObjects, as well as textStart, textDirection etc. The implementation of the RenderParagraph is too complex to go into here but needless to say it’s responsible for rendering the text associated with the RichText widget to the screen.

Creating a Tint Widget

The purpose of the widget is to overlay a tint across the content specified via the child property of the widget. The code for this widget isn’t too dissimilar to the built in Opacity widget. There going to be two classes involved:

  • Tint – this is a widget that a developer can use in their widget hierarchy in order to provide a coloured overlay, specified by the color property.
  • RenderTint – this is the descendent of RenderObject and will be responsible for painting the tint overlay.

Tint Widget

The Tint widget itself is relatively straightforward, mainly because we inherit from SingleChildRenderObjectWidget that does most of the heavy lifting for us. The important aspects of the class are that it accepts a Color as a required parameter for its constructor and that it overrides both the createRenderObject and the updateRenderObject. In the createRenderObject it returns an new instance of the RenderTint class.

class Tint extends SingleChildRenderObjectWidget {
  const Tint({
    Key key,
    @required this.color,
    Widget child,
  })  : assert(color != null),
        super(key: key, child: child);

  final Color color;

  @override
  RenderTint createRenderObject(BuildContext context) {
    return RenderTint(
      color: color,
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderTint renderObject) {
    renderObject
      ..color = color;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(ColorProperty('color', color));
  }
}

One thing you’ll notice in the code for the Tint widget is that there’s no code relating to the creation, mounting or unmounting of any element. This is because this is all handled by the SingleChildRenderObjectWidget which overrides the createElement method to return a new instance of the SingleChildRenderObjectElement class.

As a side note, one of the amazing things about Flutter is that as you’re developing your app or creating a widget, you can always drill into the various classes that make up the built-in widgets. In VS Code, pressing F12, when you have a class name selected or the cursor positioned within the class name, will take you to the definition of that class. The Flutter code is extremely well documented and has been split out into various smaller classes to make it easier to identify the purpose of each class.

RenderTint class

Where the purpose of the Tint widget is to allow developers to apply a Tint in their widget hierarchy, the responsibility of the RenderTint class it to draw the tint. The RenderTint is a descendent of the RenderObject. However, rather than implementing all the requirements of a RenderObject, such as calculating size etc, the RenderTint inherits from the RenderProxyBox which handles nearly everything related to layout and positioning.

At the beginning of the class there is some boilerplate code that includes the constructor, that accepts a Color, and the property color that allows the tint colour to be updated. It’s important to note that the setter of the color property does call markNeedPaint, which is important, otherwise the paint method won’t get invoked again.

class RenderTint extends RenderProxyBox {
  RenderTint({
    Color color = Colors.transparent,
    RenderBox child,
  })  : assert(color != null),
        _color = color,
        super(child);

  Color get color => _color;
  Color _color;
  set color(Color color) {
    assert(color != null);
    if (_color == color) return;
    _color = color;
    markNeedsPaint();
    markNeedsSemanticsUpdate();
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      context.paintChild(child, offset);
    }
    context.canvas.drawColor(color, BlendMode.srcOver);
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(ColorProperty('color', color));
  }
}

The paint method is where the actual drawing takes place. In this case it uses the paintChild method on the context to draw any child widgets and then it uses drawColor to apply a fill colour over the top of the canvas.

Tint in Action

That’s pretty much it for defining a simple widget that is responsible for rendering its own content, rather than just composing other widgets. All we need to do now is make use of it.

void main() => runApp(
      Center(
        child: Tint(
          color: Color.fromARGB(40, 255, 0, 0),
          child: Text(
            'Hello World!',
            textDirection: TextDirection.ltr,
          ),
        ),
      ),
    );

And of course, the final output

Hopefully in this post you’ve got an appreciation for the differences between a Widget, Element and RenderObject. I’d highly recommend spending some time walking through some of the Flutter code and understanding how the classes fit together and how you can build better widgets by understanding the relationship these classes have.

Leave a Reply

Your email address will not be published. Required fields are marked *