Jetpack Glance Home Screen Widgets with Flutter

Anton Borries
7 min readMar 25, 2024

Back in December of 2021 the first Alpha of Jetpack Glance was announced to write Home Screen Widgets on Android using Compose Syntax. Finally home_widget now supports Jetpack Glance making it a lot easier to write the native Android part when creating Home Screen Widgets for your Flutter Apps.

In this article we’ll take a look at the home_widget_counter App created for iOS Interactivity and add another Android Widget now using Glance technology.

Scroll down to the end to see what else is new in home_widget

Quick Summary of the Flutter Part of the App

The App is basically the classic Flutter Counter App. When counting up the App uses home_widget to store the count and updates the Home Screen Widget.

The method that is invoked looks like this:

Future<void> _sendAndUpdate([int? value]) async {
await HomeWidget.saveWidgetData(_countKey, value);

await HomeWidget.updateWidget(
iOSName: 'CounterWidget',
androidName: 'CounterWidgetProvider',
);
}

For this to work you’ll need the latest Versions of home_widget.

# For Flutter Stable
home_widget: ^0.5.0

# For Flutter Beta
home_widget: ^0.6.0

Writing the Jetpack Compose Widget

Prepare Gradle File

Jetpack Glance requires us a bit of adjustments to the Gradle File in order to compile the App:

  1. Add Glance Dependency to the build.gradle File in your app directory
dependencies {
implementation ‘androidx.glance:glance-appwidget:1.0.0’
}

2. Enable Compose Support. Note that depending on your kotlin version you might have to also adjust the compose option.

buildFeatures {
compose true
}

// For the example this is needed with defining the Kotlin Version to 1.9.20
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}

Add this point it might be best to quickly run your app to ensure that the app still compiles and runs.

Add the necessary Files

We’ll need to create the following files

  • A configuration xml file
  • A WidgetReceiver
  • The actual Widget
  • We also need to define the Configuration File and the Receiver in the App’s Manifest file

The configuration file needs to be created in android/app/src/main/res/xml . In there you can customized and configure options like min/max sizes and loading behaviour. For our Counter example it looks like this:

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="203dp"
android:minHeight="90dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="10000" />

The WidgetReceiver is handling the updating behavior of the widget. home_widget offers an abstraction for this so you only need to provide simple references to the Widget (which will be created in the next step). Thanks to these abstractions for our example the file is super short:

import HomeWidgetGlanceWidgetReceiver

class CounterGlanceWidgetReceiver : HomeWidgetGlanceWidgetReceiver<CounterGlanceWidget>() {
override val glanceAppWidget = CounterGlanceWidget()
}

The WidgetReceiver is already referencing the final file our AppWidget. This is whats actually building the Widget. Using Jetpack Compose the syntax with which you can now write the widgets is a lot closer to how you would write Flutter UI Code:

class CounterGlanceWidget : GlanceAppWidget() {

// Needed for Updating
override val stateDefinition = HomeWidgetGlanceStateDefinition()

override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceContent(context, currentState())
}
}

@Composable
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
val data = currentState.preferences
val count = data.getInt("counter", 0)

Box(modifier = GlanceModifier.background(Color.White).padding(16.dp).clickable(onClick = actionStartActivity<MainActivity>(context))) {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.Vertical.CenterVertically,
horizontalAlignment = Alignment.Horizontal.CenterHorizontally,
) {
Text(
"You have pushed the button this many times:",
style = TextStyle(fontSize = 14.sp, textAlign = TextAlign.Center),
)
Text(
count.toString(),
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
)

}
}
}
}

Please take special note of the override val stateDefinition = HomeWidgetGlanceStateDefinition() as this is vital for home_widget to update the widget’s UI

As a last step we need to tell our App about our new Widget in the Android Manifest file. Here we tie together the Configuration File and the Widget Receiver. Simply add this block inside the application section of the manifest file:

<receiver
android:name=".CounterGlanceWidgetReceiver"
android:label="Glance"
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/counter_glance_widget" />
</receiver>

That’s it. Now you should be able to add the Widget to your Home Screen and see the current Count!

Displaying the current count

Updating the Widget

Going from the example we need to adjust (or add) the HomeWidget.updateWidget method to now target our new CounterGlanceWidgetReceiver

Future<void> _sendAndUpdate([int? value]) async {
await HomeWidget.saveWidgetData(_countKey, value);

await HomeWidget.updateWidget(
iOSName: 'CounterWidget',
androidName: 'CounterGlanceWidgetReceiver',
);
}

Adding Interactivity

Next up we want to add interactivity to the widget such that we can count up directly from the HomeScreen.

Preparing the Flutter App

On the Flutter side of things we need to register an interactivity callback that will then increment the count and update the widget again. Important thing to note are that the interactive callback is accessible statically and marked with @pragma(‘vm:entry-point’)

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

@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();
}
}

/// Saves that new 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: 'CounterGlanceWidgetReceiver',
);
}

Adjusting the Glance UI

Glance supports interactivity using ActionCallbacks let’s create action callbacks for the increment and the clear action.

class IncrementAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast(
context,
Uri.parse("homeWidgetCounter://increment"))
backgroundIntent.send()
}
}

class ClearAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast(
context,
Uri.parse("homeWidgetCounter://clear"))
backgroundIntent.send()
}
}

To use these Actions in the UI we need to add a GlanceModifier to the Elements that we want to invoke these Actions with.

Box(
modifier = GlanceModifier.clickable(onClick = actionRunCallback<IncrementAction>())
) {
Image(
provider = ImageProvider(R.drawable.baseline_add_24), contentDescription = null,
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier = GlanceModifier.size(32.dp).background(
imageProvider = ImageProvider(R.drawable.fab_shape)
)
)
}

So now our GlanceContent looks like this to include our Buttons

  @Composable
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
val data = currentState.preferences
val count = data.getInt("counter", 0)

Box(modifier = GlanceModifier.background(Color.White).padding(16.dp).clickable(onClick = actionStartActivity<MainActivity>(context))) {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.Vertical.CenterVertically,
horizontalAlignment = Alignment.Horizontal.CenterHorizontally,
) {
Text(
"You have pushed the button this many times:",
style = TextStyle(fontSize = 14.sp, textAlign = TextAlign.Center),
)
Spacer(GlanceModifier.defaultWeight())
Text(
count.toString(),
style = TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
)
Spacer(GlanceModifier.defaultWeight())
Row(
modifier = GlanceModifier.fillMaxWidth()
) {
Box(
modifier = GlanceModifier.clickable(onClick = actionRunCallback<ClearAction>())
) {
Image(
provider = ImageProvider(R.drawable.baseline_close_24), contentDescription = null,
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier = GlanceModifier.size(32.dp).background(
imageProvider = ImageProvider(R.drawable.fab_shape)
)
)
}
Spacer(GlanceModifier.defaultWeight())
Box(
modifier = GlanceModifier.clickable(onClick = actionRunCallback<IncrementAction>())
) {
Image(
provider = ImageProvider(R.drawable.baseline_add_24), contentDescription = null,
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier = GlanceModifier.size(32.dp).background(
imageProvider = ImageProvider(R.drawable.fab_shape)
)
)
}
}
}
}
}

In order for the App to know that it can handle the home_widget background workers we need to add the following section to the App’s Manifest.

<receiver
android:name="es.antonborri.home_widget.HomeWidgetBackgroundReceiver"
android:exported="true">
<intent-filter>
<action android:name="es.antonborri.home_widget.action.BACKGROUND" />
</intent-filter>
</receiver>

<service
android:name="es.antonborri.home_widget.HomeWidgetBackgroundService"
android:exported="true"
android:permission="android.permission.BIND_JOB_SERVICE" />

That’s it. Now we can can increment and clear the counter directly from the HomeScreen!

More new Features in home_widget 0.5.0

Pinning Home Screen Widgets

As an additional new feature home_widget now supports pinning a Widget to the Home Screen directly from the App. This feature is only available on Android (if the User’s launcher supports it)

To request to pin the widget simply invoke HomeWidget.requestPinWidget()

Future<void> _requestToPinWidget() async {
final isRequestPinSupported = await HomeWidget.isRequestPinWidgetSupported();
if (isRequestPinSupported == true) {
await HomeWidget.requestPinWidget(androidName: 'CounterGlanceWidgetReceiver');
}
}

Widgets Analytics

You can now get information about the widgets a user currently has installed/pinned on their Home or Lock Screens. This is extremely useful for analytics and/or if you want to reward your users if they install your Widget.

To query the currently installed widgets of your app simply call

Future<void> _checkInstalledWidgets() async {
final installedWidgets = await HomeWidget.getInstalledWidgets(); debugPrint(installedWidgets.toString());
}
// Output Android:
I/flutter (28322): [HomeWidgetInfo{iOSFamily: null, iOSKind: null, androidWidgetId: 22, androidClassName: .CounterGlanceWidgetReceiver, androidLabel: Glance}]
// Output iOS (HomeScreen and LockScreen):
flutter: [HomeWidgetInfo{iOSFamily: systemSmall, iOSKind: CounterWidget, androidWidgetId: null, androidClassName: null, androidLabel: null}, HomeWidgetInfo{iOSFamily: accessoryCircular, iOSKind: CounterWidget, androidWidgetId: null, androidClassName: null, androidLabel: null}]

Closing

Thanks so much to all of the contributors in this new release.

home_widget on pub.dev and on github.com

If you or your company is benefitting from home_widget consider sponsoring me on GitHub

You can find the code for this example on Github

If you have any questions about home_widget feel free to reach out to me on X/Twitter

https://twitter.com/ABausG

--

--