Widgets live on the home screen. They show information at a glance — weather, tasks, calendar events, music controls. Users love them because they don’t have to open the app.
Building widgets used to mean writing XML RemoteViews. It was painful. Jetpack Glance changed this. Glance lets you build widgets with a Compose-like API.
In this tutorial, you will learn how to build widgets with Glance — from a simple text widget to a task list widget that loads data from Room.
Prerequisites: You should know Compose basics. Glance uses similar concepts —
Column,Row,Text,Button— but it is a separate API. Check the Jetpack Compose tutorial series if needed.
Glance vs RemoteViews
| Feature | RemoteViews (Old) | Glance (New) |
|---|---|---|
| Syntax | XML layouts | Compose-like Kotlin |
| State handling | Manual RemoteViews updates | GlanceStateDefinition |
| Theming | Manual colors | GlanceTheme with Material |
| Click handling | PendingIntent | actionRunCallback |
| Learning curve | Steep | Easy if you know Compose |
Important: Glance is not regular Compose. It translates your code to RemoteViews behind the scenes. Not all Compose features work (no LazyColumn scrolling the same way, no animations, limited modifiers).
Setting Up Glance
Add the dependency to your build.gradle.kts:
dependencies {
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.glance:glance-material3:1.1.1")
}
Building Your First Widget
A Glance widget has three parts:
- GlanceAppWidget — defines what the widget shows
- GlanceAppWidgetReceiver — connects the widget to the system
- Widget metadata XML — defines size, preview, and update frequency
Step 1: Create the Widget
class SimpleWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
SimpleWidgetContent()
}
}
}
}
@Composable
fun SimpleWidgetContent() {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(16.dp)
.cornerRadius(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Hello, Widget!",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = GlanceModifier.height(8.dp))
Text(
text = "This is a Glance widget",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 14.sp
)
)
}
}
Step 2: Create the Receiver
class SimpleWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = SimpleWidget()
}
Step 3: Widget Metadata
Create res/xml/simple_widget_info.xml:
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen"
android:description="@string/widget_description"
android:previewLayout="@layout/widget_preview" />
Key attributes:
targetCellWidth/Height— the default widget size in grid cellsminWidth/minHeight— minimum size for older launchersupdatePeriodMillis— how often the system triggers an update (minimum 30 minutes)resizeMode— allows the user to resize the widget
Step 4: Declare in Manifest
<receiver
android:name=".widget.SimpleWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/simple_widget_info" />
</receiver>
Glance Composables
Glance provides its own set of composables. They look like regular Compose but come from androidx.glance.appwidget.
Available Composables
| Glance | Regular Compose Equivalent |
|---|---|
Column | Column |
Row | Row |
Box | Box |
Text | Text |
Image | Image |
Button | Button |
LazyColumn | LazyColumn (limited) |
Spacer | Spacer |
CircularProgressIndicator | CircularProgressIndicator |
CheckBox | Checkbox |
Switch | Switch |
Important differences:
- Use
GlanceModifierinstead ofModifier - Styling uses
TextStyleinstead ofMaterialTheme.typography - Colors come from
GlanceTheme.colors - No
remember, nomutableStateOf— state works differently in Glance
Click Actions
Widgets respond to clicks using actions. Glance provides several action types.
Open the App
@Composable
fun ClickableWidget() {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(16.dp)
.clickable(
actionStartActivity<MainActivity>()
)
) {
Text(
text = "Tap to open app",
style = TextStyle(color = GlanceTheme.colors.onSurface)
)
}
}
Open a Deep Link
Button(
text = "View Details",
onClick = actionStartActivity(
Intent(Intent.ACTION_VIEW, "myapp://product/123".toUri())
)
)
Run a Callback
For actions that update the widget (like toggling a task):
class ToggleTaskAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val taskId = parameters[TaskIdKey] ?: return
// Toggle the task in the database
val repository = TaskRepository(context)
repository.toggleTask(taskId)
// Update the widget
TaskListWidget().update(context, glanceId)
}
companion object {
val TaskIdKey = ActionParameters.Key<Long>("task_id")
}
}
Use it in the widget:
CheckBox(
checked = task.isCompleted,
onCheckedChange = actionRunCallback<ToggleTaskAction>(
parameters = actionParametersOf(
ToggleTaskAction.TaskIdKey to task.id
)
),
text = task.title
)
Widget State with GlanceStateDefinition
Glance provides a state system to store and retrieve widget data.
Using Preferences State
class TaskWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<Preferences>()
val taskCount = prefs[intPreferencesKey("task_count")] ?: 0
GlanceTheme {
TaskWidgetContent(taskCount = taskCount)
}
}
}
}
@Composable
fun TaskWidgetContent(taskCount: Int) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(16.dp)
.cornerRadius(16.dp)
) {
Text(
text = "Tasks",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = GlanceModifier.height(8.dp))
Text(
text = "$taskCount remaining",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 14.sp
)
)
}
}
Updating State
suspend fun updateTaskCount(context: Context) {
val repository = TaskRepository(context)
val count = repository.getIncompleteCount()
// Update all widget instances
val manager = GlanceAppWidgetManager(context)
val glanceIds = manager.getGlanceIds(TaskWidget::class.java)
glanceIds.forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs ->
prefs[intPreferencesKey("task_count")] = count
}
TaskWidget().update(context, glanceId)
}
}
Task List Widget — Complete Example
Here is a full task list widget that shows the top 5 tasks from a Room database:
class TaskListWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val repository = TaskRepository(context)
val tasks = repository.getTopTasks(limit = 5)
provideContent {
GlanceTheme {
TaskListContent(tasks = tasks)
}
}
}
}
@Composable
fun TaskListContent(tasks: List<Task>) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(12.dp)
.cornerRadius(16.dp)
) {
// Header
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "My Tasks",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
),
modifier = GlanceModifier.defaultWeight()
)
Image(
provider = ImageProvider(R.drawable.ic_add),
contentDescription = "Add task",
modifier = GlanceModifier
.size(24.dp)
.clickable(
actionStartActivity<MainActivity>(
actionParametersOf(
ActionParameters.Key<String>("navigate_to") to "add_task"
)
)
)
)
}
Spacer(modifier = GlanceModifier.height(8.dp))
if (tasks.isEmpty()) {
Text(
text = "No tasks. Tap + to add one.",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 14.sp
)
)
} else {
// Task list
tasks.forEach { task ->
TaskItem(task = task)
Spacer(modifier = GlanceModifier.height(4.dp))
}
}
Spacer(modifier = GlanceModifier.defaultWeight())
// Footer
Text(
text = "${tasks.count { !it.isCompleted }} remaining",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp
),
modifier = GlanceModifier.fillMaxWidth()
)
}
}
@Composable
fun TaskItem(task: Task) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
CheckBox(
checked = task.isCompleted,
onCheckedChange = actionRunCallback<ToggleTaskAction>(
parameters = actionParametersOf(
ToggleTaskAction.TaskIdKey to task.id
)
)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text(
text = task.title,
style = TextStyle(
color = if (task.isCompleted) {
GlanceTheme.colors.onSurfaceVariant
} else {
GlanceTheme.colors.onSurface
},
fontSize = 14.sp,
textDecoration = if (task.isCompleted) {
TextDecoration.LineThrough
} else {
TextDecoration.None
}
),
maxLines = 1
)
}
}
The Receiver
class TaskListWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TaskListWidget()
}
Manifest and Metadata
<!-- AndroidManifest.xml -->
<receiver
android:name=".widget.TaskListWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/task_list_widget_info" />
</receiver>
<!-- res/xml/task_list_widget_info.xml -->
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp"
android:minHeight="180dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="3"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:description="@string/task_widget_description" />
Responsive Layouts with SizeMode
Widgets can be resized by the user. Use SizeMode to adapt your layout:
class ResponsiveWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Responsive(
setOf(
DpSize(120.dp, 120.dp), // Small
DpSize(250.dp, 120.dp), // Wide
DpSize(250.dp, 250.dp) // Large
)
)
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val size = LocalSize.current
GlanceTheme {
when {
size.width < 200.dp -> CompactLayout()
size.height < 200.dp -> WideLayout()
else -> FullLayout()
}
}
}
}
}
@Composable
fun CompactLayout() {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(8.dp)
.cornerRadius(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "3",
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = "tasks left",
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp
)
)
}
}
@Composable
fun WideLayout() {
Row(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(12.dp)
.cornerRadius(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "3 tasks remaining",
style = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 16.sp
),
modifier = GlanceModifier.defaultWeight()
)
Button(
text = "Open",
onClick = actionStartActivity<MainActivity>()
)
}
}
@Composable
fun FullLayout() {
// Full task list layout (similar to TaskListContent above)
}
Updating Widgets
Widgets need to be updated when data changes. You have several options:
From a Coroutine (after database change)
suspend fun refreshTaskWidgets(context: Context) {
TaskListWidget().updateAll(context)
}
From WorkManager (periodic refresh)
class WidgetRefreshWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
TaskListWidget().updateAll(applicationContext)
return Result.success()
}
}
// Schedule periodic refresh
fun scheduleWidgetRefresh(context: Context) {
val request = PeriodicWorkRequestBuilder<WidgetRefreshWorker>(
30, TimeUnit.MINUTES
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"widget_refresh",
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
From a BroadcastReceiver
Override onReceive in your widget receiver:
class TaskListWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TaskListWidget()
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == "com.example.TASK_UPDATED") {
goAsync {
glanceAppWidget.updateAll(context)
}
}
}
}
Weather Widget Example
A practical weather widget showing current temperature and conditions:
class WeatherWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val weather = WeatherRepository(context).getCurrentWeather()
provideContent {
GlanceTheme {
WeatherWidgetContent(weather = weather)
}
}
}
}
@Composable
fun WeatherWidgetContent(weather: Weather?) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.primaryContainer)
.padding(16.dp)
.cornerRadius(16.dp)
.clickable(actionStartActivity<MainActivity>()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically
) {
if (weather != null) {
Image(
provider = ImageProvider(weather.iconRes),
contentDescription = weather.condition,
modifier = GlanceModifier.size(48.dp)
)
Spacer(modifier = GlanceModifier.height(8.dp))
Text(
text = "${weather.temperature}°",
style = TextStyle(
color = GlanceTheme.colors.onPrimaryContainer,
fontSize = 36.sp,
fontWeight = FontWeight.Bold
)
)
Text(
text = weather.condition,
style = TextStyle(
color = GlanceTheme.colors.onPrimaryContainer,
fontSize = 14.sp
)
)
Text(
text = weather.location,
style = TextStyle(
color = GlanceTheme.colors.onPrimaryContainer,
fontSize = 12.sp
)
)
} else {
CircularProgressIndicator()
Text(
text = "Loading weather...",
style = TextStyle(
color = GlanceTheme.colors.onPrimaryContainer,
fontSize = 14.sp
)
)
}
}
}
data class Weather(
val temperature: Int,
val condition: String,
val location: String,
@DrawableRes val iconRes: Int
)
Common Mistakes
1. Using Regular Compose Instead of Glance
Glance composables come from androidx.glance.*, not androidx.compose.*. If you import the wrong Column or Text, the widget crashes.
2. Forgetting the Initial Layout
The android:initialLayout attribute is required. Use @layout/glance_default_loading_layout provided by the library, or create your own loading layout.
3. Heavy Work in provideGlance
The provideGlance function runs on a background thread, but it should still be fast. Don’t make network calls directly — fetch data beforehand with WorkManager and store it locally.
4. Not Handling Empty State
Widgets should always show something meaningful. If there is no data, show a helpful message instead of a blank widget.
5. Ignoring Widget Size
A widget designed for 4x3 cells looks broken when the user resizes it to 2x1. Use SizeMode.Responsive to provide layouts for different sizes.
What’s Next?
In this tutorial, you learned:
- How Glance provides a Compose-like API for widgets
- Building simple and complex widgets with Glance composables
- Handling click actions with callbacks
- Managing widget state with preferences
- Building a task list widget with Room data
- Creating responsive layouts with SizeMode
- Updating widgets from WorkManager and callbacks
Next up: Android Tutorial #16: App Security — R8, ProGuard, Encrypted Storage, Biometrics — where you will learn how to protect your app’s code and user data.
Related Articles
- Jetpack Compose Tutorial #1-6 — Compose basics (similar to Glance API)
- Android Tutorial #7: Room Database — data source for widgets
- Android Tutorial #9: WorkManager — periodic widget updates
- Android Tutorial #14: Deep Links — opening screens from widget clicks
This is part 15 of the Android Development Tutorial series.