If your custom widgets accept String
and TextStyle
or any other styling: color, weight, textAlign, etc. you are doing it wrong! Instead, your widgets should have properties of type Widget
. Have you ever wondered why TextButton()
, ListTile()
, and others accept widgets (not strings) and how do they style the Text()
widget that you pass into them? — They do it by wrapping their children with DefaultTextStyle
or AnimatedDefaultTextStyle
. This approach is very flexible without a need for hundreds of styling parameters in widget constructors.
In this article, I will show you why the code on the left wouldn’t pass my PR review and what problem the code on the right solves.
Notes:
_WidgetContainer
just adds background color, and padding, and displays children in the Column. You will see its result later in the article.- If you’re wondering why I write
context.textTheme
and notTheme.of(context).textTheme
, you should read the article on Missing extensions in Flutter.
When the TextStyle approach breaks
First, let’s imagine several iterations that a similar widget with 2 String
properties might go through. Look at how fast the code will grow and how complex it becomes.
1. Adding colors
class CustomWidgetWithColor extends StatelessWidget {
const CustomWidgetWithColor({
super.key,
required this.title,
// 1
this.titleColor,
required this.description,
// 2
this.descriptionColor,
});
final String title;
// 3
final Color? titleColor;
final String description;
// 4
final Color? descriptionColor;
@override
Widget build(BuildContext context) {
return _WidgetContainer(
children: [
Text(
title,
style: context.textTheme.headlineMedium?.copyWith(
// 5
color: titleColor ?? Colors.black,
),
),
Text(
description,
style: context.textTheme.bodySmall?.copyWith(
// 6
color: descriptionColor ?? Colors.black54,
),
),
],
);
}
}
Just to add the ability to change text color we need to add 2 properties and change code in 6 places!
2. Adding bold and alignment
class CustomWidgetWithEverything extends StatelessWidget {
const CustomWidgetWithEverything({
super.key,
required this.title,
this.titleColor,
// 1
this.isTitleBold = false,
// 2
this.titleAlignment = TextAlign.start,
required this.description,
this.descriptionColor,
// 3
this.isDescriptionBold = false,
});
final String title;
final Color? titleColor;
// 4
final bool isTitleBold;
// 5
final TextAlign titleAlignment;
final String description;
final Color? descriptionColor;
// 6
final bool isDescriptionBold;
@override
Widget build(BuildContext context) {
return _WidgetContainer(
children: [
Text(
title,
// 7
textAlign: titleAlignment,
style: context.textTheme.headlineMedium?.copyWith(
color: titleColor ?? Colors.black,
// 8
fontWeight: isTitleBold ? FontWeight.bold : FontWeight.normal,
),
),
Text(
description,
style: context.textTheme.bodySmall?.copyWith(
color: descriptionColor ?? Colors.black54,
// 9
fontWeight: isDescriptionBold ? FontWeight.bold : FontWeight.normal,
),
),
],
);
}
}
3 more properties and code changes in 9 places!
❗️ In total, it’s already 5 new properties and 15 changes since the original version!
The next day you get another request: Description should support italic and center alignment; Title should also support light in addition to bold. You should already see where it’s going — To the “boolean” hell.
3. Step in the wrong direction ❌: TextStyle
Sometimes I see that widget accepts several TextStyle
parameters for different texts, but unfortunately, you still need to pass textAlign
separately. The code would have 3 additional properties and changes in 9 places since the original version:
class CustomWidgetWithTextStyle extends StatelessWidget {
const CustomWidgetWithTextStyle({
super.key,
required this.title,
// 1
this.titleStyle,
// 2
this.titleAlignment = TextAlign.start,
required this.description,
// 3
this.descriptionStyle,
});
final String title;
// 4
final TextStyle? titleStyle;
// 5
final TextAlign? titleAlignment;
final String description;
// 6
final TextStyle? descriptionStyle;
@override
Widget build(BuildContext context) {
return _WidgetContainer(
children: [
Text(
title,
// 7
textAlign: titleAlignment,
style: context.textTheme.headlineMedium
?.copyWith(color: Colors.black)
// 8
.merge(titleStyle),
),
Text(
description,
style: context.textTheme.bodySmall
?.copyWith(color: Colors.black54)
// 9
.merge(descriptionStyle),
),
],
);
}
}
Moreover, the customization is still pretty limited. You can’t change maxLines, overflow, softWrap, and textWidthBasis (don’t even know what it is 😅). Fortunately, we have a solution!
Solution ⭐: DefaultTextStyle.merge()
Now I will show you the best way to handle text in the custom widgets. Let’s change the type of String title
and String description
properties to Widget
and remove the alignment. Instead of merging the default text style with provided and passing it into the Text()
widgets, we will wrap the Title and Description into the DefaultTextStyle.merge()
and provide styling there. Here is the code:
class TheBestCustomWidget extends StatelessWidget {
const TheBestCustomWidget({
super.key,
required this.title,
required this.description,
});
final Widget title;
final Widget description;
@override
Widget build(BuildContext context) {
return _WidgetContainer(
children: [
DefaultTextStyle.merge(
style: context.textTheme.headlineMedium?.copyWith(color: Colors.black),
child: title,
),
DefaultTextStyle.merge(
style: context.textTheme.bodySmall?.copyWith(color: Colors.black54),
child: description,
),
],
);
}
}
For basic usage, this widget might become a little harder to use, because instead of a simple string, you should now pass Text(“some string”)
. But this is the exact place that gives us customization without a trillion boolean flags 🎉
TheBestCustomWidget(
title: Text(
'The Best Title',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.purple,
fontWeight: FontWeight.bold,
),
),
description: Text(
'DefaultTextStyle.merge() is the best!',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black,
fontSize: 16,
),
),
)
As you can see, the Title has a headlineMedium
size, but purple color, bold and center alignment. The Description is black (not black54), has a size of 16, and has a center alignment. Everything worked as expected, the default styles are merged with styles passed into the Text()
widgets.
Not only TextStyle!
In the same way, we can also customize other widgets. For example, with IconTheme
we can set the default color and even SIZE for icons.
class TheBestCustomWidgetWithIconsSupport extends StatelessWidget {
const TheBestCustomWidgetWithIconsSupport({
super.key,
required this.title,
required this.description,
});
final Widget title;
final Widget description;
@override
Widget build(BuildContext context) {
// 1. IconTheme
return IconTheme.merge(
// 2. IconThemeData
data: const IconThemeData(
color: Colors.red,
size: 32,
),
child: _WidgetContainer(
children: [
DefaultTextStyle.merge(
style: context.textTheme.headlineMedium?.copyWith(color: Colors.black),
child: title,
),
DefaultTextStyle.merge(
style: context.textTheme.bodySmall?.copyWith(color: Colors.black54),
child: description,
),
],
),
);
}
}
Also, because our properties are of type Widget
, we can pass whatever we need. Even Row()
with rich text and icon!
TheBestCustomWidgetWithIconsSupport(
// Row
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Rich text
Text.rich(
TextSpan(
children: [
TextSpan(
text: 'The',
style: TextStyle(color: Colors.purple),
),
TextSpan(
text: ' Best ',
style: TextStyle(fontStyle: FontStyle.italic),
),
TextSpan(text: 'Title!'),
],
style: TextStyle(
color: Colors.purple,
fontWeight: FontWeight.bold,
),
),
textAlign: TextAlign.center,
),
SizedBox(width: 5),
// Icon
Icon(Icons.favorite),
],
),
description: Text(
'DefaultTextStyle & IconTheme are the best!',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.normal,
fontSize: 16,
),
),
)
Notes:
- Instead of
RichText
you should useText.rich()
, because the first is more low-level and doesn’t useDefaultTextStyle
.
Sometimes you will really need to pass String, TextStyle, Color, or another styling for widgets, but I hope that after learning about the DefaultTextStyle
and IconTheme
, you will find a good use for them! Also, there are even more ways to provide default styling for widgets like overriding Theme for the part of UI.
I recommend you check out my article on how to add the HTTP Authorization header to Chopper requests and retry on 401 Unauthorized response.
Bonus tip ⚡️
If you don’t like that the final widget code has too much nesting, you can fix it with widget modifiers (similar to the SwiftUI) like this:
class TheBestCustomWidgetWithExtensions extends StatelessWidget {
const TheBestCustomWidgetWithExtensions({
super.key,
required this.title,
required this.description,
});
final Widget title;
final Widget description;
@override
Widget build(BuildContext context) {
return _WidgetContainer(
children: [
title
.textStyle(context.textTheme.headlineMedium)
.foregroundColor(Colors.black),
description
.textStyle(context.textTheme.bodySmall)
.foregroundColor(Colors.black54),
],
);
}
}
extension on Widget {
Widget textStyle(TextStyle? style, {TextAlign? align}) {
return DefaultTextStyle.merge(
style: style,
textAlign: align,
child: this,
);
}
Widget foregroundColor(Color color) {
return IconTheme.merge(
data: IconThemeData(color: color),
child: textStyle(TextStyle(color: color)),
);
}
}
This idea is taken from the @luke_pighetti tweet. There you can find more examples of widget modifiers in Flutter!
Also, I invite you to check out my article on Flutter Custom Theme with ThemeExtensions + Templates:
Thank you for reading. Bye 👋