auto_fit_text: Flutter text that shrinks to fit, with TextScaler and RTL done right - DEV Community

DEV Community

Cover image for auto_fit_text: Flutter text that shrinks to fit, with TextScaler and RTL done right
Abdeldjalil Chougui
Abdeldjalil Chougui

Posted on Originally published at abdeldjalilchougui.biz

auto_fit_text: Flutter text that shrinks to fit, with TextScaler and RTL done right

A title that is fine in English overflows in German. A price fits on one phone and wraps on a smaller one. A button label is cut off when the user raises the system font size. The common answer in Flutter is a widget that shrinks the text until it fits its box, and for years that widget has been auto_size_text.

The auto_fit_text README describes where that approach breaks today: the deprecated textScaleFactor, measurement that treats every text as left-to-right, crashes on WidgetSpan, groups that flicker, and layouts such as IntrinsicHeight where the widget does not work. auto_fit_text is a replacement with the same API shape that handles those cases.

What it does

Fits width, height and maxLines. It finds the largest font size at which the text fits the available space, between minFontSize and the style's font size.

Binary search with caching. It searches over candidate sizes and caches results per constraints. When the text already fits, there is one text layout.

TextScaler handled correctly. Font sizes are logical. The ambient (or given) TextScaler is applied both when measuring and when painting, so the non-linear scaling of Android 14 works. The deprecated textScaleFactor is not used.

Measures with the same inputs as Text. The ambient Directionality, the Localizations locale, the DefaultTextStyle (style, maxLines, textAlign, softWrap, overflow, textWidthBasis, textHeightBehavior), DefaultTextHeightBehavior and MediaQuery.boldText.

Selectable text. It works inside SelectionArea, and AutoFitText.selectable builds a standalone SelectableText.

Rich text. AutoFitText.rich scales every span by the same factor, inherits the DefaultTextStyle, and does not crash on WidgetSpan.

Groups. AutoFitTextGroup makes several texts share the smallest fitted size. Each member fits against its own constraints only, so a group settles after at most one extra frame and never rebuilds in a loop.

Reading the size. onFontSizeResolved reports the displayed size after the frame. It is safe to call setState from it.

Intrinsic layouts. It supports intrinsic sizes, dry layout and dry baselines, so it works inside IntrinsicHeight, IntrinsicWidth and Chip labels.

Edge cases. It handles zero-size boxes and empty strings. A box that is unbounded in both directions triggers a debug assertion that explains the fix.

How to use it

Add it from GitHub:

dependencies:
  auto_fit_text:
    git: https://github.com/AbdeldjalilChougui/auto_fit_text
Enter fullscreen mode Exit fullscreen mode
import 'package:auto_fit_text/auto_fit_text.dart';
Enter fullscreen mode Exit fullscreen mode

The basic case:

SizedBox(
  width: 200,
  height: 60,
  child: AutoFitText(
    'The quick brown fox jumps over the lazy dog',
    style: const TextStyle(fontSize: 40),
    maxLines: 2,
    minFontSize: 10,
    overflowReplacement: const Text('…'),
    onFontSizeResolved: (size) => debugPrint('fitted at $size'),
  ),
)
Enter fullscreen mode Exit fullscreen mode

The font size starts at the style's font size (capped by maxFontSize) and goes down to minFontSize in steps of stepGranularity. To choose from fixed sizes instead:

const AutoFitText(
  'Supercalifragilisticexpialidocious',
  presetFontSizes: [32, 24, 16],
  maxLines: 1,
)
Enter fullscreen mode Exit fullscreen mode

To share one size between several widgets, keep a group in a State field:

final group = AutoFitTextGroup();

Row(
  children: [
    Expanded(child: AutoFitText('OK', group: group, maxLines: 1)),
    Expanded(child: AutoFitText('Remind me later', group: group, maxLines: 1)),
  ],
)
Enter fullscreen mode Exit fullscreen mode

Without the group, "OK" would stay large while "Remind me later" shrinks, and two buttons side by side would look mismatched.

Rich text, where every span shrinks by the same factor:

const AutoFitText.rich(
  TextSpan(
    text: 'Total: ',
    children: [
      TextSpan(
        text: '1 250,00 DZD',
        style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
      ),
    ],
  ),
  style: TextStyle(fontSize: 24),
  maxLines: 1,
)
Enter fullscreen mode Exit fullscreen mode

Selectable text:

const SelectionArea(child: AutoFitText('Select me', maxLines: 1));
// or, standalone:
const AutoFitText.selectable('Select me', maxLines: 1);
Enter fullscreen mode Exit fullscreen mode

The widget needs a bounded width or height. In a Row or Column, wrap it in Expanded or Flexible. The example/ directory holds a runnable gallery app.

Migrating from auto_size_text

In most cases you rename the widget and the group: AutoSizeText becomes AutoFitText, AutoSizeText.rich becomes AutoFitText.rich, and AutoSizeGroup becomes AutoFitTextGroup. The named arguments are the same. The README has the full table; these are the differences:

  • textScaleFactor: 1.5 becomes textScaler: TextScaler.linear(1.5), and non-linear scalers are supported.
  • minFontSize no longer has to be a multiple of stepGranularity, and stepGranularity can be any positive value.
  • presetFontSizes can be in any order.
  • wrapWords uses the text engine's line-break rules, so a non-breaking space keeps words together.
  • .rich accepts any InlineSpan.
  • New arguments: textWidthBasis, textHeightBehavior, selectionColor, onFontSizeResolved, and the AutoFitText.selectable constructor.

Some behaviour changes are worth checking after the rename:

  • Text is measured with the ambient Directionality and TextAlign.start. The README notes that auto_size_text 3.0.0 measured every text as left-to-right and left-aligned.
  • AutoFitText.rich measures with the DefaultTextStyle merged into the root style, the way Text.rich paints it.
  • overflow and overflowReplacement may be set together.
  • If both the width and the height are unbounded, debug builds throw an assertion that explains the fix. Release builds still render at full size.

The README also lists the open issues in the auto_size_text tracker that this package handles differently, by number: intrinsic layouts and chips, WidgetSpan crashes, selectable text, the deprecated textScaleFactor, group flicker and rebuild loops, reading the font size, missing TextHeightBehavior and TextWidthBasis, overflow with MediaQuery.boldText, TextPainter objects that were never disposed, and the wrapWords and minFontSize restrictions. Each one has a test, except the TextPainter disposal, which is handled in code.

Things to know

  • The search assumes monotonic text. The binary search assumes a smaller font never needs more room than a larger one. This holds for normal text. Unusual fonts or StrutStyles can break it.
  • WidgetSpans are estimated. They cannot be measured before layout, so each one is measured as a square one font size wide. Widgets of a different size can make the fit inexact.
  • Groups need one frame. A group settles in the frame after its members are first laid out, so the first frame can show each member at its own size.
  • It only shrinks. Fitting starts from the style's font size. To fill a large box, give a large fontSize in the style.
  • Caret room. AutoFitText.selectable assumes SelectableText's default 2 px cursor width when reserving room for the caret.
  • When nothing fits. If even minFontSize does not fit, the text is clipped according to overflow unless overflowReplacement is set.
  • What was tested. The tests use the Flutter test font, whose glyphs are one em square. Real fonts go through the same TextPainter paths, but fitting was not checked against golden images on devices.

Why I built it

Shrinking text to fit sounds like a solved problem, and for a left-to-right app at the default font size it mostly is. The gaps the README lists show up in exactly the apps that need this widget most.

An Arabic interface is right-to-left, and measuring it as left-to-right gives a different answer from what is painted. A user who raises the system font size on Android 14 gets non-linear scaling, which a single scale factor cannot describe. Bold-text accessibility settings make text wider than it was measured. A price in a card is often rich text, and a row of buttons needs one shared size that does not flicker.

I wanted a widget that measures with the same inputs Text uses to paint, so that what is measured is what appears. I also wanted the move to be cheap for existing code, which is why the API shape and argument names follow auto_size_text and the README maps each difference.

Get the code

The source, the migration table, the example gallery and the tests are on GitHub: https://github.com/AbdeldjalilChougui/auto_fit_text. It's MIT licensed. Issues and pull requests are welcome.


Originally published at abdeldjalilchougui.biz.

Top comments (0)