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.
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 | |
... | |
} | |
} | |
} |
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() | |
} | |
} |
A 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 | |
) |
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 | |
) |
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 | |
... | |
} | |
} | |
} |
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
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 | |
} |
The next step is to create a custom GlanceStateDefinition
, this gives us several advantages:
- 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 inreadFrom
andwriteTo
for your existing architecture.
Errors in serialization can be handled here. - 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 theDataStore
file location name (seegetLocation
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 filelocation
as a static value (this is the approach used by the platform-samples example).
If you are using a differentDataStore
for each widget you need to create a new one with aDataStoreFactory
, otherwise theDataStore
can be created as a variable at the top level of theGlanceStateDefinition
(as is done in platform-samples) - You can create a specific DataStore implementation. The example I am using creates a standard
androidx.datastore.core.DataStore
with a customSerializer
but you could instead use a database orprotobuf
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 theDataStore
.
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 aDataStore
string based file. - 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 | |
... | |
} | |
} | |
} |
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.