Perché icona e splash non sono un dettaglio

Quando pubblichiamo un'app Flutter, l'utente vede due cose prima ancora del nostro primo widget: l'icona nel launcher e la schermata di avvio nativa. Sono elementi che vivono fuori dal framework Flutter: risiedono nelle cartelle android/ e ios/, sono gestiti dal sistema operativo e richiedono decine di asset in risoluzioni diverse.

Generarli a mano significa produrre manualmente mipmap-hdpi, mipmap-xxxhdpi, Assets.xcassets/AppIcon.appiconset con una ventina di PNG, più i file per web, Windows e macOS. Due pacchetti risolvono il problema in pochi secondi: flutter_launcher_icons e flutter_native_splash.

In questa guida vediamo la configurazione completa, incluse le parti che spesso vengono dimenticate: le adaptive icons, l'icona monocromatica per i temi dinamici di Android 13+, le API di splash screen introdotte da Android 12 e la gestione dei flavor.

Preparare gli asset sorgente

Prima di configurare qualsiasi cosa, servono immagini sorgente di buona qualità:

  • Icona: un PNG quadrato di almeno 1024x1024 px, senza angoli arrotondati (li applica il sistema) e senza canale alpha per iOS.
  • Foreground adattivo Android: un PNG 1024x1024 in cui il contenuto visibile occupa al massimo il 66% centrale. Android ritaglia l'icona in forme diverse (cerchio, squircle, goccia) e tutto ciò che esce dalla safe zone viene tagliato.
  • Splash: un logo PNG con sfondo trasparente, tipicamente 1152x1152 px per la versione Android 12+.

Una struttura tipica del progetto:

assets/
  branding/
    icon.png                 # 1024x1024, opaco
    icon_foreground.png      # 1024x1024, trasparente, safe zone 66%
    icon_monochrome.png      # 1024x1024, silhouette bianca su trasparente
    splash_logo.png
    splash_logo_dark.png

flutter_launcher_icons: generare le icone

Aggiungiamo il pacchetto tra le dipendenze di sviluppo:

dev_dependencies:
  flutter_launcher_icons: ^0.14.1

La configurazione può stare direttamente in pubspec.yaml oppure, come consiglio, in un file separato flutter_launcher_icons.yaml per non appesantire il pubspec:

# flutter_launcher_icons.yaml
flutter_launcher_icons:
  image_path: "assets/branding/icon.png"

  android: "ic_launcher"
  adaptive_icon_background: "#0B1F3A"
  adaptive_icon_foreground: "assets/branding/icon_foreground.png"
  adaptive_icon_monochrome: "assets/branding/icon_monochrome.png"
  min_sdk_android: 23

  ios: true
  remove_alpha_ios: true
  background_color_ios: "#0B1F3A"

  web:
    generate: true
    image_path: "assets/branding/icon.png"
    background_color: "#0B1F3A"
    theme_color: "#0B1F3A"

  windows:
    generate: true
    icon_size: 256

  macos:
    generate: true

Generazione:

dart run flutter_launcher_icons -f flutter_launcher_icons.yaml

Cosa succede sotto il cofano

  • Su Android vengono creati i mipmap-* e, se sono presenti adaptive_icon_*, i file XML in res/mipmap-anydpi-v26/ic_launcher.xml che descrivono i layer background/foreground/monochrome.
  • Su iOS viene riempito Runner/Assets.xcassets/AppIcon.appiconset con tutte le dimensioni richieste. remove_alpha_ios: true è obbligatorio: App Store Connect rifiuta le icone con canale alpha.
  • Su web viene aggiornato anche web/manifest.json con le icone PWA.

L'icona monocromatica (Android 13+)

Con i themed icons, Android colora l'icona in base al wallpaper dell'utente usando il layer monochrome. Se non lo forniamo, il sistema mostra l'icona standard: nessun crash, ma l'app stona in una home screen tematizzata.

La regola per il file monocromatico: una silhouette piena, bianca su sfondo trasparente, senza gradienti né testo sottile. Anche qui vale la safe zone del 66%.

flutter_native_splash: la schermata di avvio

dev_dependencies:
  flutter_native_splash: ^2.4.3

Da Android 12 la splash screen non è più un semplice windowBackground: il sistema usa la SplashScreen API, che mostra l'icona dell'app al centro di uno sfondo a tinta unita, con vincoli precisi sulle dimensioni. Per questo il pacchetto prevede una sezione dedicata android_12.

# flutter_native_splash.yaml
flutter_native_splash:
  color: "#FFFFFF"
  image: assets/branding/splash_logo.png

  color_dark: "#0B1F3A"
  image_dark: assets/branding/splash_logo_dark.png

  android_12:
    image: assets/branding/splash_logo.png
    icon_background_color: "#FFFFFF"
    image_dark: assets/branding/splash_logo_dark.png
    icon_background_color_dark: "#0B1F3A"

  android: true
  ios: true
  web: false

  android_gravity: center
  ios_content_mode: center
  fullscreen: false

Generazione e, se serve, ripristino:

dart run flutter_native_splash:create --path=flutter_native_splash.yaml

# per rimuovere tutto ciò che è stato generato
dart run flutter_native_splash:remove

I vincoli di Android 12+

Questo è il punto in cui la maggior parte dei team sbaglia:

  • L'immagine viene mostrata dentro un cerchio di 160 dp di diametro; con icon_background_color l'area utile del logo scende a circa 2/3 di quel cerchio.
  • Non esiste più il concetto di splash a schermo intero con immagine di sfondo: se passiamo un banner rettangolare, verrà ritagliato e apparirà sgranato.
  • Il branding testuale va messo nell'app, non nella splash nativa.

La regola pratica: usare un logo semplice, centrato e vettorialmente pulito, e replicare eventuali elementi di brand nella prima schermata Flutter.

Tenere la splash visibile durante l'inizializzazione

Il problema classico: la splash nativa sparisce appena il motore Flutter è pronto, ma l'app deve ancora caricare token, preferenze o dati remoti. Il risultato è un flash bianco seguito da uno spinner.

Il pacchetto espone due API per evitarlo:

import 'package:flutter/widgets.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';

Future<void> main() async {
  final binding = WidgetsFlutterBinding.ensureInitialized();

  // Blocca la rimozione automatica della splash nativa.
  FlutterNativeSplash.preserve(widgetsBinding: binding);

  await _bootstrap();

  runApp(const MyApp());
}

Future<void> _bootstrap() async {
  // Esempio: preferenze, sessione utente, remote config...
  await Future.wait([
    SessionRepository.instance.restore(),
    RemoteConfigService.instance.init(),
  ]);
}

La rimozione va fatta dopo il primo frame utile, non dentro main(), altrimenti si torna al flash iniziale:

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      FlutterNativeSplash.remove();
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Demo',
      home: const HomePage(),
    );
  }
}

Attenzione: se preserve() viene chiamato e per un errore (eccezione durante il bootstrap) remove() non viene mai invocato, l'app resta bloccata sulla splash. Conviene proteggere l'inizializzazione con un try/finally o, meglio, rimuovere la splash in ogni caso e mostrare l'errore nell'interfaccia Flutter.

Future<void> main() async {
  final binding = WidgetsFlutterBinding.ensureInitialized();
  FlutterNativeSplash.preserve(widgetsBinding: binding);

  BootstrapResult result;
  try {
    await _bootstrap();
    result = BootstrapResult.ok();
  } catch (e, s) {
    result = BootstrapResult.error(e, s);
  }

  runApp(MyApp(bootstrap: result));
}

Una transizione fluida tra splash nativa e UI

Per evitare uno stacco brusco, un trucco efficace è replicare in Flutter la stessa composizione della splash nativa (stesso colore di sfondo, stesso logo, stessa posizione) e poi animare la transizione verso la home:

class SplashGate extends StatelessWidget {
  const SplashGate({super.key, required this.ready, required this.child});

  final bool ready;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 350),
      child: ready
          ? child
          : const ColoredBox(
              key: ValueKey('splash'),
              color: Color(0xFFFFFFFF),
              child: Center(
                child: SizedBox(
                  width: 120,
                  height: 120,
                  child: Image(
                    image: AssetImage('assets/branding/splash_logo.png'),
                  ),
                ),
              ),
            ),
    );
  }
}

L'utente percepisce una sola schermata continua invece di due passaggi distinti.

Gestire i flavor

Se l'app ha più ambienti (dev, staging, produzione) con icone diverse, entrambi i pacchetti supportano file di configurazione multipli.

Per le icone, il naming è convenzionale: flutter_launcher_icons-<flavor>.yaml.

flutter_launcher_icons-dev.yaml
flutter_launcher_icons-staging.yaml
flutter_launcher_icons-prod.yaml
dart run flutter_launcher_icons   # genera tutti i flavor trovati

Per la splash, si passa esplicitamente il flavor:

dart run flutter_native_splash:create --flavor dev
dart run flutter_native_splash:create --flavor prod

Un banner "DEV" sull'icona di sviluppo evita l'errore classico di provare una build sbagliata credendola quella di produzione.

Automatizzare con uno script

Per non dover ricordare i comandi, conviene definirli una volta sola. Con melos o con un semplice script shell:

#!/usr/bin/env bash
set -e

echo "→ Generazione icone..."
dart run flutter_launcher_icons

echo "→ Generazione splash screen..."
dart run flutter_native_splash:create

echo "✓ Asset nativi aggiornati"

In alternativa, con melos:

# melos.yaml
scripts:
  branding:
    run: |
      dart run flutter_launcher_icons && \
      dart run flutter_native_splash:create
    description: Rigenera icone e splash screen nativi

Va committato il codice generato?

Sì. I file generati finiscono in android/app/src/main/res e ios/Runner/Assets.xcassets, che sono cartelle sotto controllo di versione. Committarli garantisce che la CI produca build corrette senza dover eseguire i generatori a ogni pipeline. Il generatore va rilanciato solo quando cambiano gli asset di brand.

Problemi frequenti e soluzioni

Sintomo Causa probabile Soluzione
Icona vecchia dopo la rigenerazione Cache del launcher Android Disinstallare l'app e reinstallare, oppure flutter clean
App Store rifiuta l'icona Canale alpha presente remove_alpha_ios: true e ricontrollo del PNG sorgente
Logo tagliato su Android 12+ Immagine fuori dalla safe zone Ridurre il contenuto a circa 2/3 del canvas
Splash bianca poi flash scuro Mancata configurazione dark mode Impostare color_dark e image_dark
App bloccata sulla splash preserve() senza remove() Rimozione in addPostFrameCallback e gestione delle eccezioni
Icona adattiva con bordi strani Foreground opaco a tutto schermo Usare un PNG con sfondo trasparente

Checklist finale prima della pubblicazione

  • [ ] Icona testata su launcher circolare e squircle (Pixel e Samsung si comportano diversamente).
  • [ ] Icona monocromatica verificata con i temi dinamici attivi su Android 13+.
  • [ ] Splash controllata in light e dark mode.
  • [ ] Nessun flash bianco tra splash nativa e primo frame Flutter.
  • [ ] Icone generate anche per web/desktop se le piattaforme sono supportate.
  • [ ] Flavor con icone distinguibili tra loro.
  • [ ] Asset generati committati nel repository.

Conclusioni

Icone e splash screen sono fra le attività più rapide da automatizzare e fra le più trascurate. Con una configurazione di venti righe e due comandi otteniamo asset coerenti su tutte le piattaforme, conformi alle linee guida di Android 12+ e alle regole di validazione dell'App Store.

Il punto chiave da ricordare è che la splash nativa non è il posto giusto per il branding elaborato: serve a coprire i millisecondi di avvio del motore Flutter. Tutto il resto — animazioni, claim, transizioni — va costruito dentro l'app, dove abbiamo il pieno controllo del rendering.