Create widgets with Compose and Glance SDK

Alessandro Oddo
8 min readJul 29, 2024

--

With Compose we have the opportunity to write not only the UI of our application but also widgets.

The information in this article is based on the official documentation of Glance SDK.

In this article, we are going to show three different kinds of widgets:

  • a “hello world” widget
  • a counter widget
  • a timestamp widget

NOTE: To write a beautiful UI isn’t one of the goals of this article ;)

To start widget integration with Compose you need to use Glance SDK, which provides a sub-set of Composable functions that can be used to create widgets.

To start using Glance SDK you need to add the following dependencies to your project. Inside your toml file add the following dependencies:

[versions]
# Other dependencies...
glanceAppwidget = "1.1.0"

[libraries]
# Other dependencies...
androidx-glance = { module = "androidx.glance:glance", version.ref = "glanceAppwidget" }
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glanceAppwidget" }
androidx-glance-material = { module = "androidx.glance:glance-material", version.ref = "glanceAppwidget" }
androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glanceAppwidget" }

You need also to add the following code to your build.gradle file:

android{
///...

buildFeatures {
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
}



dependencies {
//other dependecies...
implementation(libs.androidx.glance)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material)
implementation(libs.androidx.glance.material3)
}

Then you are ready to create your first widget! 💪

Create a HELLO WORLD widget

The first step is to create the most simple widget you can make, a classic “Hello World”.

We need to create a new object that extends the GlanceAppWidget class and overrides the provideGlance method:

import android.content.Context
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.text.Text

object HelloWorldWidget : GlanceAppWidget() {

override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
Box(
contentAlignment = Alignment.Center,
modifier = GlanceModifier.background(color = Color.White).fillMaxSize()
) {
Text("Hello, World!")
}
}
}
}

As you see the code it’s very similar to Jetpack Compose, the concepts are equal, but if you pay attention you will see that the modifier here is a GlanceModifier and the composable classes are retrieved by androidx.glance package.

Then we need to notify the system that our application has a new widget that could be added to the screen. To do that we need to create a new class that extends the GlanceAppWidgetReceiver class, this class is fundamental to managing the state of the widgets (on update, on destroy etc…) but we don’t need to customize it.

class HelloWorldWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = HelloWorldWidget
}

Now we need to add the HelloWorldWidgetReceiver to the AndroidManifest.xml file, in that way, the system can recognize our widget.

 <receiver
android:name="it.widget.widget.HelloWorldWidgetReceiver"
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/hello_world_widget_info" />
</receiver>

Where hello_world_widget_info is an XML file that we need to create, it contains the information about the widget like size, preview, minimum height and width, etc…

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="96dp"
android:minHeight="96dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen" />

Then all is ready, run your app, go into the widgets section and you will see a new widget for your app, the final result will be something like this:

NOTE: the preview, in this case, will be a default one, you can modify it but unfortunately not with Glance, you need to customize it by writing an XML preview layout.

Create a counter widget

It’s important to understand how to update the widget state.
With this second widget, I want to explain how to visualize information inside a widget and how to update it.

The information displayed by the widget could be saved into the datastore, and retrieved like this:

val count = currentState(key = intPreferencesKey("count")) ?: 0

These preferences are saved into the widget context, so they are not shared with the application and not among widgets.

So if we want to create a second widget that shows a counter, we can create something like this:

object CounterWidget : GlanceAppWidget() {

const val KEY_COUNT = "count"
override suspend fun provideGlance(context: Context, id: GlanceId) {
Log.d("CounterWidget", "provideGlance: id -> $id") // this is our widget identifier


provideContent {
val count = currentState(key = intPreferencesKey(KEY_COUNT)) ?: 0
Row(
modifier = GlanceModifier.background(color = Color.White).fillMaxSize().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
text = "-", onClick = actionRunCallback<ChangeCountAction>(
actionParametersOf(ChangeCountAction.countParam to count - 1)
)
)
Box(
modifier = GlanceModifier.defaultWeight(),
contentAlignment = Alignment.Center,
) {
Text(count.toString())
}
Button(
text = "+", onClick = actionRunCallback<ChangeCountAction>(
actionParametersOf(ChangeCountAction.countParam to count + 1)
)
)
}
}
}
}

class ChangeCountAction : ActionCallback {


companion object {
val countParam = ActionParameters.Key<Int>("count")

fun invokeUpdateCounter(context: Context, glanceId: Int, count: Int) {
val action = ChangeCountAction()
CoroutineScope(Dispatchers.Main).launch {
action.onAction(context, GlanceAppWidgetManager(context).getGlanceIdBy(glanceId), actionParametersOf(countParam to count))
}
}

}

override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[intPreferencesKey(CounterWidget.KEY_COUNT)] = parameters[countParam] ?: 0
}
CounterWidget.update(context, glanceId)
}

}

class CounterWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = CounterWidget

override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d("ChangeCountAction", "onUpdate: ids -> ${appWidgetIds.joinToString(", ")}")
super.onUpdate(context, appWidgetManager, appWidgetIds)
}
}

NOTE: Remember to add the receiver to the AndroidManifest.xml file

As you can see, we have created a new action class called ChangeCountAction that will be called when the user clicks on the buttons, this action will update the widget state thanks to the parameter count that we have passed to the action. The same action may be called by the application to update the widget state, in this case, we need to call the invokeUpdateCount static method from the app.

To do this, we need to know the glanceId of the widgets we want to update, GlanceAppWidgetManager class comes to our aid.

For example, to retrieve the glanceId (the int value) of the widgets we can use the following method:

fun getWidgetIds(context: Context, providerClass: Class<*>): IntArray {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, providerClass)
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
return appWidgetIds
}

NOTE: invokeUpdateCounter wants a GlanceId as input, with the method below we can translate a glanceId to a GlanceId class instance:

GlanceAppWidgetManager(context).getGlanceIdBy(gId)

To recap: until now we saw how to create a simple widget and how to update its state, we saw that we can update it from the widget itself or from the application.

The final result is this:

The next section will show how widgets communicate with applications, services and broadcast receivers.

Send data from the widget to the application

To send data from the widget to the application we have multiple choose.

The main guide says that you can use one of the following methods:

  • actionStartActivity -> to start an Activity (so you can open your app with some extra intent info)
  • actionStartService -> Very useful when you want to start a service to update your widget based on remote information
  • actionSendBroadcast -> another way to communicate directly to your application

In the use of these methods, I found some problems in setting the data that I wanted to send to the application, so I decided to create, inside my broadcast, static methods to send data to the application. These are good because static methods can also be used by other classes, not only by the widgets.

So, for this example, I want to create a widget that sends to the main application the timestamp when the user clicks on it.

As first step, we need to create our broadcast receiver:

class TimeWidgetBroadcastReceiver : BroadcastReceiver() {
companion object {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "time_widget_datastore")

const val UPDATE_TIMESTAMP_ACTION = "UPDATE_TIMESTAMP"
const val TIMESTAMP_EXTRA = "timestamp"


fun updateTimestamp(context: Context, timestamp: Long) {
context.sendBroadcast(Intent(context, TimeWidgetBroadcastReceiver::class.java).apply {
action = "${context.packageName}.${UPDATE_TIMESTAMP_ACTION}"
putExtra(TIMESTAMP_EXTRA, timestamp)
})
}

fun getTimestamp(context: Context): Flow<Long> {
return context.dataStore.data.map {
it[longPreferencesKey("timestamp")] ?: 0
}
}
}


override fun onReceive(context: Context, intent: Intent) {
Log.d("TimeWidgetBroadcastReceiver", "onReceive: ${intent.action}")
if (intent.action == "${context.packageName}.${UPDATE_TIMESTAMP_ACTION}") {
val timestamp = intent.getLongExtra(TIMESTAMP_EXTRA, 0)
Log.d("TimeWidgetBroadcastReceiver", "onReceive: timestamp -> $timestamp")
GlobalScope.launch {
context.dataStore.edit {
it[longPreferencesKey("timestamp")] = timestamp
}
}
}
}

}

This class contains two static methods, one to save the timestamp into datastore to the application and one to retrieve it (the best thing is to use the datastore in a different class but, for this example, I decided to use it here for simplicity).

Then we need to add the receiver to the AndroidManifest.xml file:

<application
...
>
...
<receiver
android:name="it.widget.widget.receiver.TimeWidgetBroadcastReceiver"
android:exported="false">
<intent-filter>
<action android:name="${applicationId}.UPDATE_TIMESTAMP_ACTION" />
</intent-filter>
</receiver>
</application>

UPDATE_TIMESTAMP_ACTION is a constant containing the action that the broadcast receiver will listen to.

As the last step, we need to create the widget (remember, you need to register it in the AndroidManifest.xml file as we saw in the previous examples):

object TimeButtonWidget : GlanceAppWidget() {

const val KEY_TS = "timestamp"
override suspend fun provideGlance(context: Context, id: GlanceId) {
Log.d("TimeButtonWidget", "provideGlance: id -> $id") // this is our widget identifier


provideContent {
val timestamp = currentState(key = longPreferencesKey(KEY_TS)) ?: 0L
val formatter = SimpleDateFormat("dd-MM-yyyy HH:mm:ss")
val timeCal = Calendar.getInstance()
timeCal.timeInMillis = timestamp
val dateString = formatter.format(timeCal.time)
Column(
modifier = GlanceModifier.background(color = Color.White).fillMaxSize().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = GlanceModifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Button(
text = "Send timestamp", onClick = actionRunCallback<UpdateTimestampAction>(
actionParametersOf(UpdateTimestampAction.tsParam to System.currentTimeMillis())
)
)
}
Box(
modifier = GlanceModifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(if (timestamp != 0L) dateString else "", style = androidx.glance.text.TextStyle(textAlign = TextAlign.Center))
}
}

}
}
}



class UpdateTimestampAction : ActionCallback {


companion object {
val tsParam = ActionParameters.Key<Long>("tsParam")
}

override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val ts = parameters[tsParam] ?: 0L
updateAppWidgetState(context, glanceId) { prefs ->
prefs[longPreferencesKey((TimeButtonWidget.KEY_TS))] = ts
}
TimeButtonWidget.update(context, glanceId)
TimeWidgetBroadcastReceiver.updateTimestamp(context, ts)
}

}

We are now able to send the timestamp to the application when the user clicks on the button, in a very similar way we could send the timestamp through an intent or a service.

Final considerations

Now that we have a vision of what we can do with widgets, we can discuss some limits the Glance SDK has.
The first limit is that to create a widget preview (the layout displayed inside the widget picker), we need to create an XML layout file, we can’t use a composable function to create it.
The second limitations are linked to the layouts that we can create, we can’t use all the composable functions that we have in the Jetpack Compose library, we can use only the functions provided by the Glance SDK which is a subset, this could be a problem if you don’t talk about it previously with your UX/UI designer.
Another problem could be that most of these composable have the same name as the Jetpack Compose functions, so you need to pay attention to the import you are using. To avoid this issue, a solution is to separate the widgets into a different module.

I know there are a lot of things to say about preview customization, groups and the appwidget-provider, but I think that the official documentation is good enough.

Here is the link to the code 😉

--

--