Properly handling text scaling in Flutter

Roman Ismagilov
6 min readMay 15, 2024

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.

Users both on Android and iOS have possibility to change text scale

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.

100% text scale vs 160% text scale

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.

Full content is “1234123 users”

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,
)
100% scale, long text; 160% scale, long text; 160% scale, short text

As you can see on the screenshot, having a shortened version helps up to show the information needed.

Hope you’ve found this article useful. I will update it with more techniques whenever I found something useful. Follow me on Twitter to get the latest updates. Code could be found in this repository.

--

--

Roman Ismagilov

Covering some non-obvious nuances of Flutter development in my articles