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
TextButtoncon 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, iltextScalere ilmaxWidthpassati al painter devono essere identici a quelli del widget reale, altrimenti la misura è sbagliata; dispose()è necessario perchéTextPaintertrattiene 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
constdove possibile:const TextSpan(...)evita ricostruzioni e permette al framework di riutilizzare il layout. - Non ricreare i
GestureRecognizerinbuild: causano leak e tap fantasma. Creali ininitStatee liberali indispose. - Non usare
TextPaintera 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
MediaQuerya scala massima o conDirectionality(textDirection: TextDirection.rtl). - Verifica con i golden test: le modifiche a
StrutStyle,heighto 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.