Modularizing Flutter UI with Factory Constructors

Ximya
8 min readJun 18, 2023

--

Above are two forms of dialog commonly used throughout the app. They have similar designs but differ in the configuration and number of buttons at the bottom.

How would you modularizethese dialogs?

There are several ways to do it, but in this post, I will focus on using a “factory constructor” to modularize similar UI elements with an emphasis on maintainability, clarity, and readability.

1. Modularize with Two Separate Widget Classes

The simplest approach would be to create separate widget classes for each. However, this form is not considered conducive to maintainbility. For example, if you need to modify the top padding of the modal, you would have to make the same modification in both dialogs, resulting in repetitive and cumbersome work. Let’s say it’s just two instances, but what if you have 10 such dialogs? Then you would have to perform a tedious task multiple times. Moreover, modularizing different classes in this way does not adhere to the DRY. (Don’t Repeat Yourself) principle.

2. Modularize with Property Value Condition in the Default Constructor

So, how about distinguishing the common part and the bottom button area in a single class and handling them appropriately using conditional branching? In the above code, the boolean field value isDivideBtnFormat is used as a condition to branch the two button areas with different configurations. With this approach, maintenance becomes much easier than before, but there is a drawback in implementing required properties as optional properties based on conditions.

For example, let’s say we are implementing a dialog with a single button. In that case, you only need to initialize the required propertiesas shown in the code below:

AppDialog(title: 'Title', btnText: 'Button Text', onBtnClicked: () {...},
isDividedBtnFormat: false)

On the other hand, for a dialog with two buttons, you also need to initialize the optional properties onLeftBtnClicked and leftBtnText as they are required:

AppDialog(leftBtnText: 'Close', onLeftBtnClicked: () {...},
title: 'Title', btnText: 'Button Text', onBtnClicked: () {...},
isDividedBtnFormat: true)

Here’s where mistakes can happen. When initializing the properties for a dialog with two buttons, if you accidentally forget to initialize the leftBtnClicked property, problems can occur.

// Accidentally omitting the initialization of the 'leftBtnClicked' property for a dialog with two buttons
AppDialog(leftBtnText: 'Close', title: 'Title',
btnText: 'Button Text', onBtnClicked: () {...},
isDividedBtnFormat: true)

In the above code, we intended to implement a dialog with two buttons, but we forgot to initialize the optional property leftBtnClicked. Since it is declared as an optional property, the compiler cannot catch this error even at the compilation stage.

What if we change it to required properties?

If we change all the properties to required properties, can we solve the problem? While it can prevent the mistake of not initializing the required properties, it would result in poor "readability".

AppDialog(title: 'Title', btnText: 'Button Text',
onBtnClicked: () {...}, isDividedBtnFormat: false,
leftBtnText: null, onLeftBtnClicked: null)

In the above code, even when creating a dialog with a single button, we have to initialize the unused leftBtnText and onLeftBtnClicked properties as null, resulting in unnecessary code and poor "readability".

3. Modularization Based on Factory Constructor

Earlier, we discussed three major issues with the modularization methods:

  • Not conducive to maintainability
  • Poor readability
  • Lack of explicitness, leading to potential errors

These three issues can be addressed using the Factory pattern, specifically through the use of a factory constructor.

What is the Factory Pattern?

[Definition] The factory method pattern is an object-oriented design pattern. The factory method is a pattern that creates objects without specifying the exact class to create. The factory method allows a class to defer instantiation to subclasses. Wikipedia

Although the definition may seem complex, the factory is, in essence, the creation of instances, similar to a factory producing goods.

To understand the factory pattern more easily, let’s use the example of producing infantry units in the Terran Barracks building in the game StarCraft. The Barracks is a facility that can produce infantry units for the Terran race. Players can produce the desired infantry unit by paying the required resources.

The above image provides information about the available infantry unit “types,” the required “resources,” and the “hotkeys” for production. Now, assuming the “class” represents the Barracks and the “instance” represents the infantry unit, we can write code as follows:

class Barracks {
final int mineral; // Mineral
final int supply; // Supply
final int? gas; // gas

Barracks({
required this.mineral,
required this.supply,
this.gas,
});

// Marine
factory Barracks.M({required int mineral, required int supply}) => Barracks(mineral: mineral, supply: supply);
// Firebat
factory Barracks.F({required int mineral, required int gauss, required int supply}) => Barracks(mineral: mineral, gauss: gauss, supply: supply);
// Ghost
factory Barracks.G({required int mineral, required int gauss, required int supply}) => Barracks(mineral: mineral, gauss: gauss, supply: supply);
// Medic
factory Barracks.C({required int mineral, required int gauss, required int supply}) => Barracks(mineral: mineral, gauss: gauss, supply: supply);
}

We use a “factory constructor” to dynamically determine the type of unit the user wants to create, allowing for flexible selection of objects.

UI Module Using Factory Pattern

Now, let’s create a dialog UI module using the factory pattern.

class AppDialog extends Dialog {
const AppDialog({
Key? key,
this.isDividedBtnFormat = false,
this.description,
this.subTitle,
this.onLeftBtnClicked,
this.leftBtnText,
required this.btnText,
required this.onBtnClicked,
required this.title,
}) : super(key: key);

factory AppDialog.singleBtn({
required String title,
required VoidCallback onBtnClicked,
String? subTitle,
String? description,
String? btnContent,
}) =>
AppDialog(
title: title,
subTitle: subTitle,
onBtnClicked: onBtnClicked,
description: description,
btnText: btnContent,
);

factory AppDialog.dividedBtn({
required String title,
String? description,
String? subTitle,
required String leftBtnContent,
required String rightBtnContent,
required VoidCallback onRightBtnClicked,
required VoidCallback onLeftBtnClicked,
}) =>
AppDialog(
isDividedBtnFormat: true,
title: title,
subTitle: subTitle,
onBtnClicked: onRightBtnClicked,
onLeftBtnClicked: onLeftBtnClicked,
description: description,
leftBtnText: leftBtnContent,
btnText: rightBtnContent,
);


final bool isDividedBtnFormat;
final String title;
final String? description;
final VoidCallback onBtnClicked;
final VoidCallback? onLeftBtnClicked;
final String? btnText;
final String? leftBtnText;
final String? subTitle;

@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: EdgeInsets.zero,
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
margin: AppInset.horizontal16,
constraints: const BoxConstraints(minHeight: 120, maxWidth: 256),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: AppColor.strongGrey,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 34) +
const EdgeInsets.only(top: 18, bottom: 19),
child: Column(
children: [
Center(
child: Text(
title,
style: AppTextStyle.title3.copyWith(color: AppColor.main),
textAlign: TextAlign.center,
),
),
AppSpace.size12,
if (subTitle.hasData) ...[
Text(
subTitle!,
style: AppTextStyle.alert1,
textAlign: TextAlign.center,
),
AppSpace.size2,
],
if (description.hasData) ...[
Center(
child: Text(
description!,
textAlign: TextAlign.center,
style: AppTextStyle.desc
.copyWith(color: AppColor.lightGrey, height: 1.3),
),
)
]
],
),
),


// If the dialog is in a format with a single button, return the widget below
if (isDividedBtnFormat)
Container(
height: 44,
decoration: const BoxDecoration(
border: Border(
top: BorderSide(
color: AppColor.gray06,
width: 0.5,
),
),
),
child: Row(
children: <Widget>[
Expanded(
child: MaterialButton(
padding: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(10),
),
),
onPressed: onLeftBtnClicked,
child: Center(
child: Text(
leftBtnText!,
style: AppTextStyle.title3
.copyWith(color: AppColor.white),
),
),
),
),
Container(
width: 0.5,
color: AppColor.gray06,
),
Expanded(
child: MaterialButton(
padding: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(10),
),
),
onPressed: onBtnClicked,
child: Center(
child: Text(
btnText ?? 'Confirm',
style: AppTextStyle.title3
.copyWith(color: AppColor.white),
),
),
),
),
],
),
),

// If the dialog is in a format with a single button, return the widget below
if (!isDividedBtnFormat)
MaterialButton(
padding: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(10),
bottomRight: Radius.circular(10),
),
),
onPressed: onBtnClicked,
child: Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(
color: AppColor.gray06,
width: 0.5,
),
),
),
height: 50,
child: Center(
child: Text(
btnText ?? 'Confirm',
style:
AppTextStyle.title3.copyWith(color: AppColor.white),
),
),
),
),
],
),
),
);
}
}

We use factory constructors to return two different types of dialogs based on the button configuration. Instead of making onLeftBtnClicked and leftBtnText optional properties that must be implemented when creating the dividedBtn widget, we use the factory constructor to accept the required parameters and pass them to the object. This reduces the chance of mistakes by catching them during the compilation stage.

showDialog(
context: context,
builder: (_) => AppDialog.singleBtn(
onBtnClicked: () {},
title: 'Title',
description: 'Body Text',
),
);

showDialog(
context: context,
builder: (_) => AppDialog.dividedBtn(
title: 'Title',
subTitle: 'Subtitle',
description: 'Body',
leftBtnText: 'Left Button Text',
rightBtnText: 'Right Button Text',
onRightBtnClicked: () {},
onLeftBtnClicked: () {}
),
);

Moreover, with the use of factory constructors, the objects can be created using the constructor’s name, and the parameter names can be dynamically changed, resulting in more “explicit” and “readable” code. (e.g., btnText -> rightBtnText). Additionally, since common characteristics have been modularized, it becomes much easier to "maintain" the code.

Handling Loading Views with Factory Constructors

Furthermore, using factory constructors, we can appropriately handle “loading views” such as skeletons to be displayed before loading data in UI widgets.

// [Full Module Code]
class RoundProfileImg extends StatelessWidget {
const RoundProfileImg({Key? key, required this.size, required this.imgUrl})
: super(key: key);

final double size;
final String? imgUrl;

// factory constructor, create Skeleton loading view
factory RoundProfileImg.createSkeleton({required double size}) =>
RoundProfileImg(size: size, imgUrl: 'skeleton');

@override
Widget build(BuildContext context) {
if (imgUrl == 'skeleton') {
return SkeletonBox(
height: size,
width: size,
borderRadius: size / 2,
);
} else {
return ClipRRect(
borderRadius: BorderRadius.circular(size / 2),
child: imgUrl.hasData
? CachedNetworkImage(
height: size,
width: size,
memCacheHeight: (size * 3).toInt(),
imageUrl: imgUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => const SkeletonBox(),
errorWidget: (context, url, error) => Container(
color: Colors.grey.withOpacity(0.1),
child: const Center(
child: Icon(Icons.error),
),
),
)
: Container(
color: Colors.red,
child: Image.asset(
'assets/images/blanck_profile.png',
height: size,
width: size,
),
),
);
}
}
}
 // [examples]
if (imgUrl != null) {
return RoundProfileImg(size: 62, imgUrl: imgUrl);
} else {
return RoundProfileImg.createSkeleton(size: 62);
}

In the code above, we create a RoundProfileImg widget that displays a circular profile image. By using the factory constructor RoundProfileImg.loading, we can create a loading skeleton view without passing an imgUrl. This provides a convenient way to handle different states of the widget without duplicating code or cluttering the code with conditional statements.

Conclusion

In this post, we explored how to modularize similar UI elements using a factory constructor. By applying the factory pattern, we were able to improve the maintainability, readability, and explicitness of the code. Additionally, we demonstrated how factory constructors can be used to handle different states of UI widgets, such as loading views. By leveraging these techniques, you can create modular and flexible UI components that are easy to maintain and understand.

--

--