Properly handling text scaling in Flutter
This tutorial is structured to present the easiest and most impactful solutions first. The later sections cover solutions that are harder to implement and have a lower overall impact, but they are useful for addressing specific cases.
Limit possible extent of text scaling
You can set minimum and maximum scale factors for your MaterialApp, which will ensure that all the text scales within the specified limits. Tighter boundaries require less effort to maintain readability and aesthetics. However, the choice of boundaries should depend on your target audience. For example, if your app is designed for elderly users, you should consider using looser boundaries to accommodate their needs.
MaterialApp(
...
builder: (_, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: MediaQuery.of(context)
.textScaler
.clamp(minScaleFactor: 0.8, maxScaleFactor: 1.6),
),
child: child!,
),
);
Don’t use fixed height for elements that contain text
Take a look at this piece of code:
//DON'T
SizedBox(
height: 100,
child: Card(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Title", style: TextStyle(fontSize: 30), maxLines: 1),
Text("Subtitle", maxLines: 1),
],
),
),
),
),
What could possibly go wrong?
As you might have guessed, increasing the text size can cause the contents of a SizedBox to take up too much space.
A better solution is to make the item’s height based on the content height and padding. Additionally, you can use a ConstrainedBox
to set a minimum height.
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 100),
child: const Card(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Title", style: TextStyle(fontSize: 30), maxLines: 1),
Text("Subtitle", maxLines: 1),
],
),
),
),
),
As a result we get an identical layout on 100% scale and a valid layout on 160%.
Same applies to ListViews. If you’re using itemExtent
, consider either calculating it using font scale or providing a prototypeItem
. More about that in this article.
Let’s move on. Imagine having this layout:
Item A is from the previous example. Item B has some padding and should handle increased text scaling as well. There’s plenty of space at the bottom of the screen. So, what could possibly go wrong?
Don’t forget about phones with smaller screens. Additionally, text length can vary when the language changes.
Make sure the content is scrollable
First, we should eliminate any overflow to ensure our users can access all the content. Adding a simple SingleChildScrollView
will solve this issue.
Consider using adaptive values for margins and paddings
This might be a controversial approach, but imagine yourself as a user who needs to use larger fonts. Would you prefer to see a lot of unused space or clearly read the text?
Let’s use values that depend on the number of logical pixels to display text. You can adjust the smallScreenThreshold
to whatever value makes the most sense for your application.
class Dimens {
static const smallScreenThreshold = 300;
static bool isSmallWidth(BuildContext context) {
return MediaQuery.of(context).size.width /
MediaQuery.textScalerOf(context).scale(1) <
smallScreenThreshold;
}
static double small(BuildContext context) => isSmallWidth(context) ? 4 : 8;
static double medium(BuildContext context) => isSmallWidth(context) ? 8 : 16;
static double large(BuildContext context) => isSmallWidth(context) ? 16 : 32;
}
Note that if you want to follow Human Interface Guidelines and Material Design, these values should be divisible by 4.
Based on these Dimens
we can make a class for insets:
class Insets {
static EdgeInsets small(BuildContext context) =>
EdgeInsets.all(Dimens.small(context));
static EdgeInsets medium(BuildContext context) =>
EdgeInsets.all(Dimens.medium(context));
static EdgeInsets large(BuildContext context) =>
EdgeInsets.all(Dimens.large(context));
}
And in the code we replace it like that:
//padding: const EdgeInsets.all(16),
padding: Insets.medium(context),
//SizedBox(height: 16),
SizedBox(height: Dimens.medium(context)),
As a result we have won a bit more space to draw the text on the screen:
Limit how large text size can expand for titles
The main purpose of increasing font scale is to make content readable for people with deteriorating vision. However, some parts of the app, such as titles, might already be accessible due to their large fonts. To address this, we can limit the extent to which text can scale up. One way to achieve this is by creating a custom widget for titles:
class TitleText extends StatelessWidget {
final String text;
final TextStyle style;
const TitleText(this.text, {required this.style, super.key});
static const double maxRealFontSize = 30;
@override
Widget build(BuildContext context) {
if (MediaQuery.textScalerOf(context).scale(style.fontSize!) >
maxRealFontSize) {
return Text(
text,
style: style.copyWith(
fontSize: maxRealFontSize / MediaQuery.textScalerOf(context).scale(1),
),
);
}
return Text(text, style: style);
}
}
By doing that we can win some more space without losing readability. You can change that maxRealFontSize
to any value that suits your app more.
Specify maximum amount of lines and text overflow
Don’t forget that some texts that might look good on a large screen with normal text scale, but could take way more vertical space in some other conditions, but you don’t always need to show the full content of it, for example in subtitles. Just add a maxLines
value to a Text widget.
Looks good with maxLines
set to 1. Main information is still visible.
Use alternative versions of strings
But it’s not always possible to shorten a string in a way that it still contains useful information. Additionally, word order varies in different languages. What might be the first word in English could be at the end of a sentence in another language. Let’s consider this example of internationalization (i18n) strings:
"tasksDone": {
"one": "You have done $completed of $n tasks",
"other": "You have done $completed of $n tasks"
},
"tasksDoneShort": {
"one": "$completed/$n tasks done",
"other": "$completed/$n tasks done"
},
The most meaningful part is the one that shows numbers. In the shorter version we have put it in the beginning and made the whole string shorter. And in the code you can use it like that:
Text(
Dimens.isSmallWidth(context)
? t.tasksDoneShort(n: 10, completed: 5)
: t.tasksDone(n: 10, completed: 5),
maxLines: 1,
)
As you can see on the screenshot, having a shortened version helps up to show the information needed.