Perché il testo merita più attenzione

Nella maggior parte delle app Flutter il testo è il contenuto principale, eppure spesso ci si ferma al widget Text. Appena i requisiti si fanno seri — una parola in grassetto dentro una frase, un link cliccabile nei termini di servizio, l'evidenziazione dei risultati di ricerca, un "Leggi tutto" che compare solo quando serve — servono strumenti più precisi.

In questa guida vediamo l'intero stack del testo in Flutter: TextSpan, WidgetSpan, RichText, SelectionArea, TextPainter, StrutStyle e il nuovo TextScaler.

Il modello: gli InlineSpan

Il widget Text non è altro che un wrapper attorno a RichText, che a sua volta renderizza un albero di InlineSpan. Le due implementazioni concrete sono:

  • TextSpan: un frammento di testo con uno stile, che può contenere figli;
  • WidgetSpan: un widget arbitrario incorporato nel flusso del testo.

Gli stili si ereditano lungo l'albero: lo stile del nodo figlio viene fuso con quello del padre. Questo permette di definire una base e sovrascrivere solo ciò che serve.

Text.rich(
  TextSpan(
    style: Theme.of(context).textTheme.bodyMedium,
    children: [
      const TextSpan(text: 'Hai '),
      TextSpan(
        text: '3 nuovi messaggi',
        style: const TextStyle(fontWeight: FontWeight.bold),
      ),
      const TextSpan(text: ' da leggere.'),
    ],
  ),
)

Text.rich è preferibile a RichText nel codice applicativo, perché eredita automaticamente il DefaultTextStyle, gestisce il text scaling e applica le impostazioni di accessibilità del MediaQuery. RichText è il livello più basso: usalo solo se sai esattamente cosa stai facendo.

WidgetSpan: icone e widget in linea

WidgetSpan consente di inserire qualsiasi widget nel flusso del testo, mantenendo l'allineamento alla baseline.

Text.rich(
  TextSpan(
    children: [
      const TextSpan(text: 'Premi '),
      WidgetSpan(
        alignment: PlaceholderAlignment.middle,
        child: Icon(Icons.favorite, size: 16, color: Colors.red.shade400),
      ),
      const TextSpan(text: ' per salvare il contenuto.'),
    ],
  ),
)

Attenzione: ogni WidgetSpan introduce un sotto-albero di rendering completo. In liste molto lunghe, un uso massiccio di WidgetSpan può costare più di un semplice glifo; se ti serve solo un'icona statica, valuta un font di icone renderizzato come TextSpan con fontFamily: 'MaterialIcons'.

Porzioni di testo cliccabili

Per rendere cliccabile un singolo span si usa un GestureRecognizer. La regola fondamentale è che il recognizer va disposto, quindi serve uno StatefulWidget.

class TermsText extends StatefulWidget {
  const TermsText({super.key, required this.onTapTerms});

  final VoidCallback onTapTerms;

  @override
  State<TermsText> createState() => _TermsTextState();
}

class _TermsTextState extends State<TermsText> {
  late final TapGestureRecognizer _recognizer;

  @override
  void initState() {
    super.initState();
    _recognizer = TapGestureRecognizer()..onTap = () => widget.onTapTerms();
  }

  @override
  void dispose() {
    _recognizer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Text.rich(
      TextSpan(
        style: theme.textTheme.bodySmall,
        children: [
          const TextSpan(text: 'Proseguendo accetti i '),
          TextSpan(
            text: 'Termini di servizio',
            style: TextStyle(
              color: theme.colorScheme.primary,
              decoration: TextDecoration.underline,
            ),
            recognizer: _recognizer,
            // Migliora l'accessibilità: lo screen reader annuncia un link
            semanticsLabel: 'Termini di servizio, link',
            mouseCursor: SystemMouseCursors.click,
          ),
          const TextSpan(text: '.'),
        ],
      ),
    );
  }
}

Due dettagli spesso dimenticati:

  • mouseCursor: su web e desktop senza di esso il cursore resta una freccia e il link non sembra tale;
  • area di tocco: uno span di testo piccolo può essere difficile da centrare con il dito. Se il link è un elemento di azione importante, valuta un TextButton con padding ridotto anziché uno span.

Selezione del testo: SelectionArea

Da Flutter 3.3 la selezione del testo si gestisce a livello di sottoalbero con SelectionArea, che rende selezionabili tutti i widget testuali contenuti (inclusi quelli dentro Column, ListView, ecc.), senza sostituire Text con SelectableText.

SelectionArea(
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: const [
      Text('Titolo dell\'articolo', style: TextStyle(fontSize: 20)),
      SizedBox(height: 8),
      Text('Corpo del testo selezionabile insieme al titolo...'),
    ],
  ),
)

Per casi singoli resta valido SelectableText.rich, utile quando serve un TextSelectionControls personalizzato o un onSelectionChanged puntuale. Nota che dentro SelectionArea i TapGestureRecognizer degli span continuano a funzionare: il framework distingue tap e drag di selezione.

Evidenziare i risultati di ricerca

Un classico: dato un testo e una query, evidenziare tutte le occorrenze. Si risolve costruendo dinamicamente la lista di span.

List<TextSpan> highlightOccurrences(
  String source,
  String query, {
  required TextStyle baseStyle,
  required TextStyle highlightStyle,
}) {
  if (query.isEmpty || source.isEmpty) {
    return [TextSpan(text: source, style: baseStyle)];
  }

  final spans = <TextSpan>[];
  final lowerSource = source.toLowerCase();
  final lowerQuery = query.toLowerCase();
  var start = 0;

  while (true) {
    final index = lowerSource.indexOf(lowerQuery, start);
    if (index < 0) {
      spans.add(TextSpan(text: source.substring(start), style: baseStyle));
      break;
    }
    if (index > start) {
      spans.add(TextSpan(text: source.substring(start, index), style: baseStyle));
    }
    spans.add(TextSpan(
      text: source.substring(index, index + query.length),
      style: highlightStyle,
    ));
    start = index + query.length;
  }
  return spans;
}

Uso nel widget:

Text.rich(
  TextSpan(
    children: highlightOccurrences(
      item.title,
      searchQuery,
      baseStyle: theme.textTheme.bodyLarge!,
      highlightStyle: theme.textTheme.bodyLarge!.copyWith(
        backgroundColor: theme.colorScheme.primaryContainer,
        fontWeight: FontWeight.w600,
      ),
    ),
  ),
  maxLines: 2,
  overflow: TextOverflow.ellipsis,
)

Attenzione alle lingue accentate: toLowerCase() non normalizza i diacritici. Se ti serve una ricerca "accent-insensitive", normalizza entrambe le stringhe con una mappa di sostituzione mantenendo la stessa lunghezza, altrimenti gli indici calcolati non corrisponderanno più al testo originale.

TextPainter: misurare il testo prima di disegnarlo

TextPainter è la classe che Flutter usa internamente per il layout del testo, ed è disponibile anche a noi. Serve quando devi sapere in anticipo quanto spazio occuperà un testo o se andrà in overflow.

Il caso d'uso più comune è il "Leggi tutto" che deve apparire solo se il testo eccede davvero le righe disponibili.

class ExpandableText extends StatefulWidget {
  const ExpandableText({
    super.key,
    required this.text,
    this.collapsedMaxLines = 3,
  });

  final String text;
  final int collapsedMaxLines;

  @override
  State<ExpandableText> createState() => _ExpandableTextState();
}

class _ExpandableTextState extends State<ExpandableText> {
  bool _expanded = false;

  bool _isOverflowing({
    required String text,
    required TextStyle style,
    required double maxWidth,
    required TextScaler textScaler,
  }) {
    final painter = TextPainter(
      text: TextSpan(text: text, style: style),
      maxLines: widget.collapsedMaxLines,
      textDirection: Directionality.of(context),
      textScaler: textScaler,
    )..layout(maxWidth: maxWidth);

    final exceeded = painter.didExceedMaxLines;
    painter.dispose(); // importante: libera le risorse native
    return exceeded;
  }

  @override
  Widget build(BuildContext context) {
    final style = Theme.of(context).textTheme.bodyMedium!;
    final textScaler = MediaQuery.textScalerOf(context);

    return LayoutBuilder(
      builder: (context, constraints) {
        final overflowing = _isOverflowing(
          text: widget.text,
          style: style,
          maxWidth: constraints.maxWidth,
          textScaler: textScaler,
        );

        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            AnimatedSize(
              duration: const Duration(milliseconds: 200),
              alignment: Alignment.topCenter,
              child: Text(
                widget.text,
                style: style,
                maxLines: _expanded ? null : widget.collapsedMaxLines,
                overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
              ),
            ),
            if (overflowing)
              TextButton(
                onPressed: () => setState(() => _expanded = !_expanded),
                child: Text(_expanded ? 'Mostra meno' : 'Leggi tutto'),
              ),
          ],
        );
      },
    );
  }
}

Punti chiave:

  • layout(maxWidth: ...) va sempre chiamato prima di leggere qualsiasi metrica (size, didExceedMaxLines, computeLineMetrics);
  • lo style, il textScaler e il maxWidth passati al painter devono essere identici a quelli del widget reale, altrimenti la misura è sbagliata;
  • dispose() è necessario perché TextPainter trattiene risorse native (ui.Paragraph).

Altre metriche utili

final painter = TextPainter(
  text: TextSpan(text: 'Ciao mondo', style: style),
  textDirection: TextDirection.ltr,
)..layout(maxWidth: 200);

final Size size = painter.size;                 // dimensioni effettive
final double baseline =
    painter.computeDistanceToActualBaseline(TextBaseline.alphabetic);
final List<ui.LineMetrics> lines = painter.computeLineMetrics();
final Offset caret = painter.getOffsetForCaret(
  const TextPosition(offset: 4),
  Rect.zero,
);
painter.dispose();

computeLineMetrics() è preziosissimo per disegnare evidenziazioni personalizzate su CustomPainter, mentre getOffsetForCaret e getPositionForOffset sono la base per costruire editor o overlay allineati a una specifica parola.

TextScaler: il text scaling moderno

Da Flutter 3.16 textScaleFactor è deprecato in favore di TextScaler, che supporta il ridimensionamento non lineare introdotto da Android 14 (i testi già grandi crescono meno di quelli piccoli).

// ❌ vecchio approccio
final factor = MediaQuery.of(context).textScaleFactor;
final size = 16 * factor;

// ✅ approccio corretto
final scaler = MediaQuery.textScalerOf(context);
final size = scaler.scale(16);

Se devi limitare l'ingrandimento (per esempio in una barra con spazio fisso) non disattivarlo: applica un clamp ragionevole.

MediaQuery.withClampedTextScaling(
  maxScaleFactor: 1.4,
  child: const MyDenseHeader(),
)

Ricorda anche di non fissare mai altezze in pixel per contenitori di testo: usa IntrinsicHeight, padding e Flexible così che il layout regga anche a scala 2.0.

StrutStyle e altezze coerenti

Quando in una riga convivono font diversi, emoji e WidgetSpan, l'altezza della riga può "ballare". StrutStyle definisce un'altezza minima garantita indipendente dal contenuto: utilissimo in chat e liste dove ogni riga deve avere la stessa altezza.

Text(
  message,
  strutStyle: const StrutStyle(
    fontSize: 16,
    height: 1.4,
    forceStrutHeight: true,
  ),
)

Correlato è TextHeightBehavior, che controlla se lo spazio derivante da height viene applicato anche sopra la prima riga e sotto l'ultima:

Text(
  'Titolo compatto',
  style: const TextStyle(fontSize: 28, height: 1.2),
  textHeightBehavior: const TextHeightBehavior(
    applyHeightToFirstAscent: false,
    applyHeightToLastDescent: false,
  ),
)

È il trucco che elimina lo spazio "fantasma" sopra i titoli grandi, quello che fa sembrare il tuo layout diverso dal mockup di Figma.

Font features e numeri allineati

I font OpenType espongono funzionalità che spesso risolvono problemi di UI in una riga di codice. Il caso più utile è tabularFigures, che rende tutte le cifre della stessa larghezza: indispensabile per timer, prezzi e tabelle che altrimenti "vibrano" a ogni aggiornamento.

Text(
  '00:09:58',
  style: const TextStyle(
    fontFeatures: [FontFeature.tabularFigures()],
    fontSize: 32,
  ),
)

Altre feature comuni: FontFeature.slashedZero(), FontFeature.enable('smcp') per il maiuscoletto e, per i font variabili, fontVariations: [FontVariation('wght', 550)] per pesi intermedi.

Best practice operative

  • Usa const dove possibile: const TextSpan(...) evita ricostruzioni e permette al framework di riutilizzare il layout.
  • Non ricreare i GestureRecognizer in build: causano leak e tap fantasma. Creali in initState e liberali in dispose.
  • Non usare TextPainter a ogni frame: se la misura dipende solo da testo, stile e larghezza, mettila in cache in una mappa chiavizzata su quei tre valori.
  • Testa a scala 2.0 e in RTL: molti bug di testo emergono solo con MediaQuery a scala massima o con Directionality(textDirection: TextDirection.rtl).
  • Verifica con i golden test: le modifiche a StrutStyle, height o font sono esattamente il tipo di regressione visiva che sfugge alla revisione manuale.
  • Non affidarti all'italico sintetico: se il font non ha la variante italic, Flutter non la inventa su tutte le piattaforme. Includi il file del font corretto in pubspec.yaml.

Conclusione

Passare da Text a Text.rich e da "spero che entri" a TextPainter cambia la qualità percepita dell'interfaccia: link precisi, evidenziazioni corrette, righe di altezza costante e layout che non si rompono quando l'utente ingrandisce i caratteri. Sono API di basso livello ma stabili e ben documentate: vale la pena padroneggiarle, perché nel 90% delle schermate il contenuto è, appunto, testo.