Make Anything in Flutter using RenderObjects

RenderObjects gives you the power to create anything in Flutter. Learning this you will get a deeper understanding and also a feeling that you can accomplish anything

CODE

Learn the basics of RenderObjects

RenderObjects are very powerful and a lot of the times what the Flutter teams uses when they build all the widgets that you use. You can see this by going to the documentation of the widget and see how some extend things like the LeadRenderObjectWidget. Time to learn how we can do this ourselves!

There are three types

First we must understand the different types of classes that we can extend to create RenderObjects and those are the following:

  • LeafRenderObjectWidget
  • SingleChildRenderObjectWidget
  • MultiChildRenderObjectWidget

Single and multiChild is very understandable, it's when you want to create a RenderObject that can have one or multiple children. LeafRenderObjectWidget though is when you don't want to have any child what so ever. But instead just want to draw.

In this specific example we are going to use a LeafRenderObjectWidget.

Let's start!

Here we have our normal MyApp widget and we have created a ProgressBar now be aware that this will be red in the compiler as we haven't done the implementation yet!

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Center(
        child: Container(
          width: 400,
          //color: Colors.brown,
          child: ProgressBar(
            dotColor: Colors.blue,
            thumbColor: Colors.blue,
            thumbSize: 24,
          ),
        ),
      ),
    );
  }
}

First of we have to create our class which will extend the LeafRenderObjectWidget together with initializing the properties.

class ProgressBar extends LeafRenderObjectWidget {
  const ProgressBar({
    Key key,
    this.dotColor,
    this.thumbColor,
    this.thumbSize,
  }) : super(key: key);

  final Color dotColor;
  final Color thumbColor;
  final double thumbSize;
}

Some things are missing though and that is the override for createRenderObject updateRenderObject and debugFillProperties so let's add those!

class ProgressBar extends LeafRenderObjectWidget {
  const ProgressBar({
    Key key,
    this.dotColor,
    this.thumbColor,
    this.thumbSize,
  }) : super(key: key);

  final Color dotColor;
  final Color thumbColor;
  final double thumbSize;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderProgressBar(
      dotColor: dotColor,
      thumbColor: thumbColor,
      thumbSize: thumbSize,
    );
  }

  @override
  void updateRenderObject(BuildContext context, covariant RenderProgressBar renderObject) {
    renderObject
      ..dotColor = dotColor
      ..thumbColor = thumbColor
      ..thumbSize = thumbSize;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(ColorProperty('dotColor', dotColor));
    properties.add(ColorProperty('thumbColor', thumbColor));
    properties.add(DoubleProperty('thumbSize', thumbSize));
  }
}

Be aware that the RenderProgressBar is not created yet and we will do that next!

RenderBox

Now as this progress bar we are going to create is just paint that is going to render on a 2D cartesian coordinate system we are going to use the RenderBox so let's create that class!

class RenderProgressBar extends RenderBox {
  RenderProgressBar({
    Color dotColor,
    Color thumbColor,
    double thumbSize,
  })  : _dotColor = dotColor,
        _thumbColor = thumbColor,
        _thumbSize = thumbSize;
}

Now a lot of things are missing but we will cover that. So first thing first we can see that the variables we are trying to initialize is private. And there is a reason for that! When these values are set we need to call some methods, similar to how you call NotifyListeners in a ChangeNotifier so let's do that.

class RenderProgressBar extends RenderBox {
  RenderProgressBar({
    Color dotColor,
    Color thumbColor,
    double thumbSize,
  })  : _dotColor = dotColor,
        _thumbColor = thumbColor,
        _thumbSize = thumbSize;

  Color get dotColor => _dotColor;
  Color _dotColor;
  set dotColor(Color value) {
    if (_dotColor == value) {
      return;
    }
    _dotColor = value;
    markNeedsPaint();
  }

  Color get thumbColor => _thumbColor;
  Color _thumbColor;
  set thumbColor(Color value) {
    if (_thumbColor == value) {
      return;
    }
    _thumbColor = value;
    markNeedsPaint();
  }

  double get thumbSize => _thumbSize;
  double _thumbSize;
  set thumbSize(double value) {
    if (_thumbSize == value) {
      return;
    }
    _thumbSize = value;
    markNeedsLayout();
  } 
}

For the things with color we simply call markNeedsPaint() and for the one where the size changes we are going to call markNeedsLayout(). We have some things left and then we can move on to actually doing the painting!

We need to decide the size this RenderObject is going to have and by that we follow the rules of: Constraints go down Sizes go up Parents sets position!

To do this we need to implement the performLayout() method and that is pretty simple, here is how that one will look.

  @override
  void performLayout() {
    final desiredWidth = constraints.maxWidth;
    final desiredHeight = thumbSize;
    final desiredSize = Size(desiredWidth, desiredHeight);
    size = constraints.constrain(desiredSize);
  }

This will make this RenderObject take the full size of the parent but take the height of our thumbSize that is passed down when we use this RenderObject.

Now we can begin the painting!

Painting

Now I am not the best at math (pretty bad tbh) but this worked so if you come up with a better solution make sure to share that!

Let's take a look at the implementation

  // This is used to set the thumb value later.
  double _currentThumbValue = 0.5

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

    // paint dots
    final dotPaint = Paint()
      ..color = dotColor
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 4;

    final barPaint = Paint()
      ..color = Colors.red
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 4;

    final spacing = size.width / 10;
    for (var i = 0; i < 11; i++) {
      var upperPoint = Offset(spacing * i, size.height * 0.75);
      final lowerPoint = Offset(spacing * i, size.height);

      if (i % 5 == 0) {
        upperPoint = Offset(spacing * i, size.height * 0.25);
      }
      if (upperPoint.dx <= _currentThumbValue * size.width) {
        canvas.drawLine(upperPoint, lowerPoint, barPaint);
      } else {
        canvas.drawLine(upperPoint, lowerPoint, dotPaint);
      }
    }

    // setup thumb
    final thumbPaint = Paint()..color = thumbColor;
    final thumbDx = _currentThumbValue * size.width;

    // draw the bar from left to thumb position
    final point1 = Offset(0, size.height / 2);
    final point2 = Offset(thumbDx, size.height / 2);
    canvas.drawLine(point1, point2, barPaint);

    // paint thumb
    final center = Offset(thumbDx, size.height / 2);
    canvas.drawCircle(center, thumbSize / 2, thumbPaint);

    canvas.restore();
  }

First we Save the canvas and the reason for that is because we are going to change the size of it and in the end we want to restore it so that other RenderObjects can paint correctly.

Next we define some Paint, and that is to be used for the actual painting.

The first section with the for loop we draw the dots indicating our 10% 20% 30% and so on. We will later on make the thumb snap to those values.

After this has been done we paint the thumb as well as the line. Make sure that the line is painted first or else the line will be above the thumb which will look pretty weird. The next step is to add some hit detection so we can actually drag that thumb.

Gesture Recognition

This is probably simpler then you think but first we have to make our RenderObject be hit detected and to do that we add the following code.

  // define our variable
  HorizontalDragGestureRecognizer _drag;
  
  // Render object can be hit
  @override
  bool hitTestSelf(Offset position) => true;

  // Handle the hit event and send that to our HorizontalDragGestureRecognizer.
  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (event is PointerDownEvent) {
      _drag.addPointer(event);
    }
  }

I added some comments here to make it simpler to understand, and we also have to dispose this HorizontalDragGestureRecognizer which can be done in the Detach method.

  @override
  void detach() {
    _drag.dispose();
    super.detach();
  }

Now we initialize this HorizontalDragGestureRecognizer that we just created, which we do at the top. We can then use the onStart and onUpdate to call a method that we are going to create!

  RenderProgressBar({
    Color dotColor,
    Color thumbColor,
    double thumbSize,
  })  : _dotColor = dotColor,
        _thumbColor = thumbColor,
        _thumbSize = thumbSize {
    _drag = HorizontalDragGestureRecognizer()
      ..onStart = (DragStartDetails details) {
        _updateThumbPosition(details.localPosition);
      }
      ..onUpdate = (DragUpdateDetails details) {
        _updateThumbPosition(details.localPosition);
      };
  }

Let's take a look at the _updateThumbPosition method.

  void _updateThumbPosition(Offset localPosition) {
    // clamp the position between the full width of the renderobject
    // to avoid if you drag the mouse out of the window.
    var dx = localPosition.dx.clamp(0, size.width);

    // make the size between 0 and 1 with only 1 decimal
    // example 0.4 or 0.7.
    _currentThumbValue = double.parse((dx / size.width).toStringAsFixed(1));

    markNeedsPaint();
    markNeedsSemanticsUpdate();
  }

Now this is all there is to it, let's take a look at the final implementation of everything!

class ProgressBar extends LeafRenderObjectWidget {
  const ProgressBar({
    Key key,
    this.dotColor,
    this.thumbColor,
    this.thumbSize,
  }) : super(key: key);

  final Color dotColor;
  final Color thumbColor;
  final double thumbSize;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderProgressBar(
      dotColor: dotColor,
      thumbColor: thumbColor,
      thumbSize: thumbSize,
    );
  }

  @override
  void updateRenderObject(BuildContext context, covariant RenderProgressBar renderObject) {
    renderObject
      ..dotColor = dotColor
      ..thumbColor = thumbColor
      ..thumbSize = thumbSize;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(ColorProperty('dotColor', dotColor));
    properties.add(ColorProperty('thumbColor', thumbColor));
    properties.add(DoubleProperty('thumbSize', thumbSize));
  }
}

class RenderProgressBar extends RenderBox {
  RenderProgressBar({
    Color dotColor,
    Color thumbColor,
    double thumbSize,
  })  : _dotColor = dotColor,
        _thumbColor = thumbColor,
        _thumbSize = thumbSize {
    _drag = HorizontalDragGestureRecognizer()
      ..onStart = (DragStartDetails details) {
        _updateThumbPosition(details.localPosition);
      }
      ..onUpdate = (DragUpdateDetails details) {
        _updateThumbPosition(details.localPosition);
      };
  }

  double _currentThumbValue = 0.5;

  Color get dotColor => _dotColor;
  Color _dotColor;
  set dotColor(Color value) {
    if (_dotColor == value) {
      return;
    }
    _dotColor = value;
    markNeedsPaint();
  }

  Color get thumbColor => _thumbColor;
  Color _thumbColor;
  set thumbColor(Color value) {
    if (_thumbColor == value) {
      return;
    }
    _thumbColor = value;
    markNeedsPaint();
  }

  double get thumbSize => _thumbSize;
  double _thumbSize;
  set thumbSize(double value) {
    if (_thumbSize == value) {
      return;
    }
    _thumbSize = value;
    markNeedsLayout();
  }

  @override
  void performLayout() {
    final desiredWidth = constraints.maxWidth;
    final desiredHeight = thumbSize;
    final desiredSize = Size(desiredWidth, desiredHeight);
    size = constraints.constrain(desiredSize);
  }

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

    // paint dots
    final dotPaint = Paint()
      ..color = dotColor
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 4;

    final barPaint = Paint()
      ..color = Colors.red
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 4;

    final spacing = size.width / 10;
    for (var i = 0; i < 11; i++) {
      var upperPoint = Offset(spacing * i, size.height * 0.75);
      final lowerPoint = Offset(spacing * i, size.height);

      if (i % 5 == 0) {
        upperPoint = Offset(spacing * i, size.height * 0.25);
      }
      if (upperPoint.dx <= _currentThumbValue * size.width) {
        canvas.drawLine(upperPoint, lowerPoint, barPaint);
      } else {
        canvas.drawLine(upperPoint, lowerPoint, dotPaint);
      }
    }

    // setup thumb
    final thumbPaint = Paint()..color = thumbColor;
    final thumbDx = _currentThumbValue * size.width;

    // draw the bar from left to thumb position
    final point1 = Offset(0, size.height / 2);
    final point2 = Offset(thumbDx, size.height / 2);
    canvas.drawLine(point1, point2, barPaint);

    // paint thumb
    final center = Offset(thumbDx, size.height / 2);
    canvas.drawCircle(center, thumbSize / 2, thumbPaint);

    canvas.restore();
  }

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

  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (event is PointerDownEvent) {
      _drag.addPointer(event);
    }
  }

  void _updateThumbPosition(Offset localPosition) {
    // clamp the position between the full width of the renderobject
    // to avoid if you drag the mouse out of the window.
    var dx = localPosition.dx.clamp(0, size.width);

    // make the size between 0 and 1 with only 1 decimal
    // example 0.4 or 0.7.
    _currentThumbValue = double.parse((dx / size.width).toStringAsFixed(1));

    markNeedsPaint();
    markNeedsSemanticsUpdate();
  }

  @override
  void detach() {
    _drag.dispose();
    super.detach();
  }
}

Congratulations, you have now created your own RenderObject! I urge you to check out creativecreatorormaybenot's video where he has made a full guide to RenderObject. Here is where I learned most of the things so make sure to subscribe to him!