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

FeatureRemoteViews (Old)Glance (New)
SyntaxXML layoutsCompose-like Kotlin
State handlingManual RemoteViews updatesGlanceStateDefinition
ThemingManual colorsGlanceTheme with Material
Click handlingPendingIntentactionRunCallback
Learning curveSteepEasy 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:

  1. GlanceAppWidget — defines what the widget shows
  2. GlanceAppWidgetReceiver — connects the widget to the system
  3. 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 cells
  • minWidth/minHeight — minimum size for older launchers
  • updatePeriodMillis — 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

GlanceRegular Compose Equivalent
ColumnColumn
RowRow
BoxBox
TextText
ImageImage
ButtonButton
LazyColumnLazyColumn (limited)
SpacerSpacer
CircularProgressIndicatorCircularProgressIndicator
CheckBoxCheckbox
SwitchSwitch

Important differences:

  • Use GlanceModifier instead of Modifier
  • Styling uses TextStyle instead of MaterialTheme.typography
  • Colors come from GlanceTheme.colors
  • No remember, no mutableStateOf — 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)
        )
    }
}
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.



This is part 15 of the Android Development Tutorial series.