Skip to content

Movies Widget

Widget Declaration

Widgets allow you to embed small parts of your application into a user's home screen. They can display data from your application, allow the user to interact with it, and act as customized launch points for your application.

Glance is a Jetpack Compose framework for creating and managing widgets, built on top of the existing AppWidget framework. It looks and feels quite a bit like the Compose UIs you've built before, but there's a really important difference: the UI is remote.

Your widget runs in a separate process from your application, inside the user's selected Home application. You can't directly communicate with it, nor can you add your code to that Home application. So how does it work?

In the AppWidget framework, you'd create Remote Views. They defined a limited subset of available UI views, and acted as a proxy for your user interface. The remote views are defined in a Broadcast Receiver that you define, that accepts system broadcasts describing the current state of the widget and whether it wants an update.

Broadcast Receivers are top-level Android Components, like Activities, and are declared in the AndroidManifest.xml file. When a broadcast Intent is sent to them, the system creates an instance of them, passes the Intent to its onReceive() function to perform its action, then the receiver's work is done.

You create a subclass of AppWidgetReceiver (or for Compose, GlanceAppWidgetReceiver) to supply the remote views for the widget. When the user adds or removes a widget for their Home app, or after an amount of time that the widget declaration states it should be updated, a broadcast Intent is sent to your widget receiver. You create or update the remote views which are sent back to the widget framework to render for the user (or remove form the Home app).

This is easier to see as we create a new widget. We'll define a widget that displays our movie list and allows the user to click on one, taking them to that movie in the application.

Setting up the widget

First we need to add the glance dependencies

We'll put our code in a subpackage named com.androidbyexample.movie.glance. To do this, right-click on the com.androidbyexample.movie package and choose New > package

Choose new package option

then add ".glance" at the end and press Enter:

Write name of subpackage

Note

A "subpackage" in Kotlin (or Java) is one where another package has the same prefix. For example, a.b.c is a subpackage of a.b.

Aside from the name, there is no relation between these packages. If you import a.b.*, it doesn't make types from a.b.c available.

Warning

Never use import-on-demand (imports like a.b.*). See Import on Demand is EVIL! for details.

Info

Nested boxes are cool. So are Fezzes and Bowties.

Fezzes are cool

(I'm in a mood today...)

Now we create some new code in that package.

The MovieAppWidget class defines your user interface for the widget. We'll just create a Text for it.

Caution

The composable functions for Glance, like Text have the same names as the ones we've been using, but are in a different package. In this case, the Glance Text is androidx.glance.text.Text. Be careful when adding them; be sure to choose the one from the Glance package.

Now the MovieAppWidgetReceiver. This is the broadcast receiver that receives the system broadcasts about the widget to add/remove/update it. All we need to do is define its glanceAppWidget property.

Note

We won't be doing anything fancy with widgets (and there's much more you can do). If you want to do more, see Jetpack Glance and App widgets overview.

We need to describe to Android how our widget should behave when placed into a Home app, including sizing information and how often to explicitly update it. This information is specified in app/src/main/res/xml/movie_app_widget_info.xml.

There are many more options you can specify in the widget info file.

We attach the widget info when registering our receiver in app/src/main/AndroidManifest.xml. The manifest is our way of telling Android which top-level components we've defined in our application. So far, we've only had a single Activity in there and haven't really talked about it.

When an app is installed, Android looks at the manifest to determine what's available. The <intent-filter>s specify which Intents this application can handle, and which components to send those intents to. Any application can send intents to start activities or services, and send messages to broadcast receivers. (There are some limitations but we won't go into that detail here).

A little more on intents and filters

Let's take a quick diversion to explain in a bit more detail how Intents and intent-filters work.

When the system sees

<intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

The MAIN/LAUNCHER combination of action and category is special - it specifies an Activity as an entry point to the application that can be listed in the Home app to start the application.

If we had something like (untested - I haven't done this in a while...)

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <data android:scheme="https" />
    <data android:scheme="http" />
    <category android:name="android.intent.category.BROWSABLE" />
    <category android:name="android.intent.category.DEFAULT" />
</intent-filter>

You'd be telling android that you can handle requests to view a URI that starts with "http" or "https" - something like this might be used if you were creating a web browser application. If another application sent an implicit intent that said "hey Android, I want to view https://androidbyexample.com", your application would be a candidate to handle it.

If only one app is installed that has an intent filter that matches, that application is used to perform the request. If more than one app is installed that can handle it, the user is asked which to use.

An application can send an explicit intent, which indicates which application and activity/service should handle it. We'll see this later when we talk about services.

Using the Widget

To use the widget we need to install the application. Note that we don't need to actually run the application from Android Studio, so if you'd prefer, you can edit the application launcher setting by clicking the "..." next to the app launcher on the Android Studio toolbar and choosing "Edit":

Editing the launcher

And choose "Nothing" in the Launch dropdown:

Editing the launcher

You don't need to do this, but if you don't, you'll need to return to the home screen when the application opens so you can add the widget.

To set up the widget:

Add and remove the widget

  1. Run the application to install it.
  2. If you didn't change the launcher to not launch the app, go to the home screen. You can do this by clicking the "O" icon on the emulator toolbar, or swiping up from the bottom of the emulator or device and quickly releasing. The gesture can be tricky to use on the emulator, so I recommend the toolbar.
  3. Long press on an empty spot on the home screen. Note that this might be different based on the Home app that you're using.
  4. Choose Widgets
  5. Scroll down to the MovieWidget app
  6. Long press on the widget to add to the home screen (some apps define multiple widgets)
  7. Drag the widget to the homescreen position you'd like and release
  8. To remove, long press on the widget and drag to "Remove". Note that this might be different based on the Home app that you're using.

Code Changes

CHANGED: /app/build.gradle.kts
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
    alias(libs.plugins.androidApplication)
    alias(libs.plugins.kotlinAndroid)
}

kotlin {
    jvmToolchain(17)
}

android {
    namespace = "com.androidbyexample.movie"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.androidbyexample.moviewidget"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary = true
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.3"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

dependencies {
    implementation(project(":repository"))

implementation(libs.glance.appwidget) implementation(libs.glance.material3)
// implementation(libs.icons.extended) implementation(libs.lifecycle.compose) implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) implementation(libs.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.ui) implementation(libs.ui.graphics) implementation(libs.ui.tooling.preview) implementation(libs.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.ui.test.junit4) debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) }
CHANGED: /app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MovieUi1"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:theme="@style/Theme.MovieUi1">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>                <action android:name="android.intent.action.VIEW" />                <data android:scheme="https" />                <data android:scheme="http" />                <category android:name="android.intent.category.BROWSABLE" />                <category android:name="android.intent.category.DEFAULT" />            </intent-filter>        </activity>
<receiver android:name=".glance.MovieAppWidgetReceiver" 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/movie_app_widget_info" /> </receiver>
</application> </manifest>
ADDED: /app/src/main/java/com/androidbyexample/movie/glance/MovieAppWidget.kt
package com.androidbyexample.movie.glanceimport android.content.Contextimport androidx.glance.GlanceIdimport androidx.glance.appwidget.GlanceAppWidgetimport androidx.glance.appwidget.provideContentimport androidx.glance.text.Text
class MovieAppWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { provideContent { Text(text = "Widget!") } }}
ADDED: /app/src/main/java/com/androidbyexample/movie/glance/MovieAppWidgetReceiver.kt
package com.androidbyexample.movie.glanceimport androidx.glance.appwidget.GlanceAppWidgetimport androidx.glance.appwidget.GlanceAppWidgetReceiver
class MovieAppWidgetReceiver: GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = MovieAppWidget()}
ADDED: /app/src/main/res/xml/movie_app_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" />
CHANGED: /gradle/libs.versions.toml
[versions]
agp = "8.2.0-beta06"
kotlin = "1.9.10"
core-ktx = "1.12.0"
junit = "4.13.2"
androidx-test-ext-junit = "1.1.5"
espresso-core = "3.5.1"
lifecycle-runtime-ktx = "2.6.2"
activity-compose = "1.7.2"
lifecycle-compose = "2.6.2"
compose-bom = "2023.09.02"
appcompat = "1.6.1"
material = "1.9.0"
room = "2.5.2"
ksp = "1.9.10-1.0.13"
icons-extended = "1.6.0-alpha06"
glance = "1.0.0"
[libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } material3 = { group = "androidx.compose.material3", name = "material3" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } icons-extended = { group = "androidx.compose.material", name = "material-icons-extended-android", version.ref = "icons-extended"}
glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
[plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } androidLibrary = { id = "com.android.library", version.ref = "agp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }