Blog Infos
Author
Published
Topics
, , , ,
Published

This the next in my series of blog posts all about widgets. Check out Widgets with Glance: Blending in and Widgets with Glance: Standing out for some Widget UI tricks and tips.

Image created by ChatGPT.

I have recently been working on an app (Pay Day: Earnings Time Tracker) that includes a lot of widgets that show different types of data, but very quickly I came across a problem. The standard way of passing data to a widget uses PreferencesGlanceStateDefinition to manage the state. The way of setting state is using key & value pairs where the values are always strings. In my app I also needed enums & float values and was constantly converting to and from strings for many different data arguments and many different widget implementations. This became hard to manage and hard to read and a reusable and type safe solution was required.

I had read about using a CustomGlanceStateDefinition but I couldn’t find much about it in the official documentation so here is my deep dive to hopefully help anyone else struggling with managing complex GlanceWidget state!

Basic widget state

For the purposes of this article I have used a simpler example that just displays a text quote. While this example probably could get away with just using the string based values, adding some structure to the model can enable better loading and error states.

The starting point just sets a topic and quote as strings:

class QuoteWidget : GlanceAppWidget(errorUiLayout = R.layout.widget_error_layout) {
companion object {
val KEY_TOPIC = stringPreferencesKey("topic")
val KEY_QUOTE = stringPreferencesKey("quote")
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// Fetch the state
val displayText = currentState(KEY_QUOTE) ?: "Quote not found"
val topic = currentState(KEY_TOPIC) ?: ""
// Use the state
...
}
}
}
view raw QuoteWidget.kt hosted with ❤ by GitHub
class QuoteWidgetWorker(...) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
...
appWidgetManager.getGlanceIds(QuoteWidget::class.java).forEach { glanceId ->
...
// Update the widget with the new state
updateAppWidgetState(context, glanceId) { prefs ->
prefs[KEY_QUOTE] = newQuote?.text ?: "Quote not found"
prefs[KEY_TOPIC] = topicName
}
// Let the widget know there is a new state so it updates the UI
QuoteWidget().update(context, glanceId)
}
return Result.success()
}
}

CoroutineWorker is used to update the state periodically. You could use any method of setting the widget state, the same principles apply.

A custom state model with Json Serialization

So this works well if the state is fairly straightforward and is just represented as simple strings, but what if we want a more complex model?

My first attempt to use a more complex model, I started by serializing the model to Json.

Using my QuoteWidget example, a better model might be:

data class WidgetState(
val topicName: String,
val quote: Quote,
)
data class Quote(
val text: String
)
view raw WidgetState.kt hosted with ❤ by GitHub

Then, we can serialize the model as Json and then use that as the string value in the widget.

The first step is to use kotlinx.serialization to serialize the data model:

@Serializable
data class WidgetState(
val topicName: String,
val quote: Quote,
)
@Serializable
data class Quote(
val text: String
)
view raw WidgetState.kt hosted with ❤ by GitHub

Then, we can use kotlinx.serialization.json to encode and decode the model to a string when writing and reading from the state object:

class QuoteWidget : GlanceAppWidget(errorUiLayout = R.layout.widget_error_layout) {
companion object {
val KEY_STATE = stringPreferencesKey("state")
}
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// Fetch the state
val state = currentState(KEY_STATE)
val item = try {
state?.let {
Json.decodeFromString<WidgetState>(state)
}
} catch (e: Exception) {
null
}
// Use the state
...
}
}
}
view raw QuoteWidget.kt hosted with ❤ by GitHub
class QuoteWidgetWorker(...) : CoroutineWorker(context, params) {
...
// Update the widget with the new state
updateAppWidgetState(context, glanceId) { prefs ->
val newState = WidgetState(...)
prefs[KEY_STATE] = Json.encodeToString(newState)
}
...
}

This is pretty good, we can easily fetch and save the model as long as it serializes well. We do have to handle any encoding or decoding errors and respond as needed.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Building Modern Responsive Widgets with Jetpack Glance

In this lightning talk, we’ll explore how to get started with building responsive widgets using Jetpack Glance, a modern toolkit designed to simplify and enhance the widget development experience on Android.
Watch Video

Building Modern Responsive Widgets with Jetpack Glance

Jonathan Petit-Frere
Software Engineer,
Android at Netflix

Building Modern Responsive Widgets with Jetpack Glance

Jonathan Petit-Fre ...
Software Engineer,
Android at Netflix

Building Modern Responsive Widgets with Jetpack Glance

Jonathan Petit-F ...
Software Engineer,
Android at Netflix

Jobs

Creating a custom GlanceStateDefinition

But what if we want a different method of serialization? Or a different storage location (rather than the default preferences file). The documentation discusses the standard way of saving and fetching states, but there is not much information on using a custom model instead of strings. There is an example in the platform-samples Github repository that includes a breif implementation, I am expanding on this here.

In order to be able to handle different widget states, I have extended my model to be a sealed interface and include different implementations for various states:

@Serializable
sealed interface WidgetState {
@Serializable
data object Loading : WidgetState
@Serializable
data class Available(
val topicName: String,
val quote: Quote,
) : WidgetState
@Serializable
data class Unavailable(val message: String) : WidgetState
}
view raw WidgetState.kt hosted with ❤ by GitHub

The next step is to create a custom GlanceStateDefinition , this gives us several advantages:

  1. We can set a custom serializer, in this case I am using kotlinx.serialization as in the previous step, but you could use whatever works in readFrom and writeTo for your existing architecture.
    Errors in serialization can be handled here.
  2. We can specify the DataStore file location. In this case I am using a new file for every widget by using the fileKey as part of the DataStore file location name (see getLocation in the below example code). If you only have only one state for all of your widgets of a specific type and want them all to rely on the same file, you can set the file location as a static value (this is the approach used by the platform-samples example).
    If you are using a different DataStore for each widget you need to create a new one with a DataStoreFactory, otherwise the DataStore can be created as a variable at the top level of the GlanceStateDefinition (as is done in platform-samples)
  3. You can create a specific DataStore implementation. The example I am using creates a standard androidx.datastore.core.DataStore with a custom Serializer but you could instead use a database or protobuf storage implementation instead depending on your usecase. I have not found many examples of this other than this StackOverflow answer where the user has created a database backed version of the DataStore.
    This could be a good option if for some reason you do not want to make your model serializable or if your data object is too large to store in a DataStore string based file.
  4. The final advantage is allowing us to abstract the implementation of the widget state away from the rest of the widget implementation. If the data source or serialization method changes, then this can be adjusted in one place without affecting the rest of the implementation.

The GlanceStateDefinition is implemented as follows:

object QuoteWidgetStateDefinition : GlanceStateDefinition<WidgetState> {
private const val DATA_STORE_FILENAME_PREFIX = "quote_widget_state_"
/**
* Use the same file name regardless of the widget instance to share data between them
* If you need different state/data for each instance, create a store using the provided fileKey
*/
override suspend fun getDataStore(context: Context, fileKey: String) = DataStoreFactory.create(
serializer = WidgetStateSerializer,
produceFile = { getLocation(context, fileKey) }
)
override fun getLocation(context: Context, fileKey: String) =
context.dataStoreFile(DATA_STORE_FILENAME_PREFIX + fileKey.lowercase())
/**
* Custom serializer for RateInfo using Json.
*/
object WidgetStateSerializer : Serializer<WidgetState> {
override val defaultValue = WidgetState.Unavailable("Quote not found")
override suspend fun readFrom(input: InputStream): WidgetState = try {
Json.decodeFromString(
WidgetState.serializer(),
input.readBytes().decodeToString()
)
} catch (exception: SerializationException) {
throw CorruptionException("Could not read widget state: ${exception.message}")
}
override suspend fun writeTo(t: WidgetState, output: OutputStream) {
output.use {
it.write(
Json.encodeToString(WidgetState.serializer(), t).encodeToByteArray()
)
}
}
}
}
Using the custom GlanceStateDefinition

We then need to override the stateDefinition in the GlanceAppWidget class so that the widget implementation uses that instead of the default PreferencesGlanceStateDefinition. This is as simple as:

override val stateDefinition = QuoteWidgetStateDefinition

Following that, every instance of updateAppWidgetState needs to set the definition argument:

updateAppWidgetState(... definition = QuoteWidgetStateDefinition,...)

We can see this in more detail in the examples below:

Fetching the custom state

In this code snippit we are setting the stateDefinition and when reading the current state, specifying the type we are expecting (WidgetState in this case). From here I can detect which class implementation is used and select the right composable to display. It’s type safe and much more readable than using key-value pairs or Json serialization.

class QuoteWidget : GlanceAppWidget(errorUiLayout = R.layout.widget_error_layout) {
override val stateDefinition = QuoteWidgetStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// Fetch the state
val widgetState = currentState<WidgetState>()
when (widgetState) {
is WidgetState.Available -> QuoteWidgetContent(
displayText = widgetState.quote.text,
topic = widgetState.topicName,
)
WidgetState.Loading -> QuoteWidgetLoading()
is WidgetState.Unavailable -> QuoteWidgetError(
message = widgetState.message,
)
}
// Use the state
...
}
}
}
view raw QuoteWidget.kt hosted with ❤ by GitHub
Saving the custom state

When saving the state the updateAppWidgetState function call is updated to include the definition argument, and then the updateState lambda just needs to return the right type, no serialization is need at this point — this is all done by the custom GlanceStateDefinition . Again, it is type safe, and allows us more freedom to set loading and error behaviours.

class QuoteWidgetWorker(...) : CoroutineWorker(context, params) {
...
// Update the widget with the new state
appWidgetManager.getGlanceIds(QuoteWidget::class.java).forEach { glanceId ->
val currentState: WidgetState =
getAppWidgetState(context, QuoteWidgetStateDefinition, glanceId)
// Set the loading state
updateAppWidgetState(
context = context,
definition = QuoteWidgetStateDefinition,
glanceId = glanceId,
updateState = {
WidgetState.Loading
}
)
QuoteWidget().update(context, glanceId)
// Do the work to update the state - generate a new quote
val newQuote = ...
// Update the widget with this new state
updateAppWidgetState(
context = context,
definition = QuoteWidgetStateDefinition,
glanceId = glanceId,
updateState = {
if (newQuote != null) {
// The quote is available, set the successful state
WidgetState.Available(
topicName = currentState.topicName,
quote = Quote(text = newQuote.text)
)
} else {
// Show an error state
WidgetState.Unavailable(message = "Quote not found")
}
}
)
// Let the widget know there is a new state so it updates the UI
QuoteWidget().update(context, glanceId)
}
...
}

So there it is, a custom GlanceStateDefinition, the data model can be as complex or as large as you like as long as you can either serialize it or store it. The code is more readable and state management is easy no matter how many widgets you have!

To see a full example, see my sample widget app:

For more widget blog posts, check out the others in my Widgets with glance series:
Widgets with Glance: Blending in
Widgets with Glance: Standing out

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Using annotations in Kotlin has some nuances that are useful to know
READ MORE
blog
One of the latest trends in UI design is blurring the background content behind the foreground elements. This creates a sense of depth, transparency, and focus,…
READ MORE
blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
The suspension capability is the most essential feature upon which all other Kotlin Coroutines…
READ MORE
Menu