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:
- Flutter scrive dei valori in una memoria condivisa (
SharedPreferencessu Android,UserDefaultscon App Group su iOS). - Flutter chiede al sistema di ridisegnare il widget.
- 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" />
updatePeriodMillisha 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 Runner → Signing & Capabilities → + Capability → App 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.
qualifiedAndroidNamesbagliato: 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.