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?

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

  • 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.
  • Children: A widget, and in turn a render object, can have zero, one, or many children. The number of children affect the size and layout of the render object. In this article you’ll make a render object with no children. This will simplify a number of steps.
  • Layout: In Flutter, constraints go down, so you’re given a max and min width and length from the parent and then expected to report back how big you want to be within those constraints. Will you wrap your content tightly or will you try to expand as large as your parent allows? Part of creating a render object is making information about your intrinsic size available. Since the custom widget that you’ll be making in this article won’t have any children, you don’t need to worry about asking them how big they want to be or about positioning them. However, you will need to report back how much space you need to paint your content.
  • Painting: This step is very similar to what you would do in a CustomPaint widget. You’ll receive a canvas that you can draw on.
  • Hit testing: This tells Flutter whether you want to handle touch events or let them pass through to the widget below. In this article you’ll also add a gesture detector to help you handle the touch events.
  • Semantics: This is related to providing additional text information about your widget. You won’t see this text, but Flutter can use to help visually impaired users. If you don’t know anyone who’s blind, then it’s tempting to skip this step. But don’t.

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

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

  • progress bar color
  • thumb color
  • thumb size

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

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.
  • The Flutter framework (that is, the element) will call createRenderObject when it wants to create the render object associated with this widget. Since we named the widget ProgressBar, it’s customary to prefix this with “Render” when naming the render object. That’s why you have the return value of RenderProgressBar. Note that you haven’t created this class yet. It’s the render object class that you’ll be working on later.
  • Widgets are inexpensive to create, but it would be expensive to recreate render objects every time there was an update. So when a widget property changes, the system will call updateRenderObject, where you will simply update the public properties of your render object without recreating the whole object.
  • The debugFillProperties method provides information about the class properties during debugging, but it’s not very interesting for the purposes of this article.

Filling in the details

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
  • creating a new instance of RenderProgressBar
  • updating an existing instance of RenderProgressBar
  • providing debug information

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

Render object

Creating the class

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

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

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

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

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.
  • The constraints variable is of type BoxContraints and is a property of RenderBox. These BoxContraints are passed in from the parent and tell you the max and min width and length that you’re allowed to be. You can choose any size for yourself within those bounds. By choosing maxWidth you’re saying that you want to expand to be as big as the parent allows. For the desired height you’re hugging your content by using the thumbSize property.
  • Passing your desired size into constraints.constrain makes sure that you are still within the allowed constraints. For example, if thumbSize were large, it could exceed the constraints.maxHeight from the parent, which isn’t allowed.
  • The size variable is also a property of RenderBox. You should only set it from within the performLayout method. Everywhere else you should call markNeedsLayout. Also, the computeDryLayout method should not change any state.
  • If you’re simply expanding to fill the parent or wrapping a single child, then you don’t need to override performLayout. See the documentation for more on this.

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

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.
  • In setting the max width, you could have used double.infinity, but is infinity intrinsically the size you want to be, though? If not, then using the min width is reasonable. This is similar to what the Slider widget does.
  • I used the same values for the max and min widths and also the same values for the max and min heights. That’s because a progress bar’s height isn’t affected by the width constraints. In the same way, the width isn’t affected by the height constraints. If you think about the Text widget though, that would make a difference. As you can see in the animation below, making the width narrower causes the intrinsic height of the text to want to be taller.
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

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

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.
  • The offset parameter of the paint method tells you where the top left corner of your layout is on the canvas. Saving the canvas and then translating to that position means you don’t need to worry about this offset for any of the other drawing that your about to do. However, at the end of the paint method you call restore to undo your saved translate so that other widgets that may paint later don’t get messed up.
  • For the actual painting, first draw the bar that the thumb moves along. The color is taken from the widget parameter barColor.
  • Then paint the thumb. It’s vertically centered and the horizontal position is based on _currentThumbValue. When that value changes and markNeedsPaint is called, the new position gets repainted here.

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

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.
  • Returning true in hitTestSelf tells Flutter that touch events get handled by this widget. They won’t be passed on to any widgets below this one. hitTestSelf also provides a position parameter, so you could theoretically sometimes return true and sometimes return false based on the position of the touch event. This would be useful if you had a donut-shaped widget where you wanted to let touch events in the hole and on the outside pass through.
  • The docs say to use debugHandleEvent here. So I did. Apparently it does something useful.
  • handleEvent adds a PointerDownEvent to the drag gesture recognizer, but you still need to initialize it and handle other events, which you’ll do next.

Dealing with the gesture recognizer

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.
  • _currentThumbValue needs to be a value between 0 and 1, so you divide the touch position on the x axis by the total width.
  • Call markNeedsPaint to repaint the new position of the thumb.
  • If the thumb position changes, this represents a new state value of our widget. Calling markNeedsPaint tells Flutter to update the visual representation of this new state and calling markNeedsSemanticsUpdate tells Flutter to update the textual description of this new state. You haven’t implemented that yet, but you’ll do that in the next step.

Run the app again and try dragging the thumb:

Great! It works!

Do you need a repaint boundary?

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

Using the semantics debugger

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

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.
  • The label and value are what a screen reader would read when describing this widget. Since we don’t really want the screen reader to say “0.3478595746 Progress bar”, we converted the thumb value to a nicer number like 35%. This is what the Slider widget does as well.
  • Users with a screen reader are able to use custom gestures to perform actions. By adding callbacks for onIncrease and onDecrease you are supporting those custom gestures. This provides an alternate way to move the thumb since these users can’t see its visual location. The _semanticActionUnit of 0.05 just means that whenever the onIncrease or onDecrease action is triggered, the thumb will increase or decrease by 5%.

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

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

Further study

Full code

progress_bar.dart

Update

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 text labels for the current playing progress and total time. At first I added these as Text widgets by wrapping the ProgressBar render object widget with a StatelessWidget and formed the layout using composition with rows and columns. I wanted to add a repaint boundary, but constantly rebuilding the widget with new values for the Text widgets was triggering a new layout and causing all of the parent widgets in the whole UI to get rebuilt (and repainted). You can read about that problem here. I ended up discarding the StatelessWidget and solved it by painting the labels directly with a TextPainter. That way I could avoid calling markNeedsLayout every time the widget rebuilds. This, in combination with setting isRepaintBoundary to true, solved the problem and the widget no longer causes the whole widget tree to repaint when it rebuilds.
  • 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).
  • Added documentation comments for the public methods, properties and classes. This is also very important for a widget that other developers will be using. I also added some comments to remind the future me about what is happening in some of the tricky parts of the code.

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