App Widgets can provide a huge amount of value to users by surfacing relevant information outside of an app. They are still not as common as we’d expect, so we’re revisiting app widgets on Android to see what’s changed in the new Jetpack Glance framework, that will help developers like us create App Widgets quickly and more easily than ever before!
We wanted to create a meaningful widget that went beyond Hello World or a couple of buttons. In this journey, many blogs we found helped along the way, but there is some conflicting information due to development approaches — traditional XML layout based, and Glance / Compose based. So, this article will try to clarify some of those as well as give a broader perspective of User Experience (UX) and discoverability of App Widgets (which has been challenging in the past).
App Widgets are often under-utilized in the Android Ecosystem. We traditionally see App Widgets only on system apps like Calendar, Email, or simpler apps like weather apps. From a User Experience (UX) and marketing standpoint, they can offer a lot. App Widgets are supposed to be simple, yet provide just enough information to peak interest. Here are some benefits:
It is great to see a renewed push by Google with App Widget enhancements with the Android 12 release. There can be multiple App Widget instances associated with one app. Under the hood, App Widgets rely on RemoteViews which allows rendering views on another process. Our understanding is that Home Screen is a process that implements AppWidgetHost and we are loaning some parts of our app functionality to that process. The biggest advantage of the Glance framework is that it takes a lot of boilerplate code out by generating RemoteViews from Glance Composable code.
Full version of partial code snippets in this article can be accessed at: https://gist.github.com/harishrpatel/adcac2edfa930351c7789ede59214a7e
For this article, we will refer to a personal Calendar App called Consolidator that renders days with different colors based on types of events. We need to show either one month or two months depending on space on home screen and provide UP/DOWN arrow buttons for navigation. In order to explore communication between App and App Widget, we will fetch events (holidays, recurring meetings, etc.) data every 15 minutes and refresh App Widget.
Glance based App Widget implementation requires very little code, so we will just describe how different components interact. As illustrated below, four major aspects of our implementation are:
Here is one flow: WorkManager fetches calendar entries, caches data in DataStore and informs AppWidgetManager to update / refresh the widget. Now there is disconnect, which is why we cache data in DataStore. AppWidgetManager now invokes Glance Composable code which will read DataStore cache and render the App Widget UI.
We need to implement ActionCallback interface to handle button click actions. We update the month when the user clicks UP/DOWN buttons and take the user to the app when they click on calendar (to get detailed information). GlanceAppWidgetReceiver is an implementation of AppWidgetProvider that returns life cycle callbacks that we really did not interact with.
As described above, Glance reduces quite a bit of code and provides enough for most widgets. This table shows some criteria that one should consider before starting design.
Advanced users are likely to find App Widgets and install them. But now there is a programmatic way to prompt users to add App Widget by using requestPinAppWidget method in one of the App’s Activity or Fragment. This call should be wrapped around checks to not annoy users with frequent popups if user has already added an App Widget. AppWidgetManager methods to get providers do not accurately distinguish if user has added an App Widget. One can store a flag in DataStore in the requestPinAppWidget callback to get around this.
Glance implementation does not use XML layouts except for a preview layout (starting from Android 12 — API 31). If using WorkManager, set updatePeriodMillis to 0 indicating that WorkManager will update App Widget. Here is an example of xml-v31/calendar_widget_info.xml file. Default version will look identical except for a couple of attributes.
<!-- API 31 (Android 12 and up) -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:minWidth="200dp"
android:minHeight="200dp"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/calendar_widget_preview_image"
android:previewLayout="@layout/calendar_widget_small_layout"
android:targetCellWidth="2"
android:targetCellHeight="3"
android:maxResizeWidth="300dp"
android:maxResizeHeight="600dp"
android:widgetCategory="home_screen"
android:updatePeriodMillis="0"
/>
While most of the supported UI components worked well, there were some difficulties with LazyColumn. We avoided these by using Column instead. Composable code is translated into RemoteViews, so nested lists or very complex UI should be avoided. Glance Compose layout parameters are stricter than Jetpack Compose ones but can easily be fixed by tweaking GlanceModifiers. Here is an example of a Composable:
Column(
modifier = GlanceModifier.padding(all = 2.dp).fillMaxWidth().background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally
) {
// UP/Prev button
Image(
provider = ImageProvider(com.bottlerocket.android.consolidator.R.drawable.ic_arrow_up_24),
contentDescription = "Back",
modifier = GlanceModifier.defaultWeight()
.background(Color.Transparent)
.clickable(onClick = actionRunCallback<CalendarActionCallback>(
actionParametersOf(pairs = arrayOf(ActionParameters.Key<DateArrow>(KEY_DATE_ARROW) to DateArrow.Back)))
)
)
val monthEvents = calendarEventsMap.filter { it.key.year == firstMonthState.year && it.key.month == firstMonthState.month }
MonthCalendarWidgetUI(firstMonthState, monthEvents)
if (numMonthsToShow >= 2) {
val secondMonth = firstMonthState.plusMonths(1)
val secondMonthEvents = calendarEventsMap.filter { it.key.year == secondMonth.year && it.key.month == secondMonth.month }
MonthCalendarWidgetUI(secondMonth, secondMonthEvents)
}
}
We chose Work Manager to refresh App Widget at 15 minutes intervals (a WorkManager minimum period). WorkManager’s ability to pass in constraints for battery optimization and integration with Android system and Glance framework were primary reasons. Using PeriodicWorkRequestBuilder’s setInitialDelay method really helped giving few seconds to initialize the App Widget before starting to fetch data. Also make sure to pass ExistingPeriodicWorkPolicy.KEEP ensuring only a single instance of Workmanager request is queued.
fun startCalendarDataFetch(workManager: WorkManager) {
calendarWidgetLog("queueCalendarDataRequest")
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(false)
.setRequiresBatteryNotLow(true)
.build()
// NOTE: min period is 15 minutes.
val request = PeriodicWorkRequestBuilder<CalendarWorkManager>(
Duration.ofMinutes(
CALENDAR_WORK_REQ_PERIOD_MINUTES
))
.setConstraints(constraints)
.setInitialDelay(Duration.ofSeconds(CALENDAR_WORK_REQ_INITIAL_DELAY_SECONDS))
.build()
// MUST: Specifying "unique" and "KEEP" will ensure there is only one worker in the system.
val operation = workManager.enqueueUniquePeriodicWork(
CALENDAR_FETCH_WORKER,
ExistingPeriodicWorkPolicy.KEEP,
request)
calendarWidgetLog("WorkManager queue status: ${operation.result}")
}
The biggest advantage of WorkManager is that it reduced activity at night when our test device was in Doze mode for battery optimization. When the device was being charged overnight, a 15-minute Work Manager thread polled at that cadence all night. But when not plugged in, it only polled at 12:30 AM, 1:30 AM, 3:30 AM and then 6:30 AM to avoid unnecessary work.
private suspend fun updateWidgetData(events: List<DayEvent>) {
GlanceAppWidgetManager(context = context).getGlanceIds(ConsolidatorAppWidget::class.java).forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs ->
calendarWidgetLog("updateAppWidgetState: glanceId: $glanceId")
// Each app widget instance stores in a different DataStore file.
prefs.setCalendarData(events)
}
// VERY IMPORTANT: Refreshing App Widget with updated events here.
calendarWidgetLog("updateWidgetData: Update UI")
ConsolidatorAppWidget().update(context, glanceId)
}
}
class ConsolidatorAppWidget: GlanceAppWidget() {
private val isDebugOverride = false // Set to true to have only one widget size to reduce logging clutter
/**
* Used to dynamically configure Widget as user is resizing it.
*/
override val sizeMode: SizeMode = SizeMode.Responsive(
if (isDebugOverride && isDebug()) setOf(ONE_MONTH_WIDGET) else setOf(ONE_MONTH_WIDGET, TWO_MONTH_WIDGET)
)
...
}
App Widgets promote user engagement and Glance makes development fun while creating a professional experience quickly. As Glance is in its Alpha release phase currently, they are very receptive to get feedback. Glance Composable code provides a stable way to design UI. Data persistence and refresh inconsistencies add a few challenges, but they can be overcome. WorkManager familiarity or finding other ways to refresh data should help. In short, keep the first App Widget relatively simple and grow gradually. Starting with Android 12, emphasis on discoverability of a widget has been improved, and allows for much more visibility with users.