Perché la tipografia merita attenzione
La tipografia è uno degli elementi che distingue di più un'app "generica" da un prodotto curato. Flutter rende semplice usare font personalizzati, ma dietro alla sezione fonts: del pubspec.yaml ci sono parecchi dettagli che fanno la differenza: pesi mappati male, variable font trattati come statici, download a runtime che falliscono offline, icone custom che gonfiano il bundle.
In questa guida vediamo come gestire font e icone in modo solido, dal setup di base fino agli aspetti di performance e accessibilità.
Dichiarare un font nel pubspec
Il punto di partenza è inserire i file (.ttf o .otf) negli asset e dichiararli nel pubspec.yaml:
flutter:
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.ttf
weight: 400
- asset: assets/fonts/Inter-Medium.ttf
weight: 500
- asset: assets/fonts/Inter-Bold.ttf
weight: 700
- asset: assets/fonts/Inter-Italic.ttf
weight: 400
style: italic
Tre regole da ricordare:
- il valore di
weightdeve essere un multiplo di 100 tra 100 e 900 e deve corrispondere al peso reale del file; - se dichiari un solo file e poi chiedi
FontWeight.w700, Flutter applica un bold sintetico (lo "finto" grassetto calcolato dal motore di rendering): funziona, ma il risultato è visivamente peggiore del vero taglio bold; style: italicva dichiarato esplicitamente, altrimenti otterrai un corsivo sintetico ottenuto inclinando i glifi.
Applicare il font al tema
Il modo più pulito è impostarlo una volta sola nel ThemeData, così tutti i widget di testo lo ereditano:
MaterialApp(
theme: ThemeData(
colorSchemeSeed: const Color(0xFF3F51B5),
fontFamily: 'Inter',
textTheme: const TextTheme(
displaySmall: TextStyle(fontWeight: FontWeight.w700, height: 1.15),
bodyMedium: TextStyle(fontSize: 15, height: 1.45, letterSpacing: 0.1),
),
),
home: const HomePage(),
);
Se hai un font diverso per i titoli e uno per il corpo, puoi usare TextTheme.apply:
final base = ThemeData(colorSchemeSeed: Colors.indigo);
final theme = base.copyWith(
textTheme: base.textTheme.apply(fontFamily: 'Inter').copyWith(
displayLarge: base.textTheme.displayLarge?.copyWith(fontFamily: 'Fraunces'),
displayMedium: base.textTheme.displayMedium?.copyWith(fontFamily: 'Fraunces'),
headlineLarge: base.textTheme.headlineLarge?.copyWith(fontFamily: 'Fraunces'),
),
);
Variable font: un solo file, infiniti pesi
Un variable font contiene assi (axes) che permettono di interpolare dinamicamente peso, larghezza, inclinazione. L'asse più comune è wght. Il vantaggio è doppio: un unico file al posto di sei o sette, e la possibilità di usare pesi intermedi (es. 450) non disponibili con i tagli statici.
In Flutter si dichiara il file una volta sola e si usa fontVariations nello stile:
flutter:
fonts:
- family: InterVariable
fonts:
- asset: assets/fonts/Inter-VariableFont_wght.ttf
Text(
'Peso intermedio 450',
style: TextStyle(
fontFamily: 'InterVariable',
fontSize: 18,
fontVariations: const [FontVariation('wght', 450)],
),
);
Attenzione a un dettaglio che genera spesso confusione: con i variable font, fontWeight e fontVariations non sono la stessa cosa. Su alcune piattaforme fontWeight da solo può produrre un bold sintetico invece di spostare l'asse. La strategia più sicura è impostarli entrambi in modo coerente:
TextStyle weightedStyle(double weight) => TextStyle(
fontFamily: 'InterVariable',
fontWeight: FontWeight.values.firstWhere(
(w) => w.value == (weight ~/ 100) * 100,
orElse: () => FontWeight.normal,
),
fontVariations: [FontVariation('wght', weight)],
);
Poiché fontVariations è una lista di valori numerici, puoi anche animare il peso del testo con un TweenAnimationBuilder, cosa impossibile con i font statici:
TweenAnimationBuilder<double>(
tween: Tween(begin: 300, end: _selected ? 700 : 300),
duration: const Duration(milliseconds: 250),
builder: (context, weight, _) => Text(
'Voce di menu',
style: TextStyle(
fontFamily: 'InterVariable',
fontVariations: [FontVariation('wght', weight)],
),
),
);
Altri assi frequenti sono wdth (larghezza), slnt (inclinazione) e opsz (optical size): basta aggiungerli alla lista, purché il font li supporti.
google_fonts: comodo, ma configuralo bene
Il pacchetto google_fonts è ottimo in fase di prototipazione perché evita di scaricare manualmente i file:
import 'package:google_fonts/google_fonts.dart';
final theme = ThemeData(
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme),
);
Il problema è il comportamento di default: se il font non è presente tra gli asset, viene scaricato a runtime dal web e messo in cache. Significa primo avvio più lento, dipendenza dalla rete e possibile "flash" di testo con un font diverso. In produzione la scelta migliore è quasi sempre fare il bundling dei file e disattivare il fetch:
void main() {
GoogleFonts.config.allowRuntimeFetching = false;
runApp(const MyApp());
}
Con il fetch disattivato, google_fonts userà solo i file dichiarati nel pubspec.yaml (nominati secondo la convenzione del pacchetto, ad esempio Inter-Regular.ttf). Ricorda anche di includere le licenze OFL nel registro delle licenze dell'app:
LicenseRegistry.addLicense(() async* {
final license = await rootBundle.loadString('assets/fonts/OFL.txt');
yield LicenseEntryWithLineBreaks(['google_fonts'], license);
});
Fallback: emoji, alfabeti non latini e caratteri mancanti
Nessun font copre tutti i glifi. Se la tua app mostra contenuti generati dagli utenti, prima o poi incontrerai emoji, cirillico o ideogrammi che il font principale non contiene: il risultato sono i famigerati "tofu" (rettangoli vuoti).
La soluzione è fontFamilyFallback, una catena di famiglie consultate in ordine:
const appTextStyle = TextStyle(
fontFamily: 'Inter',
fontFamilyFallback: ['NotoSansSC', 'NotoColorEmoji'],
);
Puoi impostarlo anche a livello di tema con ThemeData(fontFamilyFallback: [...]). Su Android e iOS i font di sistema coprono già molto, ma su Flutter Web e su alcuni device Android personalizzati il fallback esplicito evita brutte sorprese. Attenzione al peso: un Noto Sans CJK completo può superare i 10 MB, valuta l'uso di versioni ridotte (subset) con gli strumenti di fonttools.
Icone personalizzate come font
Le icone vettoriali monocromatiche possono essere distribuite come font: un solo file, scalabile, colorabile e perfettamente allineato al testo. Dopo aver generato il .ttf (con strumenti come FlutterIcon o IcoMoon), si dichiara come un font qualsiasi e si creano le costanti:
flutter:
fonts:
- family: AppIcons
fonts:
- asset: assets/fonts/AppIcons.ttf
abstract final class AppIcons {
static const _family = 'AppIcons';
static const wallet = IconData(0xe900, fontFamily: _family);
static const invoice = IconData(0xe901, fontFamily: _family);
static const support = IconData(0xe902, fontFamily: _family);
}
// Uso
Icon(AppIcons.wallet, size: 24, color: Theme.of(context).colorScheme.primary);
Se il font delle icone è distribuito da un package riutilizzabile, aggiungi il parametro fontPackage:
static const wallet = IconData(
0xe900,
fontFamily: 'AppIcons',
fontPackage: 'my_design_system',
);
Tree shaking delle icone
In release Flutter esegue il tree shaking dei font di icone: rimuove i glifi non utilizzati, riducendo drasticamente il peso. Funziona solo se gli IconData sono costanti note a compile time. Costruzioni dinamiche come IconData(codePoint, fontFamily: 'AppIcons') con codePoint calcolato a runtime fanno fallire la build con un errore esplicito, costringendoti a compilare con --no-tree-shake-icons e a portarti dietro l'intero set. Se devi mappare icone ricevute dal backend, usa una mappa di costanti:
const iconByName = <String, IconData>{
'wallet': AppIcons.wallet,
'invoice': AppIcons.invoice,
};
Icon(iconByName[response.icon] ?? Icons.help_outline);
Caricare font a runtime con FontLoader
In scenari di white labeling o server-driven UI può servire caricare un font scaricato dalla rete. L'API è FontLoader:
Future<void> loadRemoteFont(String family, Uint8List bytes) async {
final loader = FontLoader(family)
..addFont(Future.value(ByteData.view(bytes.buffer)));
await loader.load();
}
Dopo il load() il font è disponibile per tutta l'app usando fontFamily: family. Ricorda di scaricare i byte prima di mostrare la schermata (o di gestire un rebuild), di salvarli in cache su disco e di avere sempre un font locale di fallback in caso di errore di rete.
Accessibilità: niente layout a misura di font
Un font personalizzato non deve rompere l'ingrandimento del testo di sistema. Alcune accortezze:
- non disattivare mai lo scaling con
TextScaler.noScalingsull'intera app: al massimo limitalo conclampnei punti critici; - preferisci altezze minime (
ConstraintedBox,minHeight) a dimensioni fisse per bottoni e card contenenti testo; - usa la proprietà
heightdelTextStyleper l'interlinea invece diSizedBoxtra le righe.
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: MediaQuery.textScalerOf(context).clamp(
minScaleFactor: 1.0,
maxScaleFactor: 1.6,
),
),
child: child,
);
Un'altra impostazione utile quando il font ha metriche verticali generose è TextHeightBehavior, che permette di eliminare lo spazio extra sopra la prima riga e sotto l'ultima:
Text(
'Titolo perfettamente allineato',
style: const TextStyle(height: 1.2),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
);
Checklist finale
- Dichiara un file per ogni peso reale, oppure usa un variable font con
fontVariations. - In produzione, bundle dei font e
allowRuntimeFetching = falsese usigoogle_fonts. - Includi le licenze dei font nel
LicenseRegistry. - Configura
fontFamilyFallbackse gestisci contenuti multilingua o emoji. - Mantieni gli
IconDatacostanti per non perdere il tree shaking. - Verifica la UI con il testo ingrandito al 160% prima di rilasciare.
Curare questi dettagli costa poche ore e si traduce in un'app che appare immediatamente più professionale, più leggera e accessibile a tutti.