Quick introduction of related Jetpack libraries which will help on your Android widget's development
Hello again fellow Android advocate, nice to have you here again.
We are aware the quick introduction to the widget’s world in Android and React Native was not enough, and we left you with tons of open thoughts and questions. We are here again to try to solve some of them.
In this opportunity, we want to deep dive in the Android platform and all the Jetpack libraries we used in our favour during our journey in the widget world. Let’s start by discussing our main goal here. What is a widget?
Yes, we know the title is a bit misleading, but for the ones who don’t know, widgets are elements of an application (same as activities or services).
A widget is a miniature lightweight view, frequently used to display, and in some cases interact, with a dedicated piece of the app without actually opening it.
Nowadays front-end developers love to build their UI using declarative style and Android developers are not an exception. Jetpack Compose was introduced not-so-long ago to fulfil this requirement. There is already plenty of material about it, but if you are not familiar with it, you can start from here.
But when it comes to widgets, UI works a bit different from the Activities or Fragments we are used to working with. Widgets have limited resources, and this includes limited support for custom layouts, styles or fonts, among others. Due to all these existing limitations, Jetpack introduced Glance. This new library allowed us to create a declarative UI using compose, but with the limited resources that a widget provides.
After working with Glance for a while, we can recap some of the differences we found so far:
Unlike the usual UI elements we work with, widgets have limited support for layouts and UI elements.
ConstraintLayouts are not yet supported, and this brings a lot of limitations to place your UI elements in the proper way.
The easiest solution we found, and it doesn’t solve all possible problems you can face when building a widget complex layout, was to play around with the boxing model.
Core layout elements (Box, Row and Column) are provided, and with help of the GlanceModifiers for their alignment and sizing, it is possible to come up with a complex layout by adding multiple layers of basic elements.
Let’s see a quick example:
// Widget Root
Box(
modifier = GlanceModifier.fillMaxSize().cornerRadius(8.dp),
contentAlignment = Alignment.Center,
) {
// Layer 1 - Image
CachedImage(
modifier = GlanceModifier.fillMaxSize(),
)
// Layer 2 - Icon
Box(
modifier = GlanceModifier.fillMaxSize().padding(all = 16.dp),
contentAlignment = Alignment.TopEnd,
) {
//... Render brand logo
}
// Layer 3 - Location
Box(
modifier = GlanceModifier.fillMaxSize().padding(all = 16.dp),
contentAlignment = Alignment.BottomStart,
) {
//... Render Location + Special Offer Logos
}
}Some resource helpers (like getString or ImageProvider) won’t work for Glance composables, but Glance enables a different subset of utilities to handle them.
// Compose
Text(
text = stringResource(id = R.string.mobile_app_inspire_widget_config_title),
)
// Glance
Text(
text = context.getString(R.string.mobile_app_inspire_widget_config_text),
)// Compose
Image(
painter = painterResource(id = R.drawable.brand_icon),
contentDescription = null,
)
// Glance
Image(
provider = ImageProvider(resId = R.drawable.brand_icon),
contentDescription = null,
)Image elements have an extra hidden complexity when it comes to remote resources. As widgets have limited time to render, downloading images in the background is not an easy task. You cannot use libraries like Glide the same way you would do with Compose. There are some examples you can look at, but overall what you need to know is to download the image in advance, and when the resource is finally downloaded and stored locally, you can ask the widget to render it.
In our case, we ended up using Glide’s cache support in our favour. Let’s look at a simple example:
class GlideImageDatasource(private val context: Context) : IRemoteImageDatasource {
override fun getCachedFilePath(imageUrl: String): String? =
Uri.parse(imageUrl).runCatching {
Glide.with(context)
.asFile()
.load(this)
.skipMemoryCache(true)
.submit()
.get()
.absolutePath
}.getOrNull()
}@Composable
fun CachedImage(
filePath: String,
@DrawableRes placeholder: Int,
contentDescription: String?,
modifier: GlanceModifier = GlanceModifier,
contentScale: ContentScale = ContentScale.Fit
) {
val provider = filePath.runCatching {
ImageProvider(BitmapFactory.decodeFile(this))
}.getOrElse {
ImageProvider(placeholder)
}
Image(
provider = provider,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
}As mentioned earlier, widgets allow user interaction. Glance provides Button support to handle user feedback. But again, we need to take into consideration we are working with limited resources. As widgets don’t have a dedicated process, there is a specific way to deal with actions callbacks.
For some of the common actions like Intents, Glance provides support functions out of the box. You can also create your own custom callbacks as well. Let’s see some examples:
modifier = GlanceModifier.clickable(
onClick = actionStartActivity(
InspireWidgetConfigActivity::class.java
)
)// Define it
class SearchForDealCallback : ActionCallback {
companion object {
val searchDealKey = ActionParameters.Key<String>(
"SearchForDealCallback:deal"
)
val searchConfigKey = ActionParameters.Key<String>(
"SearchForDealCallback:config"
)
}
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters,
) {
val deal = parameters[searchDealKey]?.toDeal()
val config = parameters[searchConfigKey]?.toSearchParameters()
// ...do something
}
}
// Then use it
modifier = GlanceModifier.clickable(
onClick = actionRunCallback<SearchForDealCallback>(
parameters = actionParametersOf(
SearchForDealCallback.searchDealKey to deal.toJsonString(),
SearchForDealCallback.searchConfigKey to config.toJsonString()
),
),
),Another big missing piece you will notice when working with widgets is the lack of ViewModels. As widgets have limited time to render, they don’t really need a ViewModel running aside. But then where do we get the UI state we want to render?
There are several answers to this question. You can check the suggested one here, but the most useful one we found was on Preferences.
Thanks to Glance and the interoperability with Jetpack Datastore it’s pretty easy to create and maintain UI state inside Preferences. As Preferences are stored in a file, the state survives the widget lifespan and also becomes accessible from any other point of your application.
With this model, it becomes easier than ever to affect the state of the widget from inside the application’s activity or other background services, as we will see later on in this post.
There are some simple setup steps you need to follow:
const val INSPIRE_PREFS_FILE: String = "fileName"
val Context.inspirePreferences: DataStore<Preferences>
by preferencesDataStore(name = INSPIRE_PREFS_FILE)class InspireWidgetStateDefinition : GlanceStateDefinition<Preferences> {
override suspend fun getDataStore(
context: Context, fileKey: String
): DataStore<Preferences> = context.inspirePreferences
override fun getLocation(
context: Context,
fileKey: String,
): File = context.preferencesDataStoreFile(INSPIRE_PREFS_FILE)
}class InspireWidget : GlanceAppWidget() {
override val stateDefinition: GlanceStateDefinition<*> =
InspireWidgetStateDefinition()
}@Composable
fun InspireWidgetContent(
modifier: GlanceModifier = GlanceModifier,
context: Context = LocalContext.current
) {
val preferences = currentState<Preferences>()
// ... render as usual
}Amazing, we know how and what to render on a widget. Let’s solve the last question: when. As already mentioned, widgets have limited time to render and are woken up to do the job. When does that happen? The answer (and responsibility) is up to you.
Glance provides an API to trigger the widget’s render whenever you decide:
InspireWidget().updateAll(appContext)With help of this method now you need to think when your widget is supposed to update. It really depends on the requirements, but for us, it was just a fixed period of time.
The main goal was after a certain period of time, fetch the newest information, update the widget’s state inside preferences, and request a re-render of the widget. And yet again, Jetpack came to the rescue.
If you haven’t heard of WorkManager, all you need to know is that it provides an amazing API to create an application’s background jobs scheduled under a variety of conditions you can use in your favour. Let’s check how it works:
The first step is to create a background worker. As we’ve been working with coroutines, WorkManager provides a specific class which supports them out of the box.
@HiltWorker
class RefreshWidgetWorker @AssistedInject constructor(
@Assisted val appContext: Context,
@Assisted workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
@Inject
lateinit var updateCurrentDealUseCase: UpdateCurrentDealUseCase
@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun doWork(): Result {
val updateResult = updateCurrentDealUseCase(Unit).firstOrNull()
if (updateResult is UseCase.Result.Success) {
InspireWidget().updateAll(appContext)
return Result.success()
}
return Result.failure()
}
}Are you wondering what those fancy decorators (@Assisted, @Inject) do? We’ll talk about them in a later post, so stay in touch to know when it drops!
Now you define what you want to do, you just need to schedule with help of the lib. But here goes an extra tip for your widget.
We found out Glance provides lifecycle callbacks for the installation of a new instance of your widget. So what we did in the end was to schedule the update when the first instance of the widget is placed.
The library also provides a simple way to clean the schedule in case you don’t want it any more. And the same goes for the lifecycle event. We just cleaned up the schedule when the last instance of the widget was removed.
class DealsWorkManager(
private val manager: WorkManager,
) {
companion object {
private const val PERIODIC_UPDATE_TASK_ID = "DealsWorkManager:PERIODIC_UPDATE_TASK_ID"
}
private fun schedulePeriodicSyncTask() {
val workRequest = PeriodicWorkRequestBuilder<RefreshWidgetWorker>(1, TimeUnit.HOURS)
.addTag(PERIODIC_UPDATE_TASK_ID)
.build()
manager.enqueueUniquePeriodicWork(
PERIODIC_UPDATE_TASK_ID,
ExistingPeriodicWorkPolicy.REPLACE,
workRequest
)
}
fun cleanPeriodicSyncTask() {
manager.cancelUniqueWork(PERIODIC_UPDATE_TASK_ID)
}
}@AndroidEntryPoint
class InspireWidgetReceiver : GlanceAppWidgetReceiver() {
@Inject
lateinit var workManager: DealsWorkManager
override val glanceAppWidget: GlanceAppWidget = InspireWidget()
override fun onEnabled(context: Context?) {
super.onEnabled(context)
workManager.schedulePeriodicSyncTask()
}
override fun onDisabled(context: Context?) {
workManager.cleanPeriodicSyncTask()
super.onDisabled(context)
}
}There is a caveat you need to know when working with WorkManager. For some of the hardware providers, you’ll need to request the autostart permission in advance to allow workers to run. You can check more or less how it’s done here and also take this reference on all possible things that can go wrong with android apps.
Let’s recap what we know so far:
The full cycle works like this more or less:
Aside from the periodic schedule we showcased, think on this process for a variety of scenarios, like loading a remote image on demand, reacting to a user action by running some network request and so many others.
That was a lot to cover, and we have a lot more to share from our experience in the widget’s world.
So keep following our adventures in the upcoming posts!
See ya!
iOS localization is a wild ride where device and app locales play by their own rules. But don’t worry, after some chaos, Apple’s settings actually matched our expectations. Of course, only after a few twists and turns [...]