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 weight deve 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: italic va 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.noScaling sull'intera app: al massimo limitalo con clamp nei punti critici;
  • preferisci altezze minime (ConstraintedBox, minHeight) a dimensioni fisse per bottoni e card contenenti testo;
  • usa la proprietà height del TextStyle per l'interlinea invece di SizedBox tra 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 = false se usi google_fonts.
  • Includi le licenze dei font nel LicenseRegistry.
  • Configura fontFamilyFallback se gestisci contenuti multilingua o emoji.
  • Mantieni gli IconData costanti 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.