Interactive HomeScreen Widgets with Flutter using home_widget

Anton Borries
8 min readSep 12, 2023

With the introduction of iOS 17 Apple now also supports adding interactivity to HomeScreen Widgets meaning we can build interactive HomeScreen Widgets for both iOS and Android that call Dart code. In this Article we will recreate the classic Flutter Counter App as HomeScreen Widgets for both platforms

HomeScreen Widget Counter Apps on iOS and Android

Prerequisites

This Article assumes that you know the basics of adding HomeScreen Widgets with home_widget. If you want more details on how to set things up initially please check out the README as well as have a look at the Codelab

Dart Setup

Please note that as of writing this article iOS Interactivity is only available as a pre-release version of home_widget 0.4.0-alpha.0 This will change once Apple officially releases iOS 17 and XCode 15. Once that is fully released I will update this Article accordingly

While this is in pre-release add this to your pubspec.yaml file

home_widget: 0.4.0-alpha.0

Building the Counter App

We only are doing slight modifications to the generated counter app. Mainly we will use static functions to save/update/restore the current count, as this will make our work to use in the interactive callback a lot easier. You should obviously not move all of your business logic into static functions in your real apps but for this example it will be fine.

We will use HomeWidget.getWidgetData for getting the current value. HomeWidget.saveData to save the new value and HomeWidget.updateWidget

In the context it looks like this

const _countKey = 'counter';

/// Gets the currently stored Value
Future<int> get _value async {
final value = await HomeWidget.getWidgetData<int>(_countKey, defaultValue: 0);
return value!;
}

/// Retrieves the current stored value
/// Increments it by one
/// Saves that new value
/// @returns the new saved value
Future<int> _increment() async {
final oldValue = await _value;
final newValue = oldValue + 1;
await _sendAndUpdate(newValue);
return newValue;
}

/// Clears the saved Counter Value
Future<void> _clear() async {
await _sendAndUpdate(null);
}

/// Stores [value] in the Widget Configuration
Future<void> _sendAndUpdate([int? value]) async {
await HomeWidget.saveWidgetData(_countKey, value);
await HomeWidget.updateWidget(
iOSName: 'CounterWidget',
androidName: 'CounterWidgetProvider',
);
}

Adding an Interactivity callback

To get interactivity working in the dart part we need to create and register an interactivity callback. This callback has 2 requirements. It needs to be static as well as public and needs to be annotated with @pragma(‘vm:entry-point’) . It will be called with an Uri to detect what element triggered the callback. In our example the callback looks like this

@pragma('vm:entry-point')
Future<void> interactiveCallback(Uri? uri) async {
// We check the host of the uri to determine which action should be triggered.
if (uri?.host == 'increment') {
await _increment();
} else if (uri?.host == 'clear') {
await _clear();
}
}

Lastly we need to register that callback. Please note that you should call this after you have setup the AppGroupId for iOS. In our case we just do it in the main method.

await HomeWidget.registerInteractivityCallback(interactiveCallback);

With this our main File should look something like this:

import 'package:flutter/material.dart';
import 'package:home_widget/home_widget.dart';

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Set AppGroup Id. This is needed for iOS Apps to talk to their WidgetExtensions
await HomeWidget.setAppGroupId('YOUR_GROUP_ID');
// Register an Interactivity Callback. It is necessary that this method is static and public
await HomeWidget.registerInteractivityCallback(interactiveCallback);
runApp(const MyApp());
}

/// Callback invoked by HomeWidget Plugin when performing interactive actions
/// The @pragma('vm:entry-point') Notification is required so that the Plugin can find it
@pragma('vm:entry-point')
Future<void> interactiveCallback(Uri? uri) async {
// We check the host of the uri to determine which action should be triggered.
if (uri?.host == 'increment') {
await _increment();
} else if (uri?.host == 'clear') {
await _clear();
}
}

const _countKey = 'counter';

/// Gets the currently stored Value
Future<int> get _value async {
final value = await HomeWidget.getWidgetData<int>(_countKey, defaultValue: 0);
return value!;
}

/// Retrieves the current stored value
/// Increments it by one
/// Saves that new value
/// @returns the new saved value
Future<int> _increment() async {
final oldValue = await _value;
final newValue = oldValue + 1;
await _sendAndUpdate(newValue);
return newValue;
}

/// Clears the saved Counter Value
Future<void> _clear() async {
await _sendAndUpdate(null);
}

/// Stores [value] in the Widget Configuration
Future<void> _sendAndUpdate([int? value]) async {
await HomeWidget.saveWidgetData(_countKey, value);
await HomeWidget.updateWidget(
iOSName: 'CounterWidget',
androidName: 'CounterWidgetProvider',
);
}

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

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData.light(
useMaterial3: false,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});

final String title;

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}

Future<void> _incrementCounter() async {
await _increment();
setState(() {});
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
setState(() {});
}
}

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
FutureBuilder<int>(
future: _value,
builder: (_, snapshot) => Text(
(snapshot.data ?? 0).toString(),
style: Theme.of(context).textTheme.headlineMedium,
),
),
TextButton(
onPressed: () async {
await _clear();
setState(() {});
},
child: const Text('Clear'),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

iOS Widget

Note: As of writing this you will required XCode 15 for this

Like mentioned above I assume that you already know how to build HomeWidgets. If not please check out the README as well as have a look at the Codelab. Before adding interactivity you should have performed at least these steps:

  1. Add an AppIdentifier and GroupIdentifier on the AppleDeveloper Console
  2. Create a WidgetExtension in Xcode. File > New > Target > Widget Extension In this example I’ve named the extension CounterWidget
  3. Add your GroupId to both your App Target and Widget Extension Target

Adjust your podfile

We need to adjust the Podfile for your iOS app to make sure your WidgetExtension has access to the home_widget plugin. For that we’ll add the following code snippet

target 'CounterWidgetExtension' do
inherit! :search_paths
end

To your Runner’s target section in the Podfile so the whole section will look like this:

target 'Runner' do
use_frameworks!
use_modular_headers!

flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
target 'CounterWidgetExtension' do
inherit! :search_paths
end
end

Creating an AppIntent

In your Runners target you want to create a new swift file where we will define an AppIntent which we will use to trigger Actions from the Widget. When creating the AppIntent it is important that you select both your Runner and your WidgetExtension as targets (you can also set this in the inspector later)

Make sure the Membership is set for the Runner and the Extension

In the AppIntent’s perform function call HomeWidgetBackgroundWorker.run this needs to be called with your AppGroupId and an Uri which will be passed into the interactivity callback in dart

import AppIntents
import Foundation
import home_widget

@available(iOS 17, *)
public struct BackgroundIntent: AppIntent {
static public var title: LocalizedStringResource = "Increment Counter"

@Parameter(title: "Method")
var method: String

public init() {
method = "increment"
}

public init(method: String) {
self.method = method
}

public func perform() async throws -> some IntentResult {
await HomeWidgetBackgroundWorker.run(
url: URL(string: "homeWidgetCounter://\(method)"),
appGroup: "YOUR_GROUP_ID")

return .result()
}
}

Adding the Button to the Widget

As a last step we will need to add a Button to our Widget that will use the BackgroundIntent we just created. So we are now back in our CounterWidget.swift and can simply add interactive buttons to the Widget like this

Button(intent: BackgroundIntent(method: "increment")) {
Image(systemName: "plus").font(.system(size: 16))
}

The full CounterWidget.swift should look something like this. There you can also see how to load the current count into a TimelineEntry

import SwiftUI
import WidgetKit

struct Provider: TimelineProvider {
func placeholder(in context: Context) -> CounterEntry {
CounterEntry(date: Date(), count: 0)
}

func getSnapshot(in context: Context, completion: @escaping (CounterEntry) -> Void) {
// Get the UserDefaults for the AppGroup
let prefs = UserDefaults(suiteName: "YOUR_GROUP_ID")
// Load the current Count
let entry = CounterEntry(date: Date(), count: prefs?.integer(forKey: "counter") ?? 0)
completion(entry)
}

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
getSnapshot(in: context) { (entry) in
let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
}
}
}

struct CounterEntry: TimelineEntry {
let date: Date
let count: Int
}

struct CounterWidgetEntryView: View {
var entry: Provider.Entry

var body: some View {
VStack {
Text("You have pushed the button this many times:").font(.caption2).frame(
maxWidth: .infinity, alignment: .center)
Spacer()
Text(entry.count.description).font(.title).frame(maxWidth: .infinity, alignment: .center)
Spacer()
HStack {
// This button is for clearing
Button(intent: BackgroundIntent(method: "clear")) {
Image(systemName: "xmark").font(.system(size: 16)).foregroundColor(.red).frame(
width: 24, height: 24)
}.buttonStyle(.plain).frame(alignment: .leading)
Spacer()
// This button is for incrementing
Button(intent: BackgroundIntent(method: "increment")) {
Image(systemName: "plus").font(.system(size: 16)).foregroundColor(.white)

}.frame(width: 24, height: 24)
.background(.blue)
.cornerRadius(12).frame(alignment: .trailing)
}
}
}
}

struct CounterWidget: Widget {
let kind: String = "CounterWidget"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
if #available(iOS 17.0, *) {
CounterWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
CounterWidgetEntryView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("Counter Widget")
.description("Count the Number Up")
}
}

#Preview(as: .systemSmall){
CounterWidget()
} timeline: {
CounterEntry(date: .now, count: 0)
}

Now run the App and start counting right from the HomeScreen

Android Widget

Like mentioned above I assume that you already know how to build HomeWidgets. If not please check out the README as well as have a look at the Codelab. Before adding interactivity you should have performed at least these steps:

  1. Create a WidgetProvider (In our case CounterWidgetProvider)
  2. Create your WidgetLayout and xml appwidget-provider
  3. Add your Widget to the AndroidManifest

Adding Interactivity

To add interactivity simply assign a `HomeWidgetBackgroundIntent` to your Remote View.

val incrementIntent = HomeWidgetBackgroundIntent.getBroadcast(
context,
Uri.parse("homeWidgetCounter://increment")
)
setOnClickPendingIntent(R.id.button_increment, incrementIntent)

The full CounterWidgetProvider.kt should look something like this

package es.antonborri.home_widget_counter

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.HomeWidgetBackgroundIntent
import es.antonborri.home_widget.HomeWidgetProvider

class CounterWidgetProvider : HomeWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
widgetData: SharedPreferences) {
appWidgetIds.forEach { widgetId ->
val views = RemoteViews(context.packageName, R.layout.counter_widget).apply {
val count = widgetData.getInt("counter", 0)
setTextViewText(R.id.text_counter, count.toString())

val incrementIntent = HomeWidgetBackgroundIntent.getBroadcast(
context,
Uri.parse("homeWidgetCounter://increment")
)
val clearIntent = HomeWidgetBackgroundIntent.getBroadcast(
context,
Uri.parse("homeWidgetCounter://clear")
)

setOnClickPendingIntent(R.id.button_increment, incrementIntent)
setOnClickPendingIntent(R.id.button_clear, clearIntent)
}
appWidgetManager.updateAppWidget(widgetId, views)
}
}
}

And with that your interactive HomeScreenWidget is done. and should look like this.

Wrapping it up

Adding interactivity is not hard and can make your HomeScreenWidgets much more useful for your users.

The full code for the example app can be found here

Please check out and like home_widget on pub.dev

Do you have an issue with home_widget please create an issue on github

Building something cool with home_widget ? Let me know over on Twitter/X

--

--