Perché un widget per la home screen

Un widget sulla home screen è uno dei pochi spazi in cui la tua app può "esistere" anche quando non viene aperta: meteo, saldo del conto, prossimo allenamento, ultima notizia, countdown. Su iOS (WidgetKit) e su Android (App Widgets) l'interfaccia del widget non è disegnata da Flutter: viene renderizzata dal sistema operativo con SwiftUI e RemoteViews. Flutter, quindi, non può "disegnare" direttamente dentro il widget.

Quello che possiamo fare — ed è esattamente il compito del pacchetto home_widget — è condividere dati tra l'app Flutter e il widget nativo e chiedere al sistema di aggiornarlo. Il flusso è sempre lo stesso:

  1. Flutter scrive dei valori in una memoria condivisa (SharedPreferences su Android, UserDefaults con App Group su iOS).
  2. Flutter chiede al sistema di ridisegnare il widget.
  3. Il codice nativo del widget legge quei valori e aggiorna la UI.

In questa guida costruiamo un widget "ultima notizia" completo: dati condivisi, deep link al tap, refresh in background e rendering di un widget Flutter come immagine.

Installazione

dependencies:
  home_widget: ^0.7.0

Sul lato Dart l'API è minimale, ma la maggior parte del lavoro è nella configurazione nativa: senza quella, nulla funziona. Vediamola piattaforma per piattaforma.

Configurazione Android

1. Il layout del widget

Crea android/app/src/main/res/layout/news_widget.xml con un layout basato su RemoteViews (attenzione: sono supportate solo alcune view, niente ConstraintLayout complessi né widget custom).

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/widget_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    android:background="#FFFFFF">

    <TextView
        android:id="@+id/widget_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="16sp"
        android:textStyle="bold"
        android:maxLines="3"
        android:text="Nessuna notizia" />

    <TextView
        android:id="@+id/widget_updated"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="12sp"
        android:alpha="0.6" />
</LinearLayout>

2. I metadati del widget

In android/app/src/main/res/xml/news_widget_info.xml:

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/news_widget"
    android:minWidth="180dp"
    android:minHeight="110dp"
    android:resizeMode="horizontal|vertical"
    android:updatePeriodMillis="1800000"
    android:widgetCategory="home_screen" />

updatePeriodMillis ha un minimo effettivo di 30 minuti imposto dal sistema: non usarlo per dati che cambiano spesso, usa un aggiornamento esplicito dall'app.

3. Il provider Kotlin

Crea NewsWidgetProvider.kt nello stesso package di MainActivity. Estendendo HomeWidgetProvider ricevi direttamente le SharedPreferences scritte da Flutter.

package it.example.news

import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.widget.RemoteViews
import es.antonborri.home_widget.HomeWidgetLaunchIntent
import es.antonborri.home_widget.HomeWidgetProvider

class NewsWidgetProvider : HomeWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray,
        widgetData: SharedPreferences
    ) {
        appWidgetIds.forEach { widgetId ->
            val views = RemoteViews(context.packageName, R.layout.news_widget).apply {
                setTextViewText(
                    R.id.widget_title,
                    widgetData.getString("headline", null) ?: "Nessuna notizia"
                )
                setTextViewText(
                    R.id.widget_updated,
                    widgetData.getString("updated_at", "") ?: ""
                )

                // Apre l'app su un deep link quando si tocca il widget
                val intent = HomeWidgetLaunchIntent.getActivity(
                    context,
                    MainActivity::class.java,
                    Uri.parse("newsapp://article?id=" + widgetData.getString("article_id", ""))
                )
                setOnClickPendingIntent(R.id.widget_container, intent)
            }
            appWidgetManager.updateAppWidget(widgetId, views)
        }
    }
}

4. Registrazione nel manifest

Dentro il tag <application> di AndroidManifest.xml:

<receiver
    android:name=".NewsWidgetProvider"
    android:exported="true">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/news_widget_info" />
</receiver>

Configurazione iOS

Su iOS servono due passaggi che spesso vengono dimenticati e che sono la causa n.1 di widget "vuoti".

1. App Group. In Xcode, seleziona il target RunnerSigning & Capabilities+ CapabilityApp Groups e crea un gruppo, ad esempio group.it.example.news. Ripeti la stessa operazione per il target del widget: entrambi devono appartenere allo stesso gruppo, altrimenti i dati non sono visibili.

2. Widget Extension. Sempre in Xcode: File → New → Target → Widget Extension. Disattiva "Include Live Activity" se non ti serve. Nel codice Swift leggi i valori tramite UserDefaults(suiteName:):

import WidgetKit
import SwiftUI

struct NewsEntry: TimelineEntry {
    let date: Date
    let headline: String
    let updatedAt: String
}

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> NewsEntry {
        NewsEntry(date: Date(), headline: "Caricamento…", updatedAt: "")
    }

    func getSnapshot(in context: Context, completion: @escaping (NewsEntry) -> Void) {
        completion(readEntry())
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<NewsEntry>) -> Void) {
        completion(Timeline(entries: [readEntry()], policy: .atEnd))
    }

    private func readEntry() -> NewsEntry {
        let defaults = UserDefaults(suiteName: "group.it.example.news")
        return NewsEntry(
            date: Date(),
            headline: defaults?.string(forKey: "headline") ?? "Nessuna notizia",
            updatedAt: defaults?.string(forKey: "updated_at") ?? ""
        )
    }
}

struct NewsWidgetEntryView: View {
    var entry: NewsEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(entry.headline).font(.headline).lineLimit(3)
            Text(entry.updatedAt).font(.caption).foregroundColor(.secondary)
        }
        .widgetURL(URL(string: "newsapp://article"))
    }
}

@main
struct NewsWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "NewsWidget", provider: Provider()) { entry in
            NewsWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Ultima notizia")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

Il valore kind (NewsWidget) è il nome che passerai a updateWidget da Dart.

Il codice Dart

Ora la parte facile. Prima di tutto, all'avvio dell'app, imposta l'App Group (viene ignorato su Android):

import 'package:home_widget/home_widget.dart';

const appGroupId = 'group.it.example.news';
const androidWidget = 'NewsWidgetProvider';
const iOSWidget = 'NewsWidget';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await HomeWidget.setAppGroupId(appGroupId);
  runApp(const MyApp());
}

Scrivere i dati e forzare l'aggiornamento:

class HomeWidgetService {
  static Future<void> updateNews(Article article) async {
    await Future.wait([
      HomeWidget.saveWidgetData<String>('headline', article.title),
      HomeWidget.saveWidgetData<String>('article_id', article.id),
      HomeWidget.saveWidgetData<String>(
        'updated_at',
        'Aggiornato alle ${DateFormat.Hm().format(DateTime.now())}',
      ),
    ]);

    await HomeWidget.updateWidget(
      name: androidWidget,
      androidName: androidWidget,
      iOSName: iOSWidget,
      qualifiedAndroidName: 'it.example.news.$androidWidget',
    );
  }
}

I tipi supportati da saveWidgetData sono quelli primitivi (String, int, double, bool). Per strutture complesse serializza in JSON e fai il parsing lato nativo, oppure — meglio — appiattisci i dati in chiavi separate: il codice nativo di un widget deve restare il più stupido possibile.

Reagire al tap: dal widget all'app

Ci sono due casi da gestire: l'app è già in memoria oppure viene lanciata dal widget.

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    // App lanciata da zero toccando il widget
    HomeWidget.initiallyLaunchedFromHomeWidget().then(_handleUri);
    // App già in background
    HomeWidget.widgetClicked.listen(_handleUri);
  }

  void _handleUri(Uri? uri) {
    if (uri == null) return;
    final id = uri.queryParameters['id'];
    if (id != null) {
      router.go('/article/$id');
    }
  }
}

Ricorda di dichiarare lo schema newsapp:// nel manifest Android (intent-filter con BROWSABLE) e negli URL Types di Xcode, esattamente come per un normale deep link.

Aggiornare il widget in background

L'app non è quasi mai in foreground quando il widget andrebbe aggiornato. Due strategie complementari:

1. Refresh periodico con WorkManager / BGTaskScheduler. Pianifica un task che scarica i nuovi dati e chiama HomeWidgetService.updateNews. Su Android il minimo è 15 minuti; su iOS il sistema decide quando eseguire, quindi considera l'aggiornamento "best effort".

2. Push silenziose. Una notifica data-only via FCM che, nell'handler in background, aggiorna i dati condivisi. È l'approccio più reattivo per contenuti che cambiano di rado ma devono essere freschi.

In entrambi i casi il codice che gira in un isolate separato deve essere annotato con @pragma('vm:entry-point'):

@pragma('vm:entry-point')
Future<void> backgroundRefresh() async {
  await HomeWidget.setAppGroupId(appGroupId);
  final article = await NewsApi().fetchLatest();
  await HomeWidgetService.updateNews(article);
}

Widget interattivi (Android 12+ / iOS 17+)

Dalle versioni recenti dei sistemi operativi è possibile inserire pulsanti nel widget che eseguono codice senza aprire l'app. home_widget espone questa funzionalità con registerInteractivityCallback:

@pragma('vm:entry-point')
Future<void> interactiveCallback(Uri? uri) async {
  if (uri?.host == 'refresh') {
    await HomeWidget.setAppGroupId(appGroupId);
    final article = await NewsApi().fetchLatest();
    await HomeWidgetService.updateNews(article);
  }
}

// in main()
await HomeWidget.registerInteractivityCallback(interactiveCallback);

Lato nativo il pulsante deve puntare a un PendingIntent generato da HomeWidgetBackgroundIntent.getBroadcast(...) su Android, o usare AppIntent con l'helper fornito dal plugin su iOS. Tieni le operazioni brevi: il sistema concede pochi secondi.

Renderizzare un widget Flutter come immagine

Se la UI nativa è troppo limitante (pensa a un grafico), puoi disegnare un widget Flutter e salvarlo come PNG nella cartella condivisa, mostrandolo poi come immagine nel widget nativo:

await HomeWidget.renderFlutterWidget(
  SizedBox(
    width: 300,
    height: 150,
    child: MiniChart(data: values),
  ),
  key: 'chart_image',
  logicalSize: const Size(300, 150),
  pixelRatio: 3,
);
await HomeWidget.updateWidget(/* ... */);

Il percorso del file finisce nella chiave chart_image: su Android lo leggi con widgetData.getString("chart_image", null) e lo imposti con setImageViewBitmap, su iOS lo carichi con UIImage(contentsOfFile:). Attenzione al peso: RemoteViews ha un limite pratico sulla dimensione dei bitmap trasferiti (transaction too large), quindi evita pixelRatio esagerati.

Errori comuni da evitare

  • App Group mancante su un target iOS: il widget mostra sempre i valori di default. È il 90% dei problemi.
  • qualifiedAndroidName sbagliato: se il package del provider non coincide con quello dichiarato, l'aggiornamento silenziosamente non fa nulla. Passa sempre il nome completamente qualificato.
  • Aspettarsi aggiornamenti al secondo: entrambi i sistemi limitano severamente la frequenza. Progetta il widget per mostrare dati che restano validi per decine di minuti.
  • Dimenticare @pragma('vm:entry-point'): funziona in debug e si rompe in release, dove il tree shaking rimuove le funzioni non referenziate.
  • Testare solo su Android: il ciclo di vita di WidgetKit è molto diverso; verifica sempre su dispositivo iOS reale.

Conclusione

home_widget non trasforma Flutter in un motore di rendering per la home screen: fa qualcosa di più pragmatico, cioè crea un ponte affidabile tra la logica Dart e due tecnologie native molto diverse. Il pattern da tenere a mente è semplice — scrivi i dati, aggiorna il widget, apri l'app con un deep link — e da lì la complessità resta tutta nel codice nativo, che puoi mantenere volutamente minimale. Il ritorno in termini di visibilità e retention dell'app, però, è tra i più alti per lo sforzo richiesto.