15 Composition and Custom Painting Which Method Should I Use to Customize Widgets

15 Composition and Custom Painting Which Method Should I Use to Customize Widgets #

Hello, I’m Chen Hang.

In the previous presentation, we learned about the most commonly used and classic layout widgets in Flutter, namely the single-child container Container, the multi-child containers Row and Column, and the stack container Stack with Positioned. We also learned the layout rules for placing child widgets among these different containers. By using them, we can achieve alignment, nesting, and stacking of child widgets. These concepts are essential for building a visually appealing app interface.

In practical development, we often encounter complex UI requirements that cannot be met by using Flutter’s basic widgets and setting their property parameters. In such cases, we need to customize widgets for specific scenarios.

In Flutter, customizing widgets is similar to other platforms: you can use basic widgets to assemble a higher-level widget, or you can draw the interface on a canvas based on specific needs.

Next, I will introduce to you the two ways of customizing widgets: combination and custom drawing.

Assembling #

Use a combination of methods to customize Widgets, i.e., use the layout methods introduced earlier to place the basic Widgets required for the project, and set the styles of these basic Widgets within the control to combine them into a more advanced control.

This approach has fewer exposed interfaces, reducing the cost of use, but also enhances the reusability of the control. In Flutter, the concept of composition is always present in framework design, which is one of the reasons why Flutter provides such a rich control library.

For example, in a news application, we often need to combine news icons, titles, summaries, and dates into a separate control to respond to user click events as a whole. When facing such requirements, we can combine existing Images, Texts, and various layouts to create a more advanced news item control, and expose properties for setting the model and click callback.

Next, I will show you how to customize controls by assembling them through an example.

The image below is a schematic illustration of the UI for upgrading items in the App Store. Each item in the image includes an app icon, name, update date, update description, app version, app size, and an update/open button. As you can see, there are relatively more UI elements here, and now we hope to encapsulate the upgrade item UI into a separate control to save the cost of use and subsequent maintenance costs.

Figure 1 App Store Upgrade Item UI

Before analyzing the overall structure of this upgrade item UI, let’s first define a data structure UpdateItemModel to store the upgrade information. For convenience, I have defined all the properties as strings here. In actual use, you can define the properties in a more standardized way according to your needs (such as defining appDate as DateTime).

class UpdateItemModel {
  String appIcon;
  String appName;
  String appSize;
  String appDate;
  String appDescription;
  String appVersion;
  UpdateItemModel({this.appIcon, this.appName, this.appSize, this.appDate, this.appDescription, this.appVersion});
}

Next, I will use Google Maps as an example to analyze the overall structure of this upgrade item UI with you.

According to the layout direction of the child Widgets, there are only two layout methods: horizontal and vertical. Therefore, we will also break down the UI structure into these two dimensions.

In the vertical direction, we divide this UI into the upper and lower parts, as shown in Figure 2. The lower part is relatively simple and consists of a combination of two text controls. The upper part is a bit more complex. We first wrap it in a Row control to create a horizontal layout.

Next, let’s see how to layout in the horizontal direction.

Figure 2 Schematic diagram of the structure of the upgrade item UI

Let’s first break down the upper part of the upgrade item into corresponding UI elements:

  1. The app icon on the left is divided into an Image control.
  2. The button on the right is divided into a FlatButton control.
  3. The middle part is a combination of two texts in the vertical direction, so it is divided into a Column, and inside the Column, there are two Texts.

The breakdown is shown in the following diagram:

Figure 3 Structure schematic diagram of the upper part of the UI

By comparing with the UI before the breakdown, you will find three remaining issues to be resolved: how to set the spacing between controls, what is the shrink (truncation) rule for the middle part, and how to achieve rounded corners for the image. Next, let’s take a look at each of them.

There is a certain spacing between the Image, FlatButton, and Column controls, and therefore we need to wrap Padding around the Image on the left and FlatButton on the right to provide the padding.

On the other hand, considering the need to adapt to screens of different sizes, the two texts in the middle part should be variable and flexible in length, but they cannot expand indefinitely; they still need to be truncated if too long, otherwise they will squeeze the fixed space of the button on the right.

Therefore, we need to wrap the Column control with an Expanded control on the outside to allocate all the space between the Image and FlatButton to the Column. However, in most cases, these two texts cannot completely fill the middle space, so we still need to set the alignment format to center vertically and left align horizontally.

The last thing to note is that the app icon in the upgrade item UI has rounded corners, but the Image control itself does not support rounded corners. In this case, we can use the ClipRRect control to solve this problem. ClipRRect can clip its child Widget according to the rules of rounded rectangles, so by wrapping the Image with ClipRRect, we can achieve rounded corners for the image.

The code below is the key code for the upper part of the control:

Widget buildTopRow(BuildContext context) {
  return Row(
    children: <Widget>[
      Padding(
        padding: EdgeInsets.all(10),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8.0),
          child: Image.asset(model.appIcon, width: 80, height: 80)
        )
      ),
      Expanded(
        child: Column(
  mainAxisAlignment: MainAxisAlignment.center,// Vertically center the content
  crossAxisAlignment: CrossAxisAlignment.start,// Horizontally align the content to the left
  children: <Widget>[
    Text(model.appName,maxLines: 1),// App name
    Text(model.appDate,maxLines: 1),// App update date
  ],
),

), Padding(// Padding widget, used to set the padding between widgets padding: EdgeInsets.fromLTRB(0,0,10,0),// Right padding is 10, others are 0 child: FlatButton(// Button widget child: Text(“OPEN”), onPressed: onPressed,// Callback function for button click ) ) ]);

The lower half of the update item UI is relatively simple, consisting of two text widgets combined together. Similarly to the upper half, we can use a Column widget to wrap them up, as shown in the following image:

Figure 4 - Structure of the lower half of the UI

Just like the upper half, there is some spacing between the two texts and the parent container. Therefore, we still need to use a Padding widget to wrap the Column and set the spacing between the parent container.

On the other hand, there is also spacing between the two text widgets. We can use another Padding widget to wrap the lower text and set the spacing between the two texts.

Similarly, in most cases, these two texts cannot completely fill the lower space. Therefore, we also need to set the alignment format to align them to the left horizontally.

Here is the key code for the lower part of the widget:

Widget buildBottomRow(BuildContext context) {
  return Padding(
    padding: EdgeInsets.fromLTRB(15,0,15,0),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(model.appDescription),
        Padding(
          padding: EdgeInsets.fromLTRB(0,10,0,0),
          child: Text("${model.appVersion} • ${model.appSize} MB")
        )
      ]
  ));
}

Finally, we wrap the top and bottom parts of the widget with a Column widget, and our custom update item UI is complete:

class UpdatedItem extends StatelessWidget {
  final UpdatedItemModel model;
  UpdatedItem({Key key,this.model, this.onPressed}) : super(key: key);
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return Column(
        children: <Widget>[
          buildTopRow(context),
          buildBottomRow(context)
        ]);
  }
  Widget buildBottomRow(BuildContext context) {...}
  Widget buildTopRow(BuildContext context) {...}
}

Try running it and you will see the following result:

Figure 5 - Example of running the update item UI

Done!

Breaking down the UI layout structure from top to bottom and from left to right, and decomposing complex UIs into smaller UI elements, is very useful in customizing UIs. Please remember this decomposition method.

Custom Painting #

Flutter provides a rich set of widgets and layout options that allow us to build a new view by combining existing widgets. However, for some irregular views, it may not be possible to achieve them using the existing widgets provided by the SDK, such as pie charts or candlestick charts. In these cases, we need to use the canvas and paint to draw them ourselves.

In native iOS and Android development, we can inherit from UIView/View and perform drawing operations in the drawRect/onDraw method. Similarly, Flutter provides a similar solution called CustomPaint.

CustomPaint is a container for custom painting widgets and is not responsible for actual drawing. Since we are dealing with drawing, we need to use a canvas and a paintbrush.

In Flutter, the canvas is represented by the Canvas class, and the paintbrush is represented by the Paint class. The appearance of the drawing is controlled by the CustomPainter, which defines the drawing logic. By setting the CustomPainter to the painter property of the CustomPaint container, we have encapsulated a custom painting widget.

For the paintbrush, we can configure various properties such as color, style, and thickness. The canvas provides various common drawing methods, such as drawLine, drawRect, drawPoint, drawPath, drawCircle, and drawArc.

With this in mind, we can implement custom drawing logic in the paint method of CustomPainter using the coordination of the Canvas and Paint.

Next, let’s examine an example.

In the following code, we inherit from CustomPainter. In the paint method, which defines the drawing logic, we use the drawArc method of the Canvas to draw 6 1/6 circular arcs using 6 different colored brushes, creating a pie chart. Finally, we use the CustomPaint container to encapsulate the painter, completing the definition of the pie chart widget called Cake.

class WheelPainter extends CustomPainter {
 // Set brush color
  Paint getColoredPaint(Color color) {//Return a brush based on the color
    Paint paint = Paint();//Generate a new brush
    paint.color = color;//Set brush color
    return paint;
  }

  @override
  void paint(Canvas canvas, Size size) {//Drawing logic
    double wheelSize = min(size.width,size.height)/2;//Size of the pie chart
    double nbElem = 6;//Divide it into 6 parts
    double radius = (2 * pi) / nbElem;//1/6 of a circle
    // Rectangle that wraps around the pie chart
    Rect boundingRect = Rect.fromCircle(center: Offset(wheelSize, wheelSize), radius: wheelSize);
    // Draw each 1/6 circular arc
    canvas.drawArc(boundingRect, 0, radius, true, getColoredPaint(Colors.orange));
    canvas.drawArc(boundingRect, radius, radius, true, getColoredPaint(Colors.black38));
    canvas.drawArc(boundingRect, radius * 2, radius, true, getColoredPaint(Colors.green));
    canvas.drawArc(boundingRect, radius * 3, radius, true, getColoredPaint(Colors.red));
    canvas.drawArc(boundingRect, radius * 4, radius, true, getColoredPaint(Colors.blue));
    canvas.drawArc(boundingRect, radius * 5, radius, true, getColoredPaint(Colors.pink));
  }
  // Determine if a repaint is needed, here we simply compare them to the old delegate
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this;
}
// Wrap the pie chart into a new widget
class Cake extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
        size: Size(200, 200),
        painter: WheelPainter(),
      );
  }
}

Try running the code, and the result should look like the following:

Figure 6: Custom painting example

As you can see, using CustomPainter for custom painting widgets is not complicated. Here, I suggest that you try using brushes and canvases to implement more advanced functionality.

When it comes to fulfilling visual requirements, custom painting requires direct handling of the drawing logic, while composition achieves drawing intent through the combination of child widgets. Therefore, in terms of rendering logic, the custom painting solution allows for deep rendering customization, making it possible to fulfill a few requirements that are difficult to achieve through composition alone (such as pie charts or candlestick charts). However, when the visual effects need adjustment, implementing changes to the drawing code using the custom painting solution may be time-consuming, whereas the composition solution is relatively simple: as long as the layout is designed properly, it can easily be accomplished by changing the type of the child widget.

Summary #

When facing complex UI views, single-purpose widgets provided by Flutter may not directly meet our needs. Therefore, we need to customize widgets. Flutter provides two ways to customize widgets: assembly and painting, to meet our customization requirements for views.

To build UI using assembly, we need to break down the target view into various UI components. Usually, we can decompose the widget hierarchy in the layout order from top to bottom and from left to right, encapsulating basic visual elements into Column and Row. For visual elements with fixed spacing, we can wrap them with Padding, while for visually flexible elements, we can use Expanded widget to fill the blank area of the parent container.

To define widgets using painting, we need to use the CustomPaint container and the CustomPainter that will ultimately implement the real drawing logic. CustomPainter encapsulates the drawing logic, and in its paint method, we can use different types of brushes (Paint) and utilize the different drawing capabilities provided by the Canvas to achieve custom drawing for the widget.

Whether using assembly or painting, when customizing UI, after having an overall impression of the target view, the first thing we need to consider should be how to simplify it, break down the visual elements into smaller parts, and transform them into widgets that we can immediately start implementing. Then, we can think about how to connect these small widgets together. By breaking down the big problem into smaller problems, the implementation goal becomes clearer, and the solutions will naturally emerge.

This process is actually similar to learning new knowledge. After having a preliminary understanding of the overall concept, we also need the ability to simplify complex knowledge: first clarify its logical structure, then break down the unfamiliar knowledge into smaller parts, and finally conquer them one by one.

I have put the two examples shared today on GitHub. You can download them and run them in your project, and learn by referencing today’s knowledge, experiencing the specific use of assembly and painting for customizing widgets in different scenarios.

Thought Questions #

Finally, I’ll leave you with two homework questions.

  • Please extend the UpdatedItem control so that it can automatically truncate long update messages and support expanding when clicked.

  • Please extend the Cake control so that it can define the size of the pie chart arcs based on the values in the passed double array (up to 10 elements).

Feel free to leave me a comment in the comments section to share your thoughts. I’ll be waiting for you in the next article! Thank you for listening, and feel free to share this article with more friends to read together.