Blog Infos
Author
Published
Topics
,
Author
Published

I paired Glance Widget with Work Manager API to create a feature for my open source project SlimeKTa Medium clone (GitHub). The result was interesting. I want to share my learnings and experience through this article.

 

 

Question: What’s the feature I was trying to build?

I tried to mock Medium’s Daily Read Reminder feature. The user is reminded daily at a particular time to read an article from the category/topic they have subscribed to.

⚒️Complexity level of Implementing this feature

Intermediate: Requires basic knowledge of Jetpack’s WorkManager, DataStore, Jetpack Compose, and Hilt Worker.

I would like to share my experience in the following areas,
  1. Understanding how Glance widget works by building our not-so-fancy UI.
  2. Registering the widget.
  3. Pairing the widget with Work Manager.
  4. Learning about GlanceStateDefinition and updating all instances of the Glance widget.
  5. The needs and steps to create a custom GlanceStateDefinition.
  6. Enqueuing the worker to run periodically.
  7. Bonus 🎁: Adding Material You support for Glance widget.

Let’s dissect how to implement this feature in-depth.

1. Adding required dependencies and understanding how the Glance widget works by building a simple UI.
dependencies {
// Compose UI
// Compse Compiler
// AndroidX core, etc
// WorkManager and Hilt worker (to support DI)
implementation("androidx.work:work-runtime-ktx:2.8.0-alpha02")
implementation("androidx.hilt:hilt-work:1.0.0")
kapt("androidx.hilt:hilt-compiler:1.0.0")
// Hilt Library
implementation("com.google.dagger:hilt-android:2.42")
implementation("com.google.dagger:hilt-compiler:2.42")
// AndroidX Glance
implementation("androidx.glance:glance-appwidget:1.0.0-alpha03")
}

Note: Glance can translate Composables into actual RemoteViews, and it requires Compose to be enabled as it depends on Runtime, Graphics, and Unit UI Compose layers. Still, it’s not directly interoperable with other existing Jetpack Compose UI elements. Learn More.

In short, Glance API has a set of UI elements that looks similar to Jetpack Compose API. If you didn’t get this point, check out the sample below.

  1. Create a Kotlin class, namely “MyWidget” which should extend GlanceAppWidget and override its Content function. Add a new Column composable and import it but make sure you see the import block below.
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.layout.Column
class MyWidget() : GlanceAppWidget() {
@Composable
override fun Content() {
Column(
modifier = GlanceModifier
.fillMaxSize()
) {
// Widget Content
}
}
}
view raw MyWidget.kt hosted with ❤ by GitHub

You may see that the Colum composable is imported from the Glance library. Also, we are not using the regular Modifier from Compose library; instead, we are using the GlanceModifier from the Glance library.

Let’s add a Text composable (again, make sure to get it from the Glance library), and all of its parameters, such as modifier and style, should also be imported from the Glance library.

import androidx.glance.GlanceModifier
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.layout.Column
class MyWidget() : GlanceAppWidget() {
@Composable
override fun Content() {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(R.color.widget_background_color),
) {
Text(
text = "Daily Read", // Header
style = TextStyle(
color = ColorProvider(R.color.widget_text_color),
fontSize = 22.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = "Article Title goes here...",
style = TextStyle(
color = ColorProvider(R.color.widget_text_color),
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
}
}
}
view raw MyWidget.kt hosted with ❤ by GitHub

Initial look of our Daily Read widget made with AndroidX Glance API

 

The UI Feel’s not so fancy? That’s fine🙋‍♀️. Let’s move forward.

2. Register your widget receiver.

Create a Kotlin class, namely “MyWidgetReceiver” which should extend GlanceAppWidgetReceiver and implement its only non-optional member and instantiate your GlanceWidget.

class MyWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MyWidget()
}

You will need to register this receiver in your AndroidManifest.xmlThis gist contains all the necessary code. (You can also refer to the Android Manifest file of SlimeKT for a more robust example).

3. Pairing the widget with Work Manager.
@HiltWorker
internal class DailyReadWorkerTask @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workParams: WorkerParameters,
private val api: ArticleApiService,
private val cache: ArticleDatabaseService,
) : CoroutineWorker(context, workParams) {
override suspend fun doWork(): Result {
val article = api.getFromUsersSubscription().mapToEntity()
return try {
// Update the widget's text content
Result.success()
} catch(e: Exception) {
Result.retry()
}
}
}

There are some edge cases to be verified such as not to repeat the same article if it’s shown once, etc. which I have already handled in the actual project. You may checkout the DailyReadTask file from SlimeKT project for a robust example.

 

The following code snippet is self-explanatory. We have requested our API to get an article from the user’s subscription, and as soon as we get the result, we need to update our Glance widget. Simple isn’t it?.

But wait, there’s a catch! How would you update your widget content from the worker? Don’t worry; we got it covered in the next part.

4. Learning about GlanceStateDefinition and updating the widget.

The Glance API has its state maintainer called GlanceStateDefinition, which utilizes Jetpack Datastore.

Jetpack DataStore is a data storage solution that allows you to store key-value pairs or typed objects with protocol buffers. DataStore uses Kotlin coroutines and Flow to store data asynchronously, consistently, and transactionally. Learn More.

Let’s go back to the MyWidget file and utilize GlanceStateDefinition to update our widget content.

  • First step: Override stateDefinition and instantiate it with PreferencesGlanceStateDefinition. (A class that implements GlanceDefinitionState of type DataStore Preferences, and it comes out of the box from AndroidX Glance library) (Line number 3)
  • Second step: Retrieve the current GlanceDefinitionState (of type DataStore Preferences) inside Content composable function by using currentState<Preferences>() (Line number 7)
  • Third step: Fetch the string stored inside of our DataStore Preferences by passing its unique key and default it to an empty string with the help of Elvis operator. (Line number 8)
class MyWidget() : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
@Composable
override fun Content() {
val state = currentState<Preferences>()
val articleTitle = state[stringPreferencesKey("article_title_key")] ?: ""
Text(text = articletitle)
}
}
view raw MyWidget.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

Now we can update our widget by updating the string value in Datastore Preferences. For that, we need to access the Datastore instance. Glance library provides us with a public function updateAppWidgetState which takes Glance ID (a GlanceAppWidget instance) as a parameter.

Let’s create a small utility function that updates our widget after retrieving all the available Glance IDs.

suspend fun updateWidget(articleTitle: String, context: Context) {
// Iterate through all the available glance id's.
GlanceAppWidgetManager(context).getGlanceIds(MyWidget::class.java).forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs ->
prefs[stringPreferencesKey("article_title_key")] = articleTitle //new value
}
}
MyWidget().updateAll(context)
}
view raw UpdateWidget.kt hosted with ❤ by GitHub

And Viola! We can call this function inside our worker and update the widget’s content.

override suspend fun doWork(): Result {
val article = api.getFromUsersSubscription().mapToEntity()
return try {
// Update the widget's text content.
// suspending function
updateWidget(article.title, context)
Result.success()
} catch(e: Exception) {
Result.retry()
}
}

 

But wait, I discovered a new issue 😲

 

When we create a new instance of the same widget, the content disappears! Don’t worry; we got it covered too in the next part.

5. The needs and steps to create a custom GlanceStateDefinition.

I asked Marcel (Developer Relations Engineer at Google) why the content disappears upon creating a new instance of the same widget while primarily Datastore is known to persist the data? He clarified that on every new instance of the widget, a brand new preference (Datastore) file is created, which is why the content is initially null. He further guided me that I should make my implementation of GlanceStateDefinition and share the same preference file. (to avoid the issue)

Let’s have a glance at PreferencesGlanceStateDefinition (no pun intended). It is a class that implements GlanceDefinitionState of type DataStore Preferences, and it comes out of the box from the AndroidX Glance library.

You may see that a new file is created with the suffix .preferences_pb, and the prefix is the fileKey which probably changes on every new widget instance.

Again creating a custom GlanceDefinitionState that shares the same preferences file is pretty simple.

  • Step 1: Extend your CustomState object class with the GlanceDefinitionState of type DataStore Preferences.
  • Step 2: In the getDataStore method, avoid the creation of a new preference file, i.e., create a file with an immutable/fixed name.
  • Step 3: Last but not least, return the location of the preference file.
object CustomGlanceStateDefinition : GlanceStateDefinition<Preferences> {
override suspend fun getDataStore(context: Context, fileKey: String): DataStore<Preferences> {
return context.dataStore
}
override fun getLocation(context: Context, fileKey: String): File {
// Note: The Datastore Preference file resides is in the context.applicationContext.filesDir + "datastore/"
return File(context.applicationContext.filesDir, "datastore/$fileName")
}
private const val fileName = "widget_store"
private val Context.dataStore: DataStore<Preferences>
by preferencesDataStore(name = fileName)
}

Now you can use your Custom GlanceStateDefinition instead of the one provided by the library.

 

 

Note: If you have multiple Glance widgets, you should consider passing the preference file name in the constructor (of your custom state definition) to have separate preference files to avoid issues. If you still want to use a single preference file across multiple Glance widgets, make sure that the key of the preferences should not be the same.

6. Enqueuing the DailyReadWorker to run periodically.

If you have used the Work Manager library, you may know that we can perform a specific work one time or periodically. In this use case, we would need to enqueue a periodic worker that repeats after 24 hours.

// Call on Application Start
fun execute() = enqueueWorker()
private fun enqueueWorker() {
workManager.enqueueUniquePeriodicWork(
"daily_read_worker_tag",
// KEEP documentation:
// If there is existing pending (uncompleted) work with the same unique name, do nothing.
// Otherwise, insert the newly-specified work.
ExistingPeriodicWorkPolicy.KEEP,
buildRequest()
)
}
private fun buildRequest(): PeriodicWorkRequest {
// 1 day
return PeriodicWorkRequestBuilder<DailyReadWorkerTask>(24, TimeUnit.HOURS)
.addTag("daily_read_worker_tag")
.setConstraints(
Constraints.Builder()
// Network required
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
}

You may checkout the DailyReadManager file from the SlimeKT project for a robust example.

 

7. Adding Material You Support (Android 12+)

Your widget background and text color can be adapted to Dynamic colors by adding a background modifier to Glance composable that accepts a color resource. You can create a color resource file inside res/colors.

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?android:attr/colorAccent"/>
</selector>

Checkout: https://issuetracker.google.com/issues/213737775

Note: Wrapping up your Glance widgets inside of Jetpack Compose MaterialTheme composable won’t have any effect and is discouraged. Marcel Pintó has clarified more common doubts in his article, Demystifying Jetpack Glance for app widgets. Make sure to check it out.

That’s it. 🙋‍♂️ If you have any queries, feel free to reach me on Twitter. I would be more than happy to help you!

Special thanks to Marcel Pintó and Gabor Varadi for the guidance and proofreading of this article. Make sure to follow them on Twitter.

You can refer to this pull request for more information: https://github.com/kasem-sm/SlimeKT/pull/148

👍 A clap for this article would be glanceful, oops! Graceful. Thank You!

Thanks toMarcel Pintó

 

This article was originally published on proandroiddev.com on June 04, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
With the introduction to Compose Google changed the way we wrote UIs in android.…
READ MORE
blog
Hey Android Devs, in this article we will go through a step by step…
READ MORE
blog
Between platform changes, new rules, and Android customizations by OEMs, scheduling long-running background jobs…
READ MORE
blog
In this article, we will learn about WorkManager best practices including performant component initialization…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu