As of December 2021 Google announced the alpha version of Jetpack Glance library for making Widgets with Jetpack Compose. This makes it much faster and easier to make App Widgets on Android.

In order to use Glance for making widgets, your module needs to add the dependencies for Compose and for Glance. The current version as of writing this is alpha-03.

dependencies {
  // For AppWidgets support
  implementation("androidx.glance:glance-appwidget:1.0.0-alpha03")
}

android {
  buildFeatures {
    compose = true
  }

  composeOptions {
    kotlinCompilerExtensionVersion = "1.1.0-beta03"
  }

  kotlinOptions {
    jvmTarget = "1.8"
  }
}

What are we making?

We have a design for a simple widget that was not yet implemented in our app.

Design no workouts Design with workouts

Here the widget is supposed to be resizable within its bounds. Clicking on the QR code should open the QR screen in the app. Furthermore, tapping the SATS S should just open the app, and tapping the workout, should open page with the specified workout.

Design QR Screen Design Gx Specific

But how?

After adding the dependency in a module like above, we started by adding our initial setup. This is very similar to a default Widget implementation in the ‘view system’. We need that because Glance only provides its own set of Composables that is converted automatically to Remote Views behind the scene.

We started by adding a widgets.xml file. This file is going to contain information about you widget like min required bounds, how it can be resized and etc. You can add one in the v31 folder as well if you want something special for API 31 and above. In API 31 and above, the targetCellWidth and targetCellHeight is available.

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:minHeight="80dp"
  android:minWidth="80dp"
  android:description="@string/qr_code_widget_description"
  android:initialLayout="@layout/widget_loading"
  android:previewLayout="@layout/widget_preview"
  android:previewImage="@drawable/ic_sats"
  android:targetCellHeight="2"
  android:targetCellWidth="3"
  android:resizeMode="horizontal|vertical"
  android:widgetCategory="home_screen"/>

This file does a lot of nice things. Some of what it does is to define our loading widget and our preview. The loading widget is shown for when the widget needs to completely reload or when it’s initialized. The preview is used for showing the user before they add the widget, how it’s going to look. These files are completely normal layout files and can be shaped however you like.

When you have defined these files, you should remember to add them as metadata in your manifest file.

<meta-data
  android:name="android.appwidget.provider"
  android:resource="@xml/widget_qr" />

Next we defined our widget receiver. This is a class that is going to get data from our data layer and pass it on to our widget. This class has to extend GlanceAppWidgetReceiver(). In our application we are using Hilt as a dependency injection (DI) framework. This makes it very easy to just inject our data layer reference and use it in our receiver. But remember to annotate it with @AndroidEntryPoint if you’re using Hilt as well.

@AndroidEntryPoint
class SatsWidgetReceiver : GlanceAppWidgetReceiver() {

  @Inject
  lateinit var repository: Repository

  override val glanceAppWidget: GlanceAppWidget = SatsWidget()

  override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
    super.onUpdate(context, appWidgetManager, appWidgetIds)
    MainScope().launch {
      observeData(context)
    }
  }

  override fun onReceive(context: Context, intent: Intent) {
    super.onReceive(context, intent)
    if (intent.action == SatsWidgetUpdateAction.UpdateAction) {
      MainScope().launch {
        val glanceId = GlanceAppWidgetManager(context).getGlanceIds(SatsWidget::class.java).firstOrNull()
        glanceId?.let {
          updateAppWidgetState(context, it) { state ->
            state[UpcomingIsUpdating] = true
          }
          glanceAppWidget.update(context, glanceId)
        }
        observeData(context)
      }
    }
  }

  private suspend fun observeData(context: Context) {
    val glanceId = GlanceAppWidgetManager(context).getGlanceIds(SatsWidget::class.java).firstOrNull()
    glanceId?.let { id ->
      if (isLoggedIn()) {
        updateAppWidgetState(context, id) { state ->
          val event = repository.getMyUpcomingTraining()
          state[UpcomingNameKey] = event.name
        }
      } else {
        // ...
      }
    }
  }

  // ...

  companion object {
    val UpcomingNameKey = stringPreferencesKey("upcoming_gx_name_key")
    val UpcomingIsUpdating = booleanPreferencesKey("upcoming_is_updating")

    // ...
  }
}

This class does a lot, but in essence it only updates view state from data provided by the data layer. It also handles updates from the widget in an update intent that is called when someone clicks the update button on the widget. The update method is invoked if there is a change in the system that the widget to reload. This could be for instance changing the system locale. They both call on observeData to reload the data from the data layer API. Also, it’s important to add the receiver in your manifest.

<receiver android:name=".SatsWidgetReceiver"
  android:enabled="true"
  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/widget_qr" />
</receiver>

The way that we update the state in the Widget is that we override the stateDefinition in our GlanceWidget. So we can make a class that extends GlanceWidget. Then we override stateDefinition

override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition

to provide the state. Since DataStore was used in the receiver to store the data, we can use DataStore again to retrieve it. We can do this in the Content method from the GlanceWidget class.

@Composable
override fun Content() {
  val market = currentState(SatsWidgetReceiver.MarketKey)
  val upcomingEventName = currentState(SatsWidgetReceiver.UpcomingNameKey)
  val upcomingIsUpdating = currentState(SatsWidgetReceiver.UpcomingIsUpdating) ?: false
  // ...

  SatsAppWidget(      
    icon = icon,
    upcomingEventName = upcomingEventName,
    upcomingIsUpdating = upcomingIsUpdating,
    // ...
  )
}

@Composable
private fun SatsWidget(
  // ...
) {
  // ...
}

In the SatsWidget composable you can pretty much write compose code just as you do with any other Composable function. However it needs to use it’s own GlanceModifier. This is because under the hood, the GlanceComposable converts it into RemoteViews in order to render the widget using the existing App Widgets APIs. Not every component supports this transformation.

Cool! But how can the user interact with the Widget?

We have now made a workable widget. However, we have not shown how to deal with any user interaction yet. In our widget, we have a progress indicator that should show an animation when the user has clicked on it. It should also update the UI and stop spinning when the UI is updated.

Loading indicator

The way we solved this was to show an CircularProgressIndicator when the value from upcomingIsUpdating is true and if it is false, we just show the refresh icon.

if (upcomingIsUpdating) {
  CircularProgressIndicator()
} else {
  Image(
    modifier = GlanceModifier.clickable(onClick = actionRunCallback<SatsWidgetUpdateAction>()),
    provider = ImageProvider(R.drawable.ic_refresh),
    contentDescription = context.getString(R.string.widget_refresh_cd),
  )
}

As you can see in the code above for the refresh icon, we use an extension method on the GlanceModifier that handles the click interaction from the user. You can make this callback action by extending the glance ActionCallback and use it with actionRunCallback<ActionCallback>. This function can also take actionParameters that you can use in your ActionCallback.

class SatsWidgetUpdateAction : ActionCallback {
  override suspend fun onRun(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
    val intent = Intent(context, SatsWidgetReceiver::class.java).apply {
      action = UpdateAction
    }
    context.sendBroadcast(intent)
  }

  companion object {
    const val UpdateAction = "update_action"
  }
}

Here we have added an intent for updating the UI. This intent is handled in the widget receiver and will just update the widget state. In our implementation we have also actions for launching the app on different screens. We handled that with deep-linking for the different destinations. For instance the way we open the app looks like this.

class OpenAppAction : ActionCallback {
  override suspend fun onRun(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
    val intent = Intent(Intent.ACTION_VIEW, "sats://home/".toUri())).apply {
      setPackage(context.packageName)
      flags = Intent.FLAG_ACTIVITY_NEW_TASK
    }
    context.startActivity(intent)
  }
}

You don’t have to use deep-links to open activities. You can also just use the actionStartActivity<MainActivity>() instead if you want to. This is a lot easier if you don’t want to get into deep-linking or your Activity and your Widget is in the same of a dependent module.

All in all, I really like this way of handling interaction with the Widget. You don’t have to make a PendingIntent to handle calls in a different process from your app. With this ActionCallback all that complexity is handled for us.

At this early stage, this code will probably be subject to a lot of changes. However, I hope that this post will help someone or just induce some curiosity at least :)

You can read more about Glance from the docs and test out our widget in version 3.23.0 of our member app!

Thanks for reading!