Rebuild an Android widget from scratch with Glance

Denislav Popov, 20 January 2025

Creating widgets on Android has always been challenging. The widget API has been introduced since the very early days of Android development. With the arrival of the first devices supporting this operating system widgets were already available for us to use. Bookmarks, emails, to-do lists, photo frames and many other use cases could be applied to widgets. Despite providing so many solutions, widgets were always second-class citizen to the development of Android. APIs were rarely updated to reflect the latest tendencies and there is still limited view components to work with – ConstraintLayout, ViewPager, custom views and many other UI components are not available for use when developing a widget. This severely limits the capabilities of the widgets themselves and doesn’t allow the developers to let their imagination free.

For the last couple of years, I’ve been tinkering with a specific widget that I still use to this day and have rewritten a couple of times already. The widget is nothing more than 3 buttons acting like shortcuts to the music player, gallery and videos.

The original app is called Media Shortcuts and was available for Sony Xperia devices with Android version 2.3 way back in 2010. In Android 4 they decided to remove it and it was dropped ever since. I liked the widget so much however that I decided to try and bring it back to life. Initially, I modified the original version, so it works newer Android, player and gallery but as time went on simply modding it wouldn’t do. As a consequence I developed it from scratch using the resources from the original one and called it MediaWidget. I’ll stop here as the story of this widget is quite long and you can read more about it on this post: https://denislavpopov46.wordpress.com/2021/01/26/revamped-widgets-media-widget/

Before diving into the details let me quickly remind you that a widget is a part of an app or stand-alone app that can be placed on the home screen of the device, enabling the user to reach information quicker or interact with a specific feature of an app without starting it. Additionally, widgets can be used to decorate and arrange the home screen allowing for optimal use UI enhancements.

In the last year I’ve been seeing more and more developers trying out a new way of creating widgets. This led me to once again to rebuild this widget from scratch and now I’d like to show you a comparison between how we used to build widgets with traditional xml-based UI versus the new Compose derived way – Glance.

This new library was announced in 2021, shortly after Google officially announced Compose. To put it simply, Glance is a simpler and smaller version of Compose, specifically tailored towards widget building. Here are some of the benefits going with Glance:

  1. Use Compose way of writing UI
  2. Bring closer app widget building to regular Compose apps
  3. Reduce boilerplate code
  4. Updated APIs for widget development

There are only a number of views that are supported just like the classic approach – Box that corresponds to RelativeLayout, Column, which translates to LinearLayout with vertical orientation and Row that translates to LinearLayout with horizontal orientation. Beside those three composables Text, Button and Image will be mostly at your disposal due to the inherent limitations of the widgets’ API. Of course, there are other variations of Button like CheckBox and Switch, you can find more at the official Glance page: https://developer.android.com/develop/ui/compose/glance

With some of the basics already cleared, let’s dive into the code and do side-by-side comparison of Glance and xml-based approach.

The old way

Luckily, there are no major changes when it comes to the architecture of the widget. AndroidManifest is practically untouched and there are no changes.

The main difference lays in the main class where the widget is declared, what functions will it have, etc.

override fun onUpdate(
        context: Context?,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        appWidgetIds.forEach { appWidgetId ->
            val pendingIntent = Intent(context, MainActivity::class.java)
                .let { intent ->
                    PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
                }
 
            val view = RemoteViews(context?.packageName, R.layout.media_widget_layout)
                //.apply { setOnClickPendingIntent(R.id.center_image, pendingIntent) }
            setMusicIntent(context, view)
            setPhotoIntent(context, view)
            setVideoIntent(context, view)
            view.setInt(R.id.widget_layout, "setBackgroundResource", R.drawable.widget_plate)
            appWidgetManager.updateAppWidget(appWidgetId, view)
        }
      
    }

The traditional approach is extending AppWidgetProvider class and overriding onUpdate() method. Since widgets are apps that are working inside other apps (mostly Launcher and Lockscreen) in order to inflate the view of a widget a RemoteView must be created. Then, since this is a widget that consists mostly of buttons, we need to define what each button will do on click. This is done with PendingIntent. Citing the official documentation:

“By giving a PendingIntent to another application, you are granting it the right to perform the operation you have specified as if the other application was yourself (with the same permissions and identity).”

Now, let’s define three PendingIntents for each button shortcut:

private fun setMusicIntent(paramContext: Context?, paramRemoteViews: RemoteViews){
       paramRemoteViews.setOnClickPendingIntent(R.id.music_icon, PendingIntent.getActivity(paramContext, 0, Intent("android.intent.action.MUSIC_PLAYER"), PendingIntent.FLAG_IMMUTABLE))
   }
 
   private fun setPhotoIntent(paramContext: Context?, paramRemoteViews: RemoteViews){
       val localIntent = Intent(Intent.ACTION_MAIN)
               .apply{
                   addCategory(Intent.CATEGORY_LAUNCHER)
                   component = ComponentName("com.sonyericsson.album", "com.sonyericsson.album.MainActivity")
                   flags = Intent.FLAG_ACTIVITY_NEW_TASK //how 335544320 turned to this flag: https://stackoverflow.com/questions/52390129/android-intent-setflags-issue
               }
       paramRemoteViews.setOnClickPendingIntent(R.id.center_image, PendingIntent.getActivity(paramContext, 0, localIntent, PendingIntent.FLAG_IMMUTABLE))
   }
 
   private fun setVideoIntent(paramContext: Context?, paramRemoteViews: RemoteViews){
       val localIntent = Intent(Intent.ACTION_VIEW)
               .apply {
                   addCategory(Intent.CATEGORY_DEFAULT)
                   putExtra("com.sonyericsson.album.intent.extra.SCREEN_NAME", "videos")
                   component = ComponentName("com.sonyericsson.album", "com.sonyericsson.album.MainActivity")
                   flags = Intent.FLAG_ACTIVITY_CLEAR_TASK
               }
       paramRemoteViews.setOnClickPendingIntent(R.id.video_icon, PendingIntent.getActivity(paramContext, 0, localIntent, PendingIntent.FLAG_IMMUTABLE))
   }

In order for these intents to work it is important to attach them to the remoteView.

You may notice that the pending intent for the music player is defined in a different manner compared to the Photo and Video intents. That’s because a music player app can be set as default and simply calling the default music player will result in opening the music player. Default gallery can also be set and in theory such pending intent can be used for gallery too.

The rest of the pending intents are not that easy, there we need to explicitly define the package and class of the app we’re calling.

Now, let’s see the Glance version.

The new way

I’ll first post the required dependencies for Glance. Keep in mind that these might change at some point and some may not be required anymore.

// For AppWidgets support
implementation "androidx.glance:glance-appwidget:1.0.0"
 
// For interop APIs with Material 2
implementation "androidx.glance:glance-material:1.0.0"
 
// For interop APIs with Material 3
implementation "androidx.glance:glance-material3:1.0.0"
 
// For Compose preview
debugImplementation("androidx.compose.ui:ui-tooling:1.6.0")
implementation("androidx.compose.ui:ui-tooling-preview:1.6.0")

The implementation here is noticeably easier. We need to define an object that will extend GlanceAppWidget class. There are 4 methods that we must override.

object MediaWidget: GlanceAppWidget() {
 
    override val sizeMode: SizeMode
        get() = super.sizeMode
 
    override val stateDefinition: GlanceStateDefinition<*>?
        get() = super.stateDefinition
 
    override suspend fun onDelete(context: Context, glanceId: GlanceId) {
        super.onDelete(context, glanceId)
    }
 
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
                Widget()
            }
    }
}

The last one is the one of greatest importance here.

This is where the Composable view must be called and thus initiated.

And this is how the UI of the widget will be defined using Glance.

@Composable
private fun Widget() {
    Row(
        modifier = GlanceModifier
            .fillMaxSize()
            .background(ImageProvider(resId = R.drawable.widget_plate)),
        verticalAlignment = Alignment.Vertical.CenterVertically,
        horizontalAlignment = Alignment.Horizontal.CenterHorizontally
    ) {
        val modifier = GlanceModifier.defaultWeight()
        val padding = 12.dp
        val photoIntent = Intent(Intent.ACTION_MAIN)
            .apply{
                component = ComponentName("com.sonyericsson.album", "com.sonyericsson.album.MainActivity")
                flags = Intent.FLAG_ACTIVITY_NEW_TASK //how 335544320 turned to this flag: https://stackoverflow.com/questions/52390129/android-intent-setflags-issue
            }
        val musicIntent = Intent(Intent.ACTION_MAIN)
            .apply{
                component = ComponentName("com.sonyericsson.music", "com.sonyericsson.music.MusicActivity")
            }
        val videoIntent = Intent(Intent.ACTION_VIEW)
            .apply {
                addCategory(Intent.CATEGORY_DEFAULT)
                putExtra("com.sonyericsson.album.intent.extra.SCREEN_NAME", "videos")
                component = ComponentName("com.sonyericsson.album", "com.sonyericsson.album.MainActivity")
                flags = Intent.FLAG_ACTIVITY_CLEAR_TASK
            }
        Box (modifier)
        {
            Image(
                provider = ImageProvider(resId = R.drawable.music_button),
                contentDescription = "",
                modifier = GlanceModifier
                    .clickable(onClick = actionStartActivity(
                        intent = musicIntent
                    )
                    )
                    .fillMaxSize()
                    .padding(padding),
                colorFilter = ColorFilter.tint(colors.primary)
 
            )
        }
        Box (modifier)
        {
            Image(
                provider = ImageProvider(resId = R.drawable.photo_button),
                contentDescription = "",
                modifier = GlanceModifier
                    .clickable(onClick = actionStartActivity(
                        intent = photoIntent
                    )
                    )
                    .fillMaxSize()
                    .padding(padding),
                colorFilter = ColorFilter.tint(colors.primary)
            )
        }
        Box (modifier)
        {
            Image(
                provider = ImageProvider(resId = R.drawable.video_button),
                contentDescription = "",
                modifier = GlanceModifier
                    .clickable(onClick = actionStartActivity(
                        intent = videoIntent
                    )
                    )
                    .fillMaxSize()
                    .padding(padding),
                colorFilter = ColorFilter.tint(colors.primary)
            )
        }
 
    }
}

So far no noticeable changes in comparison to Compose, it is important to mention however that all Composables must be imported from Glance package and not from Compose as I initially did. I have also tried to use as few composables as possible to describe the UI of the widget with only a Row that defines the alignments and placed 3 Boxes inside it, each of which has a modifier to keep the Image horizontally and vertically aligned.

Another interesting thing unique to Glance is the actionStartActivity method. I already mentioned that widgets live on remote processes inside other apps and the execution of a widget code also happens there. So this code execution is, once again, done by PendingIntents. In Glance however, those PendingIntents are defined by Actions. Depending on the use case there are a few actions:

  1. Launch an activity
  2. Launch a service
  3. Send broadcast event
  4. Run callback You can read more about these action here: https://developer.android.com/develop/ui/compose/glance/user-interaction

Going back to our MediaWidget composable, there we have defined click event for each Image – they will act as buttons. Every click event will start actionStartActivity() where music, photo or video intents will be passed depending on the use case to start the corresponding activity.

So far so good but since this is Android 13 we want to have dynamic theming of the buttons. We already assigned primary colors to the Image composables, but they won’t change dynamicly unless we wrap the whole widget composable with a theme. As in Compose, this is also very easy to do in Glance, just wrap the composable with GlanceTheme and we’re good to go:

override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            GlanceTheme {
                Widget()
            }
        }
    }

Let’s now see how the widget looks like, I added a couple of screenshots to demonstrate the color scheme changing that the widget now supports:

With the Composable out of the way I tried to create a preview of it but at the time of writing this post I still can’t make it work. I see other people experiencing similar issues. Some of them mention that this feature is currently disabled as it was not quite stable. Others have successfully working with @Preview annotation.

The last thing to do is to inherit GlanceAppWidgetReceiver and pass the GlanceAppWidget implementation – MediaWidget. Internally GlanceAppWidgetReceiver is called by AppWidgetProvider to generate the remote Glance view.

class MediaWidgetReceiver: GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget
        get() = MediaWidget
}

There’s one last thing to do. Every widget must have an xml defining some basic widget parameters – description, resizeMode, widgetCategory, etc. Google also strongly encourages to add initialLayout and previewLayout attributes to define the layout before the widget can load its view and layout that will be displayed in the launcher’s widget picker.

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name"
    android:minHeight="30dip"
    android:minWidth="300dip"
    android:resizeMode="none"
    android:widgetCategory="home_screen"
    android:initialLayout="@layout/media_widget_layout"
    android:previewLayout="@layout/media_widget_layout"/>

For xml-based widgets this is easier to achieve as the layout is already there but for Glance-based widgets we also need to provide xml layout, so we still need to build xml UI specifically for the preview. I just carried over the layout from the previous version of the widget and removed the references to the theme style that tints the buttons. And with that we already have the preview working too.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/widget_layout"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:layout_marginTop="0dp"
    android:layout_marginBottom="0dp"
    android:background="@drawable/widget_plate">
 
    <TextView style="@style/WidgetVerticalSpacing"/>
 
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
 
        <ImageView
            android:id="@+id/music_icon"
            android:layout_width="100dp"
            android:layout_height="wrap_content"
            android:src="@drawable/music_button" />
 
        <TextView
            style="@style/WidgetHorizontalSpacing"/>
 
        <ImageView
            android:id="@+id/center_image"
            android:layout_width="100dp"
            android:layout_height="wrap_content"
            android:src="@drawable/photo_button" />
 
        <TextView
            style="@style/WidgetHorizontalSpacing"/>
 
        <ImageView
            android:id="@+id/video_icon"
            android:layout_width="100dp"
            android:layout_height="wrap_content"
            android:src="@drawable/video_button" />
 
    </LinearLayout>
 
    <TextView style="@style/WidgetVerticalSpacing"/>
     
</LinearLayout>

Here’s how the two implementations look side-by-side:

Apart from the slight margin differences of the icons there’s no telling which is which (Glance is top, xml one is at the bottom) and the widgets are working in the same manner.

And here are the two projects:

  1. MediaWidget with Kotlin: https://github.com/DeniE46/MediaWidgetKotlin
  2. MediaWidget with Compose: https://github.com/DeniE46/Media-Widget-Compose

The verdict

During the course of researching and learning Glance and then building the Glance version of MediaWidget I found that there are still some issues in the world of Android widgets – the APIs, despite brand new and carrying over huge part of Compose, the “flexibility” feeling of Compose is missing. It’s like I’m still using RelativeLayout and LinearLayout wrapped in a different syntax. Xml layout is still needed in order to provide preview and I’m quite displeased with this. Apps widget size on disk has also increased, with Kotlin and xml the debug version of MediaWidget is 2.32MB while the Glance version is sitting at 15.43MB. Granted, you can use debugImplementation for Glance dependencies but I feel that optimizations must be done out-of-the-box and I feel that there’s plenty to do in this area. Then again, Compose is not exactly lightweight, so this negative trait will inherently transfer to Glance too.

I also must point out that I’m very impressed with how uniform the widget code feels – once the composable is created you just pass it as a remote view and that’s all. Theming is handled automatically, PendingIntents are masked behind actions, which clearly defines what you can and cannot do. The whole Glance framework is giving clear boundaries of what is possible and how to create it.

There’s still long road ahead for widgets until they feel as modern as Jetpack and the rest of modern Android tools but Glance seems very promising and is a step in the right direction.