Writing custom Widgets in Flutter (Part 1) — EllipsizedText

Rostyslav Lesovyi
ITNEXT
Published in
5 min readMay 27, 2021

--

Intro

Declarative UI in Flutter is pretty nice, easy to use and it is very enticing to use it as much as possible. But very often developers just go overboard with it — writing everything in a declarative style even when sometimes task can be done much more efficiently and easier to understand in a more imperative way.

What everyone should understand — there always must be a balance between declarative and imperative programming. Each has its own uses and each shines at some tasks brighter than other.

In this series of articles I’ll describe how to solve different problems by creating custom Widgets from scratch. Each one is a little more complicated than a previous one.

Quick Theory

There some basic things we need to know before looking at the code.

Widget — is just an immutable (preferably const) class that contains configuration properties for Elements and RenderObjects. It is also responsible for creating said Elements and RenderObjects. Important thing to understand — Widgets never contain state nor any business logic, only pass them.

Element — is an entity responsible for the actual UI tree. It has references to all children and (unlike Widget) to its parent. Elements are reused most of the time, unless key or Widget are changed. So if onlyWidget properties are changed, even though new Widget is allocated, Element will remain the same.

State — is nothing more than a user-defined class inside Element that also has some callbacks from itsElement exposed.

RenderObject — is responsible for actual size calculation, children placing, drawing, touch events handling and more. These objects are most closely resembling classic Views from Android or other frameworks.

Why do we have Elements and RenderObjects at the same time? Because of the efficiency. Each Widget has its respective Element, but only some have RenderObjects. Thanks to this a lot of layout, touch and other hierarchy traversal calls can be omitted.

Code

First example will be a pretty simple Widget that ellipsizes text when it doesn’t fit. Why do we need such a Widget when built-in Text has ellipsis support already you might ask? The answer is simple —as of this time it cuts only by words and not by characters (https://github.com/flutter/flutter/issues/18761). So if you have a very long word at the end — most of the time you’ll see only the first letters of this word even when there is a plenty of space left to fill.

So lets start. Flutter has a lot of built-in base classes and mixins that will help with building fully custom Widgets. Here are few of them:

  • LeafRenderObjectWidget — has no children
  • SingleChildRenderObjectWidget — has a single child
  • MultiChildRenderObjectWidget — has any number of children

In our case we will use LeafRenderObjectWidget because we will only need to render text and there will be no children:

enum Ellipsis { start, middle, end }

class EllipsizedText extends LeafRenderObjectWidget {
final String text;
final TextStyle? style;
final Ellipsis ellipsis;

const EllipsizedText(
this.text, {
Key? key,
this.style,
this.ellipsis = Ellipsis.end,
}) : super(key: key);

@override
RenderObject createRenderObject(BuildContext context) {
return RenderEllipsizedText()..widget = this;
}

@override
void updateRenderObject(BuildContext context, RenderEllipsizedText renderObject) {
renderObject.widget = this;
}
}

We have created our Widget. Only thing that is unusual — is a presence of two methods:

  • createRenderObject — responsible for actually creating our RenderObject
  • updateRenderObject — will be called when Widget’s data changes but RenderObject remains the same. In this case we need to update data in RenderObject or otherwise it will render the old text.

I also need to note that it is preferred to copy each value from the Widget to the RenderObject. But I will pass whole Widget, because they are immutable anyways (and I’m too lazy to write all that boilerplate code).

Now lets start with the actual RenderObject:

class RenderEllipsizedText extends RenderBox {
var _widgetChanged = false;
var _widget = const EllipsizedText('');

set widget(EllipsizedText widget) {
if (_widget.text == widget.text &&
_widget.style == widget.style &&
_widget.ellipsis == widget.ellipsis) {
return;
}
_widgetChanged = true;
_widget = widget;
markNeedsLayout();
}
}

Here we defined all our variables and wrote a setter to actually update them. There is also a guard to check if values have actually changed — there is no need to recalculate ellipsis and redraw text if nothing has changed.

Now we need to layout our RenderObject.

class RenderEllipsizedText extends RenderBox {
// ...
var _constraints = const BoxConstraints();

@override
void performLayout() {
if (!_widgetChanged && _constraints == constraints && hasSize) {
return;
}

_widgetChanged = false;
_constraints = constraints;

size =_ellipsize(
minWidth: constraints.minWidth,
maxWidth: constraints.maxWidth,
);
}
}

Process of layout is pretty simple. All we need to do — calculate size of our RenderObject based on the constraints provided to us. Constraints only describe minimal and maximal size that we must comply. Also additional check is added if nothing has change and size was already calculated during previous layout pass.

Actual process of creating ellipsized text is pretty cumbersome and there are definitely better solution but I chose to use binary search to find best match.

class RenderEllipsizedText extends RenderBox {
// ...
final _textPainter = TextPainter(textDirection: TextDirection.ltr);

Size _ellipsize({required double minWidth, required double maxWidth}) {
final text = _widget.text;

if (_layoutText(length: text.length, minWidth: minWidth) > maxWidth) {
var left = 0;
var right = text.length - 1;

while (left < right) {
final index = (left + right) ~/ 2;
if (_layoutText(length: index, minWidth: minWidth) > maxWidth) {
right = index;
} else {
left = index + 1;
}
}
_layoutText(length: right - 1, minWidth: minWidth);
}

return constraints.constrain(Size(_textPainter.width, _textPainter.height));
}
}

I will not go through all of this logic (you can read trough it if you wish). But what is important is that TextPainter is used to calculate text size. If text size is longer than our constraints — I’ll try to make it shorter and shorter until it fits our constraints.

_layoutText is used to calculate our cropped text size:

double _layoutText({required int length, required double minWidth}) {
final text = _widget.text;
final style = _widget.style;
final ellipsis = _widget.ellipsis;

String ellipsizedText = '';

switch (ellipsis) {
case Ellipsis.start:
if (length > 0) {
ellipsizedText = text.substring(text.length - length, text.length);
if (length != text.length) {
ellipsizedText = '...' + ellipsizedText;
}
}
break;
case Ellipsis.middle:
if (length > 0) {
ellipsizedText = text;
if (length != text.length) {
var start = text.substring(0, (length / 2).round());
var end = text.substring(text.length - start.length, text.length);
ellipsizedText = start + '...' + end;
}
}
break;
case Ellipsis.end:
if (length > 0) {
ellipsizedText = text.substring(0, length);
if (length != text.length) {
ellipsizedText = ellipsizedText + '...';
}
}
break;
}

_textPainter.text = TextSpan(text: ellipsizedText, style: style);
_textPainter.layout(minWidth: minWidth, maxWidth: double.infinity);
return _textPainter.width;
}

Pretty much thats it. All we left to do — actually paint our text.

@override
void paint(PaintingContext context, Offset offset) {
_textPainter.paint(context.canvas, offset);
}

And here is the result:

You can find implementation on my GitHub:
https://github.com/MatrixDev/Flutter-CustomWidgets

Hope you liked it!

Other articles:

--

--