What are combinatorial design patterns?
The Combinatorial Design Pattern is one of the structural design patterns. Its purpose in the GoF book is described as follows:
Combine objects into a tree structure to represent a part-whole hierarchy. Combinations allow clients to treat individual objects and combinations of objects uniformly.
To understand the Combinatorial Design Pattern, you should be familiar with tree data structures:
In simple terms, the tree data structure consists of nodes (elements) and edges (relationships between nodes). Each node can have multiple children, but each child node can have only one parent. The root node is the base of the tree and has no parent node. Leaf nodes are tree nodes and do not have any children. In the context of the Composition Design Pattern, we use two types of nodes – Leaf (a component with no children) and Composition (a component that contains one or more children). Basically, any hierarchical data can be represented as a tree structure and stored. The main question is how to implement this structure in code? A very inflexible approach is to define leaf and combination objects in different ways and to consider combination objects as containers for leaf objects by assigning specific logic, interfaces to them. This can lead to the problem of clients needing to deal with leaf and combination objects in different ways, especially when constructing data structures dynamically, making the code very complex. This is one of the main reasons why the Combinatorial Design Pattern is used – to define an abstract class (interfaces are fine) that represents both leaf and combinatorial objects, thus enabling the client to handle each element of the tree structure in the same way.
Doesn’t this sound familiar?” In Flutter, everything is a widget!” , “Flutter Widget Tree”, isn’t it? The Flutter framework builds the application’s UI as a widget tree and allows you to place widgets into other widgets or their containers (widgets that contain the _children_ attribute, e.g., Column
, Row
, ListView
, etc.). It’s pretty much the Portfolio Design Pattern, well, enhanced, with some extra Flutter magic…
The general structure of the combinatorial design pattern is as follows:
Component – An interface that declares the objects in a combination. This interface allows the client to treat leaf and combination objects uniformly.
Leaf – represents a leaf object in the combination. This object has no child elements (subcomponents) and defines the behavior for the original objects in the combination, since they have no objects to which work can be delegated.
Composite – stores child elements (subcomponents) and implements operations related to them in the _Composite_ interface. Unlike a _leaf_ component, a _composite_ object delegates work to its child elements, handles intermediate results, and then returns the final result to the client.
Client – uses the _Component_ interface to interact with objects in the composition structure. This allows the client to work with both simple and complex elements of the tree in the same way.
The Combinatorial Design Pattern should be used to represent a part-whole hierarchy of objects and requires the client to be able to ignore the differences between combinations of objects and individual objects. In my opinion, the hardest part of this pattern is determining when and where you can apply it in your code base. A general rule of thumb is – if you have a set of groups or collections, that’s a pretty good indication that you can use the Combinatorial Design Pattern. A more easily detected situation is – you are using a tree structure. In this case, you should consider where you can apply the pattern to make working with this data structure easier. If you detect these cases in your code, then only implementation details remain, which I will describe below.
This time, the design pattern implementation is more visual (finally!) , and make more sense in a Flutter context (yes, I took your feedback into account, so don’t hesitate to share insights about the series – it helps me greatly improve the quality of the content!) . Suppose we want to represent the structure of a file system. A file system consists of various types of directories and files: audio, video, images, text files, and so on. Files are stored in directories, while directories can be stored in other directories. For example, our file structure might look like the following:
In addition, we want to show the size of each file or directory. For specific files, it’s easy to display their size, but directory sizes depend on the items in them and therefore need to be calculated. To accomplish this, the Combination design pattern is a good choice!
The following class diagram shows the implementation of the Combination design pattern:
IFile
Defines a generic interface for the File
(leaf) and Directory
(combination) classes:
-
getSize()
– Returns the size of the file; -
render()
– Renders the component’s UI.
File
class implements the getSize()
and render()
methods and also contains the title
, size
and icon
properties. Directory
implements the same required methods, but it also contains the title, isInitiallyExpanded
and files
lists, contains the IFile
object, and defines the addFile()
method, which allows adding the IFile
object to a directory (file list). AudioFile
The ImageFile
, TextFile
and VideoFile
classes extend the File
class to specify file types.
IFile
An interface that defines the methods implemented by leaf and combination components.
abstract interface class IFile {
int getSize();
Widget render(BuildContext context);
}
File
Implements a concrete implementation of the IFile
interface that matches the leaf class in the combinatorial design pattern. In the File
class, the getSize()
method simply returns the file size, while the render()
method returns the file’s UI widget, which is used in the example screen.
base class File extends StatelessWidget implements IFile {
final String title;
final int size;
final IconData icon;
const File({
required this.title,
required this.size,
required this.icon,
});
@override
int getSize() => size;
@override
Widget render(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: LayoutConstants.paddingS),
child: ListTile(
title: Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
leading: Icon(icon),
trailing: Text(
FileSizeConverter.bytesToString(size),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.black54),
),
dense: true,
),
);
}
@override
Widget build(BuildContext context) => render(context);
}
Concrete classes that extend File
All of these classes extend the File
class and specify specific file types by providing unique icons for the corresponding file types.
final class AudioFile extends File {
const AudioFile({
required super.title,
required super.size,
}) : super(icon: Icons.music_note);
}
final class ImageFile extends File {
const ImageFile({
required super.title,
required super.size,
}) : super(icon: Icons.image);
}
final class TextFile extends File {
const TextFile({
required super.title,
required super.size,
}) : super(icon: Icons.description);
}
final class VideoFile extends File {
const VideoFile({
required super.title,
required super.size,
}) : super(icon: Icons.movie);
}
Directory
Implements a concrete implementation of the IFile
interface that matches the composite
class in the Combination design pattern. Similar to the File
class, render()
returns the UI widget for the catalog, which is used in the example screens. However, in this class, the getSize()
method calculates the catalog size by calling the getSize()
method for each item in the files
list and adding the result. This is the main idea of the combinatorial design pattern, which allows combinatorial classes to handle all the elements in a containment list in the same way, as long as they implement the same interface.
class Directory extends StatelessWidget implements IFile {
final String title;
final bool isInitiallyExpanded;
final List<IFile> files = [];
Directory(this.title, {this.isInitiallyExpanded = false});
void addFile(IFile file) => files.add(file);
@override
int getSize() {
var sum = 0;
for (final file in files) {
sum += file.getSize();
}
return sum;
}
@override
Widget render(BuildContext context) {
return Theme(
data: ThemeData(
colorScheme: ColorScheme.fromSwatch().copyWith(primary: Colors.black),
),
child: Padding(
padding: const EdgeInsets.only(left: LayoutConstants.paddingS),
child: ExpansionTile(
leading: const Icon(Icons.folder),
title: Text('$title (${FileSizeConverter.bytesToString(getSize())})'),
initiallyExpanded: isInitiallyExpanded,
children: files.map((IFile file) => file.render(context)).toList(),
),
),
);
}
@override
Widget build(BuildContext context) => render(context);
}
FileSizeConverter
To represent file sizes in a more attractive format, the FileSizeConverter
helper class was created, which provides a static method called bytesToString()
that converts file size values from bytes to human-readable text.
class FileSizeConverter {
const FileSizeConverter._();
static String bytesToString(int bytes) {
final sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var len = bytes.toDouble();
var order = 0;
while (len >= 1024 && order++ < sizes.length - 1) {
len /= 1024;
}
return '${len.toStringAsFixed(2)} ${sizes[order]}';
}
}
Example
First, a Markdown file was prepared and provided as a description of the model:
CompositeExample
The widget contains the buildMediaDirectory()
method for building the file structure of the example. This method demonstrates the combinatorial design pattern – even if the components are of different types, they can be handled in the same way, because all components implement the IFile
interface. This allows us to build the tree structure of the IFile
component by placing Directory
objects into other directories and mixing them with specific File
objects.
class CompositeExample extends StatelessWidget {
const CompositeExample();
Widget _buildMediaDirectory() {
final musicDirectory = Directory('Music')
..addFile(const AudioFile(title: 'Darude - Sandstorm.mp3', size: 2612453))
..addFile(const AudioFile(title: 'Toto - Africa.mp3', size: 3219811))
..addFile(
const AudioFile(
title: 'Bag Raiders - Shooting Stars.mp3',
size: 3811214,
),
);
final moviesDirectory = Directory('Movies')
..addFile(const VideoFile(title: 'The Matrix.avi', size: 951495532))
..addFile(
const VideoFile(title: 'The Matrix Reloaded.mp4', size: 1251495532),
);
final catPicturesDirectory = Directory('Cats')
..addFile(const ImageFile(title: 'Cat 1.jpg', size: 844497))
..addFile(const ImageFile(title: 'Cat 2.jpg', size: 975363))
..addFile(const ImageFile(title: 'Cat 3.png', size: 1975363));
final picturesDirectory = Directory('Pictures')
..addFile(catPicturesDirectory)
..addFile(const ImageFile(title: 'Not a cat.png', size: 2971361));
final mediaDirectory = Directory('Media', isInitiallyExpanded: true)
..addFile(musicDirectory)
..addFile(musicDirectory)
..addFile(moviesDirectory)
..addFile(picturesDirectory)
..addFile(Directory('New Folder'))
..addFile(
const TextFile(title: 'Nothing suspicious there.txt', size: 430791),
)
..addFile(const TextFile(title: 'TeamTrees.txt', size: 104));
return mediaDirectory;
}
@override
Widget build(BuildContext context) {
return ScrollConfiguration(
behavior: const ScrollBehavior(),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: LayoutConstants.paddingL,
),
child: _buildMediaDirectory(),
),
);
}
}