[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-gestione-avanzata-del-testo-in-flutter-richtext-textspan-e-textpainter":3,"comments-article-gestione-avanzata-del-testo-in-flutter-richtext-textspan-e-textpainter":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},98,"Gestione avanzata del testo in Flutter: RichText, TextSpan e TextPainter","gestione-avanzata-del-testo-in-flutter-richtext-textspan-e-textpainter","Oltre il widget Text: come comporre testi ricchi con TextSpan e WidgetSpan, rendere cliccabili porzioni di testo, evidenziare i risultati di ricerca, misurare il layout con TextPainter e rispettare il text scaling.","## Perché il testo merita più attenzione\n\nNella 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.\n\nIn questa guida vediamo l'intero stack del testo in Flutter: `TextSpan`, `WidgetSpan`, `RichText`, `SelectionArea`, `TextPainter`, `StrutStyle` e il nuovo `TextScaler`.\n\n## Il modello: gli InlineSpan\n\nIl widget `Text` non è altro che un wrapper attorno a `RichText`, che a sua volta renderizza un albero di `InlineSpan`. Le due implementazioni concrete sono:\n\n- `TextSpan`: un frammento di testo con uno stile, che può contenere figli;\n- `WidgetSpan`: un widget arbitrario incorporato nel flusso del testo.\n\nGli 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.\n\n```dart\nText.rich(\n  TextSpan(\n    style: Theme.of(context).textTheme.bodyMedium,\n    children: [\n      const TextSpan(text: 'Hai '),\n      TextSpan(\n        text: '3 nuovi messaggi',\n        style: const TextStyle(fontWeight: FontWeight.bold),\n      ),\n      const TextSpan(text: ' da leggere.'),\n    ],\n  ),\n)\n```\n\n`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.\n\n## WidgetSpan: icone e widget in linea\n\n`WidgetSpan` consente di inserire qualsiasi widget nel flusso del testo, mantenendo l'allineamento alla baseline.\n\n```dart\nText.rich(\n  TextSpan(\n    children: [\n      const TextSpan(text: 'Premi '),\n      WidgetSpan(\n        alignment: PlaceholderAlignment.middle,\n        child: Icon(Icons.favorite, size: 16, color: Colors.red.shade400),\n      ),\n      const TextSpan(text: ' per salvare il contenuto.'),\n    ],\n  ),\n)\n```\n\nAttenzione: 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'`.\n\n## Porzioni di testo cliccabili\n\nPer rendere cliccabile un singolo span si usa un `GestureRecognizer`. La regola fondamentale è che il recognizer va **disposto**, quindi serve uno `StatefulWidget`.\n\n```dart\nclass TermsText extends StatefulWidget {\n  const TermsText({super.key, required this.onTapTerms});\n\n  final VoidCallback onTapTerms;\n\n  @override\n  State\u003CTermsText> createState() => _TermsTextState();\n}\n\nclass _TermsTextState extends State\u003CTermsText> {\n  late final TapGestureRecognizer _recognizer;\n\n  @override\n  void initState() {\n    super.initState();\n    _recognizer = TapGestureRecognizer()..onTap = () => widget.onTapTerms();\n  }\n\n  @override\n  void dispose() {\n    _recognizer.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final theme = Theme.of(context);\n    return Text.rich(\n      TextSpan(\n        style: theme.textTheme.bodySmall,\n        children: [\n          const TextSpan(text: 'Proseguendo accetti i '),\n          TextSpan(\n            text: 'Termini di servizio',\n            style: TextStyle(\n              color: theme.colorScheme.primary,\n              decoration: TextDecoration.underline,\n            ),\n            recognizer: _recognizer,\n            \u002F\u002F Migliora l'accessibilità: lo screen reader annuncia un link\n            semanticsLabel: 'Termini di servizio, link',\n            mouseCursor: SystemMouseCursors.click,\n          ),\n          const TextSpan(text: '.'),\n        ],\n      ),\n    );\n  }\n}\n```\n\nDue dettagli spesso dimenticati:\n\n- **`mouseCursor`**: su web e desktop senza di esso il cursore resta una freccia e il link non sembra tale;\n- **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.\n\n## Selezione del testo: SelectionArea\n\nDa 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`.\n\n```dart\nSelectionArea(\n  child: Column(\n    crossAxisAlignment: CrossAxisAlignment.start,\n    children: const [\n      Text('Titolo dell\\'articolo', style: TextStyle(fontSize: 20)),\n      SizedBox(height: 8),\n      Text('Corpo del testo selezionabile insieme al titolo...'),\n    ],\n  ),\n)\n```\n\nPer 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.\n\n## Evidenziare i risultati di ricerca\n\nUn classico: dato un testo e una query, evidenziare tutte le occorrenze. Si risolve costruendo dinamicamente la lista di span.\n\n```dart\nList\u003CTextSpan> highlightOccurrences(\n  String source,\n  String query, {\n  required TextStyle baseStyle,\n  required TextStyle highlightStyle,\n}) {\n  if (query.isEmpty || source.isEmpty) {\n    return [TextSpan(text: source, style: baseStyle)];\n  }\n\n  final spans = \u003CTextSpan>[];\n  final lowerSource = source.toLowerCase();\n  final lowerQuery = query.toLowerCase();\n  var start = 0;\n\n  while (true) {\n    final index = lowerSource.indexOf(lowerQuery, start);\n    if (index \u003C 0) {\n      spans.add(TextSpan(text: source.substring(start), style: baseStyle));\n      break;\n    }\n    if (index > start) {\n      spans.add(TextSpan(text: source.substring(start, index), style: baseStyle));\n    }\n    spans.add(TextSpan(\n      text: source.substring(index, index + query.length),\n      style: highlightStyle,\n    ));\n    start = index + query.length;\n  }\n  return spans;\n}\n```\n\nUso nel widget:\n\n```dart\nText.rich(\n  TextSpan(\n    children: highlightOccurrences(\n      item.title,\n      searchQuery,\n      baseStyle: theme.textTheme.bodyLarge!,\n      highlightStyle: theme.textTheme.bodyLarge!.copyWith(\n        backgroundColor: theme.colorScheme.primaryContainer,\n        fontWeight: FontWeight.w600,\n      ),\n    ),\n  ),\n  maxLines: 2,\n  overflow: TextOverflow.ellipsis,\n)\n```\n\nAttenzione 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.\n\n## TextPainter: misurare il testo prima di disegnarlo\n\n`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.\n\nIl caso d'uso più comune è il \"Leggi tutto\" che deve apparire solo se il testo eccede davvero le righe disponibili.\n\n```dart\nclass ExpandableText extends StatefulWidget {\n  const ExpandableText({\n    super.key,\n    required this.text,\n    this.collapsedMaxLines = 3,\n  });\n\n  final String text;\n  final int collapsedMaxLines;\n\n  @override\n  State\u003CExpandableText> createState() => _ExpandableTextState();\n}\n\nclass _ExpandableTextState extends State\u003CExpandableText> {\n  bool _expanded = false;\n\n  bool _isOverflowing({\n    required String text,\n    required TextStyle style,\n    required double maxWidth,\n    required TextScaler textScaler,\n  }) {\n    final painter = TextPainter(\n      text: TextSpan(text: text, style: style),\n      maxLines: widget.collapsedMaxLines,\n      textDirection: Directionality.of(context),\n      textScaler: textScaler,\n    )..layout(maxWidth: maxWidth);\n\n    final exceeded = painter.didExceedMaxLines;\n    painter.dispose(); \u002F\u002F importante: libera le risorse native\n    return exceeded;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final style = Theme.of(context).textTheme.bodyMedium!;\n    final textScaler = MediaQuery.textScalerOf(context);\n\n    return LayoutBuilder(\n      builder: (context, constraints) {\n        final overflowing = _isOverflowing(\n          text: widget.text,\n          style: style,\n          maxWidth: constraints.maxWidth,\n          textScaler: textScaler,\n        );\n\n        return Column(\n          crossAxisAlignment: CrossAxisAlignment.start,\n          children: [\n            AnimatedSize(\n              duration: const Duration(milliseconds: 200),\n              alignment: Alignment.topCenter,\n              child: Text(\n                widget.text,\n                style: style,\n                maxLines: _expanded ? null : widget.collapsedMaxLines,\n                overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,\n              ),\n            ),\n            if (overflowing)\n              TextButton(\n                onPressed: () => setState(() => _expanded = !_expanded),\n                child: Text(_expanded ? 'Mostra meno' : 'Leggi tutto'),\n              ),\n          ],\n        );\n      },\n    );\n  }\n}\n```\n\nPunti chiave:\n\n- `layout(maxWidth: ...)` va **sempre** chiamato prima di leggere qualsiasi metrica (`size`, `didExceedMaxLines`, `computeLineMetrics`);\n- lo `style`, il `textScaler` e il `maxWidth` passati al painter devono essere identici a quelli del widget reale, altrimenti la misura è sbagliata;\n- `dispose()` è necessario perché `TextPainter` trattiene risorse native (`ui.Paragraph`).\n\n### Altre metriche utili\n\n```dart\nfinal painter = TextPainter(\n  text: TextSpan(text: 'Ciao mondo', style: style),\n  textDirection: TextDirection.ltr,\n)..layout(maxWidth: 200);\n\nfinal Size size = painter.size;                 \u002F\u002F dimensioni effettive\nfinal double baseline =\n    painter.computeDistanceToActualBaseline(TextBaseline.alphabetic);\nfinal List\u003Cui.LineMetrics> lines = painter.computeLineMetrics();\nfinal Offset caret = painter.getOffsetForCaret(\n  const TextPosition(offset: 4),\n  Rect.zero,\n);\npainter.dispose();\n```\n\n`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.\n\n## TextScaler: il text scaling moderno\n\nDa 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).\n\n```dart\n\u002F\u002F ❌ vecchio approccio\nfinal factor = MediaQuery.of(context).textScaleFactor;\nfinal size = 16 * factor;\n\n\u002F\u002F ✅ approccio corretto\nfinal scaler = MediaQuery.textScalerOf(context);\nfinal size = scaler.scale(16);\n```\n\nSe devi limitare l'ingrandimento (per esempio in una barra con spazio fisso) **non disattivarlo**: applica un clamp ragionevole.\n\n```dart\nMediaQuery.withClampedTextScaling(\n  maxScaleFactor: 1.4,\n  child: const MyDenseHeader(),\n)\n```\n\nRicorda 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.\n\n## StrutStyle e altezze coerenti\n\nQuando 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.\n\n```dart\nText(\n  message,\n  strutStyle: const StrutStyle(\n    fontSize: 16,\n    height: 1.4,\n    forceStrutHeight: true,\n  ),\n)\n```\n\nCorrelato è `TextHeightBehavior`, che controlla se lo spazio derivante da `height` viene applicato anche sopra la prima riga e sotto l'ultima:\n\n```dart\nText(\n  'Titolo compatto',\n  style: const TextStyle(fontSize: 28, height: 1.2),\n  textHeightBehavior: const TextHeightBehavior(\n    applyHeightToFirstAscent: false,\n    applyHeightToLastDescent: false,\n  ),\n)\n```\n\nÈ il trucco che elimina lo spazio \"fantasma\" sopra i titoli grandi, quello che fa sembrare il tuo layout diverso dal mockup di Figma.\n\n## Font features e numeri allineati\n\nI 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.\n\n```dart\nText(\n  '00:09:58',\n  style: const TextStyle(\n    fontFeatures: [FontFeature.tabularFigures()],\n    fontSize: 32,\n  ),\n)\n```\n\nAltre feature comuni: `FontFeature.slashedZero()`, `FontFeature.enable('smcp')` per il maiuscoletto e, per i font variabili, `fontVariations: [FontVariation('wght', 550)]` per pesi intermedi.\n\n## Best practice operative\n\n- **Usa `const` dove possibile**: `const TextSpan(...)` evita ricostruzioni e permette al framework di riutilizzare il layout.\n- **Non ricreare i `GestureRecognizer` in `build`**: causano leak e tap fantasma. Creali in `initState` e liberali in `dispose`.\n- **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.\n- **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)`.\n- **Verifica con i golden test**: le modifiche a `StrutStyle`, `height` o font sono esattamente il tipo di regressione visiva che sfugge alla revisione manuale.\n- **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`.\n\n## Conclusione\n\nPassare 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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002F4f46fa05-b1fb-4dfc-9005-16c9c9a4ec9c.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1778974501752-9d1169b24e1c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3ODk0NDQ5MTZ8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"New York Said","https:\u002F\u002Funsplash.com\u002F@newyorksaid","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fmembers-only-sign-on-a-brick-wall-xtE6-nIO_Oc",null,"published","2026-09-15T04:01:56+00:00","Testo avanzato in Flutter: RichText e TextPainter","Guida pratica al testo avanzato in Flutter: TextSpan, WidgetSpan, link cliccabili, SelectionArea, misurazione con TextPainter, TextScaler e StrutStyle.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1792316910664]