Creating a Flutter widget from scratch

Suragch
Suragch
Dec 23, 2020 · 21 min read

A guide to building your own custom RenderObject

The custom render object widget you’ll create

The normal way to create widgets in Flutter is through composition, which means combining several basic widgets into a more complex one. That’s not what this article is about, but if your not familiar with the concept, you can read more about it in Creating Reusable Custom Widgets in Flutter.

When the existing Flutter widgets can’t be combined in a way to match what you need, you can use a CustomPaint widget to draw exactly what you want. Again, that’s not what this article is about, but you can see some good examples of it here and here.

When you browse the Flutter source code you’ll discover that the vast majority of widgets use neither composition nor CustomPaint. Instead they use RenderObject or or one of its subclasses, especially RenderBox. Creating a custom widget in this way will give you the most flexibility in painting and sizing your widget — but also the most complexity, which is where this article comes in.

Are you sure you’re ready?

This article assumes you’re already familiar with render objects. If you’re not, you can start by reading Flutter Text Rendering, which includes an exploration of widgets, elements, and render objects.

Also, if this is your first time creating a custom widget, you should probably try the composition or custom painting methods mentioned above first. But if you dare to go low-level, then welcome along. You’ll learn the most by following each step as you read the directions.

The code in this article up to date for Flutter 2.0.0 and Dart 2.12.

The big picture

Creating a custom render object involves the following aspects:

  • Widget: The widget is your interface with the outside world. This is how you get the properties you want. You’ll use these properties to either create or update the render object.

So are you sure you want to make a custom render object? There’s still time to back out.

All right. Here we go then.

Preview

The custom widget you’ll make will look like this:

It’s a simplified version of the Slider widget.

Note: Why not just use a Slider widget then, you say, and skip all the trouble of making a custom render object? The reason I chose this is because I’d like to make an audio player progress bar that shows the download buffer in addition to the current play location. The standard Slider doesn’t do that, though it might be possible to hack a RangeSlider into something close. One could probably also get CustomPaint to work, but it doesn’t give much flexibility with layout. Anyway, given the basic render object above, it will be a fairly simple matter to pass in a few more properties and update the paint method to create the audio player progress bar. Besides, this is an opportunity for both you and me to finally learn about making our own render objects.

We’ll follow the general outline I described in The Big Picture section above. If you get lost along the way, scroll down to the bottom of this article where you’ll find the full code.

Widget

The first step is to make a widget that will allow you to pass properties in to your render object. There are a million customizations that you could allow, but let’s start with these three:

  • progress bar color

The colors will allow you to learn about repainting, and the size will allow you to learn about updating the size of the widget.

Note: I don’t really know why the thumb is called a thumb, but that’s what the Flutter Slider calls it, so I’m using the same name. Think of it more like a handle or a knob. Another name for the progress bar is track bar or seek bar.

Understanding the main parts of the widget

Before creating the widget, have a look at a skeletal outline of its contents:

class ProgressBar extends LeafRenderObjectWidget {
@override
RenderProgressBar createRenderObject(...) {}
@override
void updateRenderObject(...) {}
@override
void debugFillProperties(...) {}
}

Notes:

  • Your widget will extend LeafRenderObjectWidget because it won’t have any children. If you were making a render object with one child you would use SingleChildRenderObjectWidget and for multiple children you’d use MultiChildRenderObjectWidget.

Filling in the details

Create a new file called progress_bar.dart. Add the following code to it. This is just a fuller version of what you saw above.

Ignore the errors about the RenderProgressBar for now. You haven’t created it yet.

In this filled out version of ProgressBar, you can see the barColor, thumbColor, and thumbSize properties are used in the following ways:

  • initializing the constructor

Now that you’ve created the ProgressBar widget, it’s time to create the RenderProgressBar class.

Render object

In this section you’ll create the render object class and add the properties you need.

Creating the class

Create a new class named RenderProgressBar as shown below. You can keep it in the same file as the ProgressBar widget.

class RenderProgressBar extends RenderBox {}

RenderBox is a subclass of RenderObject and has a two-dimensional coordinate system. That is, it has a width and a height.

Adding the constructor

Add the following constructor to RenderProgressBar:

RenderProgressBar({
required Color barColor,
required Color thumbColor,
required double thumbSize,
}) : _barColor = barColor,
_thumbColor = thumbColor,
_thumbSize = thumbSize;

This defines the public properties that you want, but you’ll see some errors since you haven’t created the private fields for them yet. You’ll do that next.

Adding the properties

Add the code for barColor:

Color get barColor => _barColor;
Color _barColor;
set barColor(Color value) {
if (_barColor == value)
return;
_barColor = value;
markNeedsPaint();
}

Since the setter is updating the color, you’ll need to repaint the bar with a new color. Calling markNeedsPaint at the end of the method tells the framework to call the paint method at some point in the near future. Since painting can be potentially expensive, you should only call markNeedsPaint when necessary. That’s the reason for the early return at the beginning of the setter.

Now add the code for thumbColor:

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

This works the same as barColor did.

Finally, add the code for thumbSize:

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

The one difference here is that instead of calling markNeedsPaint, now you are calling markNeedsLayout. That’s because changing the size of the handle will also affect the size of the whole render object. Calling markNeedsLayout tells the system to call the layout method in the near future. Another layout call will automatically result in a repaint, so there is no need to add an additional markNeedsPaint.

Layout and size

If you tried to use your widget now like this:

Scaffold(
body: Center(
child: Container(
color: Colors.white,
child: ProgressBar(
barColor: Colors.blue,
thumbColor: Colors.red,
thumbSize: 20.0,
),
),
),
),

the IDE wouldn’t complain at you (until you try to run the app). Even if you did run the app, though, there would be nothing to see. One reason is because your widget doesn’t have any intrinsic size, and the other reason is because you haven’t painted any content. First let’s handle the size issue.

Take another look at a the progress bar that we want to make:

On a typical screen you’d probably want to width to expand to whatever the parent width is. But for the height, you’d want it to hug height of the handle.

Given that information you can set the size.

Setting the desired size

The computeDryLayout method is where you should calculate how big your widget will be based on the given constraints. In the past this was done in performLayout but now you can put the logic in compute dry layout and just reference it from performLayout. See more information here.

Add the following code to RenderProgressBar:

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

Notes:

  • If you need the sizes of any children you can get them by calling the child’s getDryLayout method and passing in some min and max size constraints. (The old way was to call layout on each of them from inside performLayout.) This gives you (that is, the parent render object) the information you need to place the children and determine your own size. (Remember the quote, “Constraints go down. Sizes go up. Parent sets position.”) Since RenderProgressBar doesn’t have any children, though, (you made a LeafRenderObjectWidget if you recall), all you need to do here is calculate your own size.

Now you’ve officially set the size of your render object, and the parent render object will also have that information.

Setting the intrinsic size

Given only a height constraint, how wide would your widget naturally want to be? Or given only a width constrain, how tall would your widget naturally want to be. That’s what intrinsic size is all about.

Add the following four methods to RenderProgressBar:

static const _minDesiredWidth = 100.0;@override
double computeMinIntrinsicWidth(double height) => _minDesiredWidth;
@override
double computeMaxIntrinsicWidth(double height) => _minDesiredWidth;
@override
double computeMinIntrinsicHeight(double width) => thumbSize;
@override
double computeMaxIntrinsicHeight(double width) => thumbSize;

Notes:

  • The min intrinsic width is the narrowest that the widget would ever want to be. That’s not guaranteeing it’ll never have a smaller width, but this is saying that the widget isn’t designed to be any narrower than that. The Flutter Slider widget uses a hard coded value of 144.0 (or three times a 48-pixel touch target). I figured we could go a little narrower with 100.0.
Text height being affected by width constraint

To understand intrinsic sizes, it’s also helpful to see the difference between width, minIntrinsicWidth, and maxIntrinsicWidth for a Text widget.

This image shows the width. This is based on what the parent told the widget to be and is independent of the content.

The following image shows minIntrinsicWidth. It is the narrowest that this widget would ever want to be. Notice that it is the size of the word “Another”. If you forced the widget to be any narrower than this width, it would make “Another” have to break across lines unnaturally.

Finally, the last image shows maxIntrinsicWidth. It’s the widest that this widget would ever want to be. The first two lines end with a \n newline character. However, because of the width constraint imposed by the parent, the third line soft-wrapped so that “wraps around.” is on the fourth line. If you took the full width of “A line of text that wraps around.” without making it wrap, that would be the value of maxIntrinsicWidth.

See my Stack Overflow answer here for the code and more details.

Testing it out

Our widget doesn’t paint itself yet, so it’s effectively invisible, but it does have a size now. That means we can wrap it with a colored Container as a trick to “see” it.

Replace your main.dart file with the following simple layout:

Run that and you’ll see a cyan colored bar:

Since the Container has the same size as our ProgressBar, we know that the size is working. That’s good.

On to painting some content in there!

Painting

All the drawing action of a render object happens in the paint method.

Add the following code to the RenderProgressBar class:

double _currentThumbValue = 0.5;@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas;
canvas.save();
canvas.translate(offset.dx, offset.dy);
// paint bar
final barPaint = Paint()
..color = barColor
..strokeWidth = 5;
final point1 = Offset(0, size.height / 2);
final point2 = Offset(size.width, size.height / 2);
canvas.drawLine(point1, point2, barPaint);
// paint thumb
final thumbPaint = Paint()..color = thumbColor;
final thumbDx = _currentThumbValue * size.width;
final center = Offset(thumbDx, size.height / 2);
canvas.drawCircle(center, thumbSize / 2, thumbPaint);
canvas.restore();
}

Notes:

  • We’ll define _currentThumbValue to be a number from 0 to 1 that will represent the thumb position on the progress bar, where 0 means far left and 1 means far right. Using 0.5 will place it in middle of the bar for now.

In main.dart, comment out the color on the parent Container widget:

child: Container(
// color: Colors.cyan, <-- comment this out
child: ProgressBar(
...

Now run the app again:

Nice! You can see it now!

It still isn’t interactive yet, though, so let’s handle that.

Hit testing

Hit testing just tells Flutter whether or not you want your widget to handle touch events. Since we want to be able to move the thumb on our progress bar, we definitely do want the render object to handle touch events.

Add the following imports to progress_bar.dart:

import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';

And then add the code below to RenderProgressBar:

late 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);
}
}

Notes:

  • Since you want to be able to move the thumb horizontally along the bar, HorizontalDragGestureRecognizer allows you to get notifications about these kind of touch events. You make it late to give yourself time to initialize it in the constructor. You’ll do that in just a second.

Dealing with the gesture recognizer

Replace the RenderProgressBar constructor with the following code:

RenderProgressBar({
required Color barColor,
required Color thumbColor,
required double thumbSize,
}) : _barColor = barColor,
_thumbColor = thumbColor,
_thumbSize = thumbSize {

// initialize the gesture recognizer
_drag = HorizontalDragGestureRecognizer()
..onStart = (DragStartDetails details) {
_updateThumbPosition(details.localPosition);
}
..onUpdate = (DragUpdateDetails details) {
_updateThumbPosition(details.localPosition);
};
}

And then add the _updateThumbPosition method:

void _updateThumbPosition(Offset localPosition) {
var dx = localPosition.dx.clamp(0, size.width);
_currentThumbValue = dx / size.width;
markNeedsPaint();
markNeedsSemanticsUpdate();
}

Notes:

  • The localPosition is the touch location of the drag event in relation to the top left corner of our widget. This can go out of bounds so you clamp it between zero and the width of the widget.

Run the app again and try dragging the thumb:

Great! It works!

Do you need a repaint boundary?

To achieve the visual effects you got above, you had to repaint the widget every time there was a touch event update. An interesting thing about Flutter is that when a render object repaints itself, generally the parent render objects repaint themselves, too.

You can observe what parts of your app are getting repainted by turning on the debug repaint rainbow. Let’s do that now. In main.dart replace this line:

void main() => runApp(MyApp());

with the following:

void main() {
debugRepaintRainbowEnabled = true;
runApp(MyApp());
}

The debugRepaintRainbowEnabled flag turns the repaint rainbow on. An alternate way to do it is to use the Dart DevTools as I described here. The following images are made using the DevTool version. (Sometime the flag version wasn’t adding a rainbow border for me.)

Now restart the app and move the thumb.

The regions that are being repainted have a rainbow border that changes colors on each repaint. As you can see, the entire window is getting repainted every time you update the thumb position.

Now, for widgets that don’t repaint themselves very often, it doesn’t really matter if the whole parent tree repaints. This is the default behavior in Flutter and even the standard Slider widget is the same. However, an audio progress bar is going to be doing a lot of repainting, not just for when users move the thumb but also whenever the music is playing. For that reason it seems to me that it would be good to limit the repainting to just our widget and not make all of the parent widgets repaint themselves, too.

To limit repainting to just our widget, add this single getter to the RenderProgressBar class:

@override
bool get isRepaintBoundary => true;

The default was false, but now you’re setting it to true.

Run the app again and see the difference:

Now only the progress bar widget is getting repainted. The parent widget tree isn’t.

That’s great isn’t it? Why wouldn’t you always do this? Why isn’t that the default? Well, when you put a repaint boundary around your widget, Flutter makes a new painting layer that is separate from the rest of the tree. Doing so takes more memory resources. If the widget repaints a lot then that’s probably a good use of resources. But if it doesn’t then you’re wasting memory. You need to make that call for your own widget, but in my opinion it makes sense for this one.

Even for widgets that don’t have a repaint boundary, developers can always add one by putting RepaintBoundary in their widget tree. Read the documentation on that and also watch the excellent video Improve your Flutter Apps performance with a RepaintBoundary to learn more.

You can remove the debugging repaint rainbow flag in main.dart now:

void main() {
// debugRepaintRainbowEnabled = true; // <-- delete this
runApp(MyApp());
}

There’s one more step before we’re done.

Semantics

Semantics is about adding the necessary information for Flutter to tell a screen reader what to say when users are interacting with your widget.

Using the semantics debugger

First of all, let’s see what visually impaired users “see” when they use your app. Go to main.dart and wrap MaterialApp with a SemanticsDebugger widget.

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SemanticsDebugger( // <-- Add this
child: MaterialApp(
home: Scaffold(...

Then run your app again:

SemanticsDebugger view

It’s blank. So it looks like our app is completely useless to visually impaired people. Not good. Let’s fix that.

Adding semantics configuration

In progress_bar.dart, add the following methods to RenderProgressBar:

Here’s what that code does:

  • describeSemanticsConfiguration is where you set the textual description for your widget by setting properties on the config object. This method will be called whenever you call markNeedsSemanticsUpdate from elsewhere within the render object.

Run your app again:

SemanticsDebugger view “Progress bar (adjustable)”

It’s hard to see because the font is small, but it says “Progress bar (adjustable)”. You’re widget is now “visible” and interactable to screen readers. Good job.

Remove the SemanticsDebugger widget that you added earlier from your widget layout in main.dart. Run the app again and everything should be back to normal.

A few more notes on semantics

First of all, thank you to creativecreatorormaybenot for help with semantics in the video A definitive guide to RenderObjects in Flutter. Watch that video for a much more in depth guide to making render objects.

I still don’t have much experience using a screen reader so I haven’t actually tested the semantics on a real device. This means there’s a good chance I’m still missing something. Please let me know if you find a bug and I’ll update the article.

Accessibility issues are important to think about. Just recently one of my friends who is visually impaired asked me if I would consider working on a screen reader for traditional Mongolian. That reminded me I need to make sure all of the custom widgets I create are accessible — and semantics is the key to that.

By the way, have you ever tested your app with the system screen reader turned on? Let’s all do that before we publish our next update.

Here’s an introduction to TalkBack, the Android screen reader. It even includes a short discussion about sliders:

Conclusion

Although there is certainly more work that needs to be done on this custom widget, this article took you though all the main steps that you need to think about when building a custom render object. Check out the links below to learn more.

Further study

Full code

main.dart

progress_bar.dart

Update

Are you still here? Well, since you are, would you like to hear about the progress I made after finishing this article?

After making additional improvements to the widget I finally published it on Pub as audio_video_progress_bar. Here is what it looks like:

These are some of the improvements:

  • Added a buffered duration to show the progress of the download buffer for streamed content.
  • Added more parameters for changing the colors and sizes. I also made the colors default to the app theme’s primary color and the labels’ style default to the text theme’s bodyText1 attribute. This makes the widget still look good when the users switch themes, even to a dark theme.
  • Added an enum parameter to allow showing the text labels on the sides or not at all. That made the painting and touch event position handling especially tricky. But in the end it seems to be working all right.
  • Added some widget tests. For a package that lots of people will be using, it’s especially important to add tests. I need to learn more about widget testing, though, because there was some behavior I didn’t know how to test (like dragging the thumb and checking the new duration).

You can browse the source code for the current version of ProgressBar here.

https://www.twitter.com/FlutterComm

Flutter Community

Articles and Stories from the Flutter Community

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store