Don’t pass TextStyle to Widgets

Alexander
6 min readJun 14, 2023

--

Don’t pass TextStyle to your widgets!

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.

Passing TextStyle vs DefaultTextStyle.merge()

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 not Theme.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,
),
),
)
Result of the code above (TheBestCustomWidget)

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,
),
),
)
Result of the code above (TheBestCustomWidgetWithIconsSupport)

Notes:

  • Instead of RichText you should use Text.rich(), because the first is more low-level and doesn’t use DefaultTextStyle.

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 👋

--

--