Love at first Glance
As of December 2021 Google announced the alpha version of Jetpack Glance library for making Widgets with Jetpack Compose. This makes it much faster and easier to make App Widgets on Android.
In order to use Glance for making widgets, your module needs to add the dependencies for Compose and for Glance. The current version as of writing this is alpha-03.
dependencies {
// For AppWidgets support
implementation("androidx.glance:glance-appwidget:1.0.0-alpha03")
}
android {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.1.0-beta03"
}
kotlinOptions {
jvmTarget = "1.8"
}
}
What are we making?
We have a design for a simple widget that was not yet implemented in our app.
Here the widget is supposed to be resizable within its bounds. Clicking on the QR code should open the QR screen in the app. Furthermore, tapping the SATS S should just open the app, and tapping the workout, should open page with the specified workout.
But how?
After adding the dependency in a module like above, we started by adding our
initial setup. This is very similar to a default Widget implementation in the
‘view system’. We need that because Glance only provides its own set of
Composables that is converted automatically to Remote Views
behind the scene.
We started by adding a widgets.xml file. This file is going to contain
information about you widget like min required bounds, how it can be resized
and etc. You can add one in the v31 folder as well if you want something
special for API 31 and above. In API 31 and above, the targetCellWidth
and
targetCellHeight
is available.
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
android:minHeight="80dp"
android:minWidth="80dp"
android:description="@string/qr_code_widget_description"
android:initialLayout="@layout/widget_loading"
android:previewLayout="@layout/widget_preview"
android:previewImage="@drawable/ic_sats"
android:targetCellHeight="2"
android:targetCellWidth="3"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"/>
This file does a lot of nice things. Some of what it does is to define our loading widget and our preview. The loading widget is shown for when the widget needs to completely reload or when it’s initialized. The preview is used for showing the user before they add the widget, how it’s going to look. These files are completely normal layout files and can be shaped however you like.
When you have defined these files, you should remember to add them as
metadata
in your manifest file.
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_qr" />
Next we defined our widget receiver. This is a class that is going to get
data from our data layer and pass it on to our widget. This class has to extend
GlanceAppWidgetReceiver()
. In our application we are using Hilt as a
dependency injection (DI) framework. This makes it very easy to just inject
our data layer reference and use it in our receiver. But remember to
annotate it with @AndroidEntryPoint
if you’re using Hilt as well.
@AndroidEntryPoint
class SatsWidgetReceiver : GlanceAppWidgetReceiver() {
@Inject
lateinit var repository: Repository
override val glanceAppWidget: GlanceAppWidget = SatsWidget()
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
MainScope().launch {
observeData(context)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == SatsWidgetUpdateAction.UpdateAction) {
MainScope().launch {
val glanceId = GlanceAppWidgetManager(context).getGlanceIds(SatsWidget::class.java).firstOrNull()
glanceId?.let {
updateAppWidgetState(context, it) { state ->
state[UpcomingIsUpdating] = true
}
glanceAppWidget.update(context, glanceId)
}
observeData(context)
}
}
}
private suspend fun observeData(context: Context) {
val glanceId = GlanceAppWidgetManager(context).getGlanceIds(SatsWidget::class.java).firstOrNull()
glanceId?.let { id ->
if (isLoggedIn()) {
updateAppWidgetState(context, id) { state ->
val event = repository.getMyUpcomingTraining()
state[UpcomingNameKey] = event.name
}
} else {
// ...
}
}
}
// ...
companion object {
val UpcomingNameKey = stringPreferencesKey("upcoming_gx_name_key")
val UpcomingIsUpdating = booleanPreferencesKey("upcoming_is_updating")
// ...
}
}
This class does a lot, but in essence it only updates view state from data
provided by the data layer. It also handles updates from the widget in an
update intent that is called when someone clicks the update button on the
widget. The update method is invoked if there is a change in the system that
the widget to reload. This could be for instance changing the system locale.
They both call on observeData
to reload the data from the data layer API.
Also, it’s important to add the receiver in your manifest.
<receiver android:name=".SatsWidgetReceiver"
android:enabled="true"
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/widget_qr" />
</receiver>
The way that we update the state in the Widget is that we override the
stateDefinition
in our GlanceWidget
. So we can make a class that extends
GlanceWidget
. Then we override stateDefinition
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
to provide the state. Since DataStore
was used in the receiver to store the
data, we can use DataStore
again to retrieve it.
We can do this in the Content
method from the GlanceWidget
class.
@Composable
override fun Content() {
val market = currentState(SatsWidgetReceiver.MarketKey)
val upcomingEventName = currentState(SatsWidgetReceiver.UpcomingNameKey)
val upcomingIsUpdating = currentState(SatsWidgetReceiver.UpcomingIsUpdating) ?: false
// ...
SatsAppWidget(
icon = icon,
upcomingEventName = upcomingEventName,
upcomingIsUpdating = upcomingIsUpdating,
// ...
)
}
@Composable
private fun SatsWidget(
// ...
) {
// ...
}
In the SatsWidget
composable you can pretty much write compose code just as
you do with any other Composable function. However it needs to use it’s own
GlanceModifier. This is because under the hood, the GlanceComposable
converts it into RemoteViews
in order to render the widget using the
existing App Widgets APIs. Not every component supports this transformation.
Cool! But how can the user interact with the Widget?
We have now made a workable widget. However, we have not shown how to deal with any user interaction yet. In our widget, we have a progress indicator that should show an animation when the user has clicked on it. It should also update the UI and stop spinning when the UI is updated.
The way we solved this was to show an CircularProgressIndicator
when the
value from upcomingIsUpdating
is true
and if it is false, we just show
the refresh icon.
if (upcomingIsUpdating) {
CircularProgressIndicator()
} else {
Image(
modifier = GlanceModifier.clickable(onClick = actionRunCallback<SatsWidgetUpdateAction>()),
provider = ImageProvider(R.drawable.ic_refresh),
contentDescription = context.getString(R.string.widget_refresh_cd),
)
}
As you can see in the code above for the refresh icon, we use an extension
method on the GlanceModifier
that handles the click interaction from the user.
You can make this callback action by extending the glance ActionCallback
and
use it with actionRunCallback<ActionCallback>
. This function can also take
actionParameters
that you can use in your ActionCallback
.
class SatsWidgetUpdateAction : ActionCallback {
override suspend fun onRun(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val intent = Intent(context, SatsWidgetReceiver::class.java).apply {
action = UpdateAction
}
context.sendBroadcast(intent)
}
companion object {
const val UpdateAction = "update_action"
}
}
Here we have added an intent for updating the UI. This intent is handled in the widget receiver and will just update the widget state. In our implementation we have also actions for launching the app on different screens. We handled that with deep-linking for the different destinations. For instance the way we open the app looks like this.
class OpenAppAction : ActionCallback {
override suspend fun onRun(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val intent = Intent(Intent.ACTION_VIEW, "sats://home/".toUri())).apply {
setPackage(context.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
}
}
You don’t have to use deep-links to open activities. You can also just use
the actionStartActivity<MainActivity>()
instead if you want to. This is
a lot easier if you don’t want to get into deep-linking or your Activity
and your Widget is in the same of a dependent module.
All in all, I really like this way of handling interaction with the Widget.
You don’t have to make a PendingIntent
to handle calls in a different
process from your app. With this ActionCallback
all that complexity is
handled for us.
At this early stage, this code will probably be subject to a lot of changes. However, I hope that this post will help someone or just induce some curiosity at least :)
You can read more about Glance from the docs and test out our widget in version 3.23.0 of our member app!
Thanks for reading!