16.
Creating Widgets Using Jetpack Glance
Written by Denis Buketa
If you want to extend your app’s functionality beyond the app itself, App Widgets are the best way to do it. They allow you to provide some features of your app as at-a-glance views that live in your user’s home screen.
According to Google’s blog announcement for Jetpack Glance, 84% of users use at least one widget. That gives you a sense of how important it is to at least be familiar with the basics of building app widgets.
In this chapter, you’ll learn how simple it is to build app widgets using Jetpack Glance.
You’ll learn:
- What is Jetpack Glance.
- How to define essential characteristics of your app widget using
AppWidgetProvider. - How to use
GlanceAppWidgetReceiverto instantiate your app widget and update it. - How to create UI layouts using
GlanceAppWidget. - How to handle actions in the widget.
Note: If you want to check Google’s announcement of Jetpack Glance, please refer to the official Google blog: https://android-developers.googleblog.com/2021/12/announcing-jetpack-glance-alpha-for-app.html.
Introducing Jetpack Glance
Jetpack Glance is a new framework that allows you to build app widgets using the same declarative APIs that you are used to with Jetpack Compose.
Beside using the similar APIs, it uses Jetpack Compose Runtime to translate a Composable into a RemoteView, which it then displays in an app widget. It also depends on Jetpack Compose Graphics and UI layers that you covered in the very first chapter of this book.
Keep in mind that these dependencies mean that Glance requires for you to enable Compose in your project, but it’s not directly interoperable with other Jetpack Compose UI elements. Because of that, you’ll notice that in this chapter you’ll use GlanceModifier instead of Modifier and some other composables will be imported from androidx.glance package instead of androidx.compose package.
Defining Essential Characteristics of Your App Widget
To follow along with the code examples, open this chapter’s starter project in Android Studio and select Open an existing project.
Then, navigate to 16-creating-widgets-using-jetpack-compose-glance/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’re ready to go! You can see the completed project by skipping ahead to the final project.
In this chapter you’re going to build a widget that displays the list of subreddits which you can find in the Subreddits screen. For learning purposes, imagine that the depicted switches allow you to toggle on or off notifications for a specific subreddit:
To enable you to focus only on building your widget, we made some improvements in the app:
- In
MainViewModel, we addedtoggleSubreddit()which triggers the logic for storing the switch information to preferences. - In
SubredditsScreen(), we added the logic that calls that method when you toggle a specific switch. - We added app_widget_loading.xml and app_widget_subreddits_preview.xml, which you’ll use when building the widget.
If you want to verify this functionality, you can toggle some switches, kill the app, and relaunch it. You’ll notice that all switches are as you left them.
OK, now when you know what you are building, you’re ready to begin your Jetpack Glance journey. :]
In res/xml folder, create a new file named app_widget_subreddits.xml and add the following code to it:
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_widget_description"
android:minWidth="100dp"
android:minHeight="100dp"
android:minResizeHeight="100dp"
android:minResizeWidth="100dp"
android:initialLayout="@layout/app_widget_loading"
android:previewLayout="@layout/app_widget_subreddits_preview"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="3"
android:widgetCategory="home_screen">
</appwidget-provider>
This file contains the information that defines essential characteristics of your widget.
- You specified the description for the widget picker.
- You defined a couple of width and height attributes with
min*,minResize*andtargetCell*. With this, you defined default size in terms of grid cells. Cell attributes are ignored in Android 11 and lower. You also defined min height and width, and widget’s absolute minimum size when the user tries to resize the widget. - With
resizeModeyou specified the rules for widget resizing. - For the
initialLayoutyou used the already available filelayout/app_widget_loadingthat will be shown before the content you’ll build with Jetpack Glance is configured. - With
previewLayoutyou defined how the widget will look like after it’s configured. For that attribute you also used preparedlayout/app_widget_subreddits_previewlayout. - With
widgetCategoryyou can specify if your widget can be displayed on home screen (home_screen) and/or lock screen (keyguard).
Note: For more information about all widget attributes, please refer to the official documentation: https://developer.android.com/develop/ui/views/appwidgets#AppWidgetProviderInfo.
Creating the Hello Glance Widget
In this section you’ll create a Hello Glance widget so that you get one step closer to displaying your widget on screen.
Create jetreddit/appwidget package and create SubredditsWidget.kt file in it. Then add the following content to it:
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.text.Text
class SubredditsWidget : GlanceAppWidget() {
@Composable
override fun Content() {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Text(text = "Hello Glance")
}
}
}
With this you created a simple widget that will display a text when configured. You extended GlanceAppWidget that handles the composition and also communicates with the AppWidgetManager. The AppWidgetManager is responsible for updating widget states, keeping the information about installed widgets and related state.
Note: For more information about
AppWidgetManager, please refer to the official documentation: https://developer.android.com/develop/ui/views/appwidgets.
Notice that in your SubredditsWidget() you overrode composable function Content(). That function allows you to define the UI for your widget. Whenever you update your widget, the system will start a composition and translate its content to RemoteViews. Finally, the system will send it to the AppWidgetManager.
Displaying the Widget on the Home Screen
The final step before you can see your widget on the home screen is to create AppWidgetProvider.
In SubredditsWidget.kt, add the following code at the bottom:
class SubredditsWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget =
SubredditsWidget()
}
GlanceAppWidgetReceiver is the implementation of AppWidgetProvider. It uses GlanceAppWidget to generate the remote views. Because of that, you were required to define SubredditsWidget() for the glanceAppWidget property.
For this to compile, you have to add the following import:
import androidx.glance.appwidget.GlanceAppWidgetReceiver
Next, open AndroidManifest.xml and at the end of the application tag add the following:
<receiver
android:name=".appwidget.SubredditsWidgetReceiver"
android:enabled="@bool/glance_appwidget_available"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_subreddits" />
</receiver>
For android:name you have to provide the name of the AppWidgetProvider used by the widget. In your case, that is SubredditsWidgetReceiver.
With the intent-filter, you defined that the provider can react to the ACTION_APPWIDGET_UPDATE broadcast. This broadcast you have to explicitly declare. For other widget broadcasts, AppWidgetManager will automatically send them when needed.
With meta-data you defined the essential characteristic for this widget. You defined those in the Defining Essential Characteristics of Your App Widget section of this chapter.
Now is the time to check your widget in action! :]
Build and run your app. Once your app opens, close it and find the app launcher. If you long press the app launcher, you’ll see that you can now add a widget for it:
If you select Widgets, you should see the preview of your widget:
On that step, you’ll be able to drag your widget to the screen. When dragging, you should see the preview of your widget and once you drop it, you’ll see the content you defined in SubredditsWidget:
These are all the steps that you need to do for displaying your widget on the home screen. Next, you’ll add some more content to the widget! :]
Adding Subreddits to the Widget
Before you start implementing the rest of the UI, it is important to mention that @Preview still doesn’t work for Jetpack Glance. You’ll be coding the UI for the widget and then building the project later to see it.
First, add the Subreddit() composable to SubredditsWidget.kt:
@Composable
fun Subreddit(@StringRes id: Int) {
val checked: Boolean = false
Row(
modifier = GlanceModifier
.padding(top = 16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Image(
provider = ImageProvider(
R.drawable.subreddit_placeholder
),
contentDescription = null,
modifier = GlanceModifier.size(24.dp)
)
Text(
text = LocalContext.current.getString(id),
modifier = GlanceModifier
.padding(start = 16.dp)
.defaultWeight(),
style = TextStyle(
color = FixedColorProvider(
color = MaterialTheme.colors.primaryVariant
),
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
)
Switch(
checked = checked,
onCheckedChange = null
)
}
}
This is pretty much the same composable you use in SubredditsScreen(), but with Glance composables. Add the following imports:
import androidx.annotation.StringRes
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.LocalContext
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.appwidget.Switch
import androidx.glance.layout.Row
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.fillMaxWidth
import androidx.glance.text.FontWeight
import androidx.glance.text.TextStyle
import androidx.glance.unit.FixedColorProvider
import com.yourcompany.android.jetreddit.R
Notice that all composables are from the androidx.glance package.
Next, you’ll create the title composable and you’ll use Subreddit() to create a list of subreddits:
@Composable
fun WidgetTitle() {
Text(
text = "Subreddits",
modifier = GlanceModifier.fillMaxWidth(),
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = FixedColorProvider(Color.Black)
),
)
}
@Composable
fun ScrollableSubredditsList() {
LazyColumn {
items(communities) { communityId ->
Subreddit(id = communityId)
}
}
}
Add the following imports as well:
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import com.yourcompany.android.jetreddit.screens.communities
These composables represent your title and list of subreddits.
Finally, replace the Hello Glance definition in the Content() composable with:
@Composable
override fun Content() {
Column(
modifier = GlanceModifier
.fillMaxSize()
.padding(16.dp)
.appWidgetBackground()
.background(Color.White)
.cornerRadius(16.dp)
) {
WidgetTitle()
ScrollableSubredditsList()
}
}
Add the following imports as well:
import androidx.glance.layout.Column
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.cornerRadius
If you now remove the old widget, build and run the app, and try again adding the widget to the Home screen. You should see something like this:
If you now try to interact with the widget, you’ll notice that you can switch on/off subreddits, but that change is not visible in your app. This is also true vice versa. You’ll work on that next! :]
Handling Widget Actions
First, let’s handle the onCheckedChange() action in Switch(). When working with widgets, you handle user actions using the ActionCallback interface.
Add the following code to SubredditsWidget.kt:
private val toggledSubredditIdKey = ActionParameters.Key<String>("ToggledSubredditIdKey")
class SwitchToggleAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val toggledSubredditId: String =
requireNotNull(parameters[toggledSubredditIdKey])
val checked: Boolean =
requireNotNull(parameters[ToggleableStateKey])
updateAppWidgetState(context, glanceId) { glancePrefs ->
glancePrefs[booleanPreferencesKey(toggledSubredditId)] = checked
}
SubredditsWidget().update(context, glanceId)
}
}
For this to work, you need the following imports:
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.ToggleableStateKey
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.GlanceId
import androidx.datastore.preferences.core.booleanPreferencesKey
import android.content.Context
Here, you defined the ActionCallback that will be executed in response to the user action. The important thing to notice is that it will be executed before the widget content is updated.
You’ve overriden onAction() with three parameters: Context, GlanceId and ActionParameters.
-
Contextgives you access to the app context and you’ll use it later to communicate with the JetReddit app. -
GlanceIdtells you what widget triggered the action so that you can update its content in response to the action. - With
ActionParametersyou can pass data between the widget andActionCallback.
Notice that you also defined ActionParameters.Key. That is the key for ActionParameters and in this case you use it for passing the subreddit ID for which a switch has been toggled on or off. Right now you don’t have the code that passes that information using that key, but you added the code to read that ID from ActionParameters.
After reading the value, you call updateAppWidgetState() which updates the state of an app widget using the global PreferencesGlanceStateDefinition. Here, it is important to use the correct glanceId.
Finally, you call SubredditsWidget().update() which triggers the composition of Content() and sends the result to the AppWidgetManager.
Now, edit the Subreddit() like this:
@Composable
fun Subreddit(@StringRes id: Int) {
// HERE
val preferences: Preferences = currentState()
val checked: Boolean = preferences[booleanPreferencesKey(id.toString())] ?: false
Row(...) {
...
Switch(
checked = checked,
// HERE
onCheckedChange = actionRunCallback<SwitchToggleAction>(
actionParametersOf(
toggledSubredditIdKey to id.toString()
)
)
)
}
}
Here you used actionRunCallback(), which creates an Action that executes SwitchToggleAction. With actionParametersOf() you passed the subreddit ID that you’ll read in SwitchToggleAction.
You also added the code that will initialize the checked property with the value from the app widget state. For this to work, add the following imports:
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.action.actionParametersOf
import androidx.glance.currentState
import androidx.datastore.preferences.core.Preferences
If you now build the app and create a new widget, you won’t notice anything changed. However, with this code you made your widget stateful. It now manages its own state. That can be used to connect it to the app so that the widget is synced with switches in the app.
Connecting the Widget With the JetReddit App
At the beginning of the chapter, it was mentioned that the JetReddit app now stores states of the switches in preferences.
You can leverage that to sync the widget with the app. In SubredditsWidget.kt, edit the onAction() in SwitchToggleAction like this:
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val toggledSubredditId: String = requireNotNull(parameters[toggledSubredditIdKey])
val checked: Boolean = requireNotNull(parameters[ToggleableStateKey])
updateAppWidgetState(context, glanceId) { glancePreferences ->
glancePreferences[booleanPreferencesKey(toggledSubredditId)] = checked
}
// HERE
context.dataStore.edit { appPreferences ->
appPreferences[booleanPreferencesKey(toggledSubredditId)] = checked
}
SubredditsWidget().update(context, glanceId)
}
Add the following imports as well:
import com.yourcompany.android.jetreddit.dependencyinjection.dataStore
import androidx.datastore.preferences.core.edit
With this change, you added the logic that modifies JetReddit’s preferences when you toggle a switch in the widget. That means that the state from the widget will be propagated to the app.
Remove the widget if you have it on the home screen, run the app and create a new widget. Try toggling off and on some switches in the widget and then check the Subreddits screen in the app.
You’ll notice that changes in the widget are propagated to the app. However, it still doesn’t work the other way around. You’ll tackle that next!
What you need to do next is to update the widget state when it’s created and also update its state when the user toggles switches in the app.
To do that, add the following code to SubredditsWidgetReceiver:
private suspend fun updateAppWidgetPreferences(
subredditIdToCheckedMap: Map<Int, Boolean>,
context: Context,
glanceId: GlanceId
) {
subredditIdToCheckedMap.forEach { (subredditId, checked) ->
updateAppWidgetState(context, glanceId) { state ->
state[booleanPreferencesKey(subredditId.toString())] = checked
}
}
}
private fun Preferences.toSubredditIdToCheckedMap(): Map<Int, Boolean> {
return communities.associateWith { communityId ->
this[booleanPreferencesKey(communityId.toString())] ?: false
}
}
You added an updateAppWidgetPreferences() method that you’ll use to instantiate the widget state with the information about the switches stored in the JetReddit’s preferences.
You also added a helper method toSubredditIdToCheckedMap(). This method maps the information from JetReddit’s preferences to a Map where the key is the subreddit ID and the value represents if it is toggled on or off.
Now, complete SubredditsWidgetReceiver by adding the following:
private val coroutineScope = MainScope()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
coroutineScope.launch {
// Step 1: Get GlanceId for your widget
val glanceId: GlanceId? = GlanceAppWidgetManager(context)
.getGlanceIds(SubredditsWidget::class.java)
.firstOrNull()
if (glanceId != null) {
// Step 2: Collect JetReddit's preferences
withContext(Dispatchers.IO) {
context.dataStore.data
.map { preferences -> preferences.toSubredditIdToCheckedMap() }
.collect { subredditIdToCheckedMap ->
// Step 3: Update app widget state
updateAppWidgetPreferences(subredditIdToCheckedMap, context, glanceId)
// Step 4: Update app widget content
glanceAppWidget.update(context, glanceId)
}
}
}
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
coroutineScope.cancel()
}
Finish by adding the following imports:
import android.appwidget.AppWidgetManager
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.map
import androidx.glance.appwidget.GlanceAppWidgetManager
Let’s unpack what you did here. You’ve overridden the onUpdate() method. The OS calls this method in response to the AppWidgetManager#ACTION_APPWIDGET_UPDATE broadcast. That’s the moment when the app widget provider needs to provide RemoteViews for its app widgets.
In this case, that happens when you create this widget on the screen. In other words, this allows you to prepare your widget’s state before it renders it first content.
The first step you did in that method was to get an app widget ID because that is what you need to call the update() method on it. Second, you fetched the JetReddit’s state from preferences. You then updated the app widget’s state. And lastly, you updated the widget.
It is also important to notice that the coroutine you created will be alive until the widget is deleted, so whenever the user changes something in the JetReddit’s state, the widget will be updated with that data as well. You cancel the coroutineScope in onDeleted() method to clear up the resources.
If you now delete the previous widget, run the app and create a new widget, you’ll notice that your widget’s state is completely synced with your JetReddit’s state. You can even try toggling the switches in the widget or the app. You’ll see that they stay in sync.
Excellent work! With this you’ve completed this chapter! You just learned the basics of working with Jetpack Glance. Your users can now have JetReddit widget on their home screen! :]
Key Points
- Jetpack Glance is a new framework that allows you to build app widgets using declarative APIs that you are used to with Jetpack Compose.
- To define widget’s essential characteristic, you need to create a
appwidget-providerin res/xml folder. - You use
GlanceAppWidgetto define the widget UI and to communicate with theAppWidgetManager. - You need to create
GlanceAppWidgetReceiverand define in the AndroidManifest.xml for your app to be able to create widgets. - If you want to handle widget actions, you’ll use
ActionCallback. -
ActionParametersenables you to pass data between app widget andActionCallback. - To make your app widget stateful, you can update its state with
updateAppWidgetState(). - The
onUpdate()method inGlanceAppWidgetReceiver()allows you to update the widget state once the user creates it.
Where to Go From here?
Congratulations, you just completed the Creating Widgets Using Jetpack Glance chapter!
With these new skills, we don’t doubt you’ll implement some exciting new widgets for your apps.
Wishing you all the best in your continued Jetpack Compose adventures!