Compose Glance: How to Create an App Widget in Android with Kotlin

Creating an app widget in Android using Kotlin and Jetpack Compose’s Glance framework is an elegant approach to bringing interactivity and dynamic UI to a user’s home screen. This article breaks down the key components of a CounterWidget app, which demonstrates how to build an increment counter widget. We will cover how to use Glance to structure the UI, manage state using Jetpack’s DataStore, and handle interactions like button clicks. The CounterWidget example is a great starting point for understanding the fundamentals of creating app widgets using modern Android development practices.

In this explanation, we will walk through the code step-by-step, beginning with the widget’s layout and composable functions, followed by how the widget’s state is updated, and how actions such as button clicks are handled. By the end, you'll have a clear understanding of the necessary components involved in creating a fully functional app widget with Kotlin and Glance.

Structure of the CounterWidget

The CounterWidget class extends GlanceAppWidget, which is the core component responsible for rendering the widget's content. It overrides the Content() composable function to define the layout and behavior of the widget. This layout includes a text view displaying the current count and a button to increment the counter.

A Column is used to organize the layout vertically, centering the elements both horizontally and vertically. The background color for the widget is set to a light blue (0xFFD3E9FA), and the GlanceModifier.fillMaxSize() ensures the widget fills the available space. The count is displayed in the Text composable using a large font size (50.sp) and is aligned to the center. The button labeled "Up" allows the user to increment the counter when pressed. Its size is set to 100x50 dp, with a background color of 0xFFB6C0C9.

Managing State with Preferences

One of the key features of this app widget is its ability to retain the counter value across widget updates. To achieve this, Jetpack’s DataStore is used to store the counter state persistently. The countPreferenceKey is defined as a key to access the stored counter value, and it is retrieved using currentState<Preferences>(). If the key is not already present (e.g., the widget is used for the first time), a default value of 0 is provided.

The GlanceStateDefinition is overridden to use PreferencesGlanceStateDefinition, which binds the widget to DataStore’s state management. This allows the counter value to be retrieved and updated in a persistent manner without needing a backend service.

Handling Button Clicks and State Updates

The button's functionality is handled using the actionRunCallback<UpdateActionCallback>(), which triggers an action when the button is pressed. This is where the counter's value is incremented. The parameters passed to the callback include the updated count value (count + 1).

The UpdateActionCallback class implements the ActionCallback interface. The onRun() function, which gets executed when the button is pressed, retrieves the new count from the action parameters. It then updates the widget’s state by calling updateAppWidgetState(). This function writes the new counter value to DataStore by converting the preferences into a mutable form and assigning the updated count. Finally, CounterWidget().update() refreshes the widget's UI to display the new count.

Widget Configuration and Manifest Setup

The widget’s appearance and size are defined in the res/xml/counter_widget_info.xml file. Here, the minWidth and minHeight attributes determine the default size of the widget, while resizeMode allows users to resize it both horizontally and vertically. Additionally, the widget is marked as a home screen widget with android:widgetCategory="home_screen".

In the AndroidManifest.xml, the CounterWidgetReceiver class is declared as a broadcast receiver that listens for app widget update events. It specifies that the receiver handles the APPWIDGET_UPDATE action. This ensures that the system knows when to update the widget, especially after a configuration change or an action-triggered update. The receiver is also linked to the widget’s XML configuration through the <meta-data> tag.

Dependencies

Lastly, in the build.gradle file, the Glance library is added as a dependency with the following line:

gradle

implementation "androidx.glance:glance-appwidget:1.0.0-alpha03"

This includes all the necessary APIs to create, manage, and update app widgets using Jetpack Compose.

Summary

In this article, we explored how to create a simple counter app widget using Kotlin and Jetpack Compose’s Glance framework. The CounterWidget uses a combination of DataStore for state persistence and Glance composables to build an interactive user interface directly on the home screen. We walked through how the UI is structured, how the state is managed and updated, and how interactions like button clicks are handled.

This example demonstrates the potential of using modern Compose tools to build efficient and functional widgets with ease. By utilizing Glance, developers can leverage the power of Jetpack Compose, even outside the app’s main interface, creating a more consistent and enjoyable user experience.


CounterWidget.kt

package com.cfsuman.widgetexamples

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.glance.*
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.layout.*
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider

private val countPreferenceKey = intPreferencesKey("count-key")
private val countParamKey = ActionParameters.Key<Int>("count-key")


class CounterWidget : GlanceAppWidget(){

    override val stateDefinition: GlanceStateDefinition<*> =
        PreferencesGlanceStateDefinition

    @Composable
    override fun Content(){
        val prefs = currentState<Preferences>()
        val count = prefs[countPreferenceKey] ?: 0

        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalAlignment = Alignment.CenterVertically,
            modifier = GlanceModifier
                .background(Color(0xFFD3E9FA))
                .fillMaxSize()
        ) {
            Text(
                text = count.toString(),
                modifier = GlanceModifier.fillMaxWidth(),
                style = TextStyle(
                    textAlign = TextAlign.Center,
                    color = ColorProvider(Color.Blue),
                    fontSize = 50.sp
                )
            )

            Spacer(modifier = GlanceModifier.padding(8.dp))

            Button(
                text = "Up",
                modifier = GlanceModifier
                    .background(Color(0xFFB6C0C9))
                    .size(100.dp,50.dp),
                onClick = actionRunCallback<UpdateActionCallback>(
                    parameters = actionParametersOf(
                        countParamKey to (count + 1)
                    )
                )
            )
        }
    }
}


class UpdateActionCallback : ActionCallback{
    override suspend fun onRun(context: Context, glanceId: GlanceId,
                               parameters: ActionParameters) {

        val count = requireNotNull(parameters[countParamKey])

        updateAppWidgetState(
            context = context,
            definition = PreferencesGlanceStateDefinition,
            glanceId = glanceId
        ){ preferences ->
            preferences.toMutablePreferences()
                .apply {
                    this[countPreferenceKey] = count
                }
        }

        CounterWidget().update(context,glanceId)
    }
}


class CounterWidgetReceiver : GlanceAppWidgetReceiver(){
    override val glanceAppWidget:GlanceAppWidget = CounterWidget()
}
res/xml/counter_widget_info.xml

<appwidget-provider
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name"
    android:minWidth="100dp"
    android:minHeight="50dp"
    android:resizeMode="horizontal|vertical"
    android:targetCellWidth="3"
    android:targetCellHeight="2"
    android:widgetCategory="home_screen"
    />
AndroidManifest.xml [Part]

<receiver
	android:name=".CounterWidgetReceiver"
	android:enabled="@bool/glance_appwidget_available"
	android:exported="false">
	<intent-filter>
		<action android:name
			="android.appwidget.action.APPWIDGET_UPDATE" />
	</intent-filter>
	<meta-data
		android:name="android.appwidget.provider"
		android:resource="@xml/counter_widget_info" />
</receiver>

build.gradle [app] [dependencies]

implementation "androidx.glance:glance-appwidget:1.0.0-alpha03"
More android jetpack compose tutorials