Glance looks like Compose and compiles to RemoteViews, which explains every limitation

Glance lets you write widgets in Compose-like Kotlin, but it emits RemoteViews rather than drawing. Knowing that explains the missing modifiers, the absent animations, and why state works the way it does.

5 min read
androidcomposekotlinsystem

Day 99 — Glance looks like Compose and compiles to RemoteViews

Day 99 of 100, closing the final pillar. An API that looks familiar enough to be misleading.

The symptom

A widget written as if it were an app screen:

class OrderWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            Column(GlanceModifier.padding(16.dp)) {
                Text("Next delivery")
                AsyncImage(model = order.imageUrl, contentDescription = null)   // no
                LazyColumn { items(orders) { OrderRow(it) } }                    // no
                val alpha by animateFloatAsState(…)                              // no
            }
        }
    }
}

None of the last three compile, or they compile against the wrong import and fail at runtime. The Column and Text are Glance's own, the modifier is GlanceModifier, and most of what the last ninety-eight days covered isn't available.

The usual reaction is that Glance is a half-finished Compose.

Why the obvious framing fails

The obvious framing is "Compose for widgets", which the marketing supports and which sets the wrong expectations.

Glance is a Compose-syntax DSL that emits RemoteViews. Your code doesn't draw anything; it produces a description that the launcher's process inflates into actual views. That's the same boundary as yesterday's notifications, and every limitation follows from it.

Once you hold that, the API stops looking arbitrary.

Glance code emits RemoteViews for another process to inflate — it never draws

The actual mechanism

The composition runs in your process and produces a tree; the tree is translated into RemoteViews; the launcher inflates it. Three consequences:

Only what RemoteViews supports exists. Box, Column, Row, Text, Image, Button, LazyColumn, Spacer — and that's most of it. No Canvas, no arbitrary drawBehind, no custom Layout.

No animation. RemoteViews has no frame loop you control, so animateFloatAsState and everything from Days 61–66 has no equivalent. A widget changes by being recomposed and re-pushed, not by animating.

Updates are pushed, not continuous. The launcher isn't running your composition; it's displaying a snapshot. update() sends a new one, and the system rate-limits how often that can happen.

The shape of a widget:

class OrderWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val orders = repository.nextDeliveries()          // suspend, before content

        provideContent {
            GlanceTheme {
                Column(
                    GlanceModifier
                        .fillMaxSize()
                        .background(GlanceTheme.colors.widgetBackground)
                        .padding(12.dp),
                ) {
                    Text("Next delivery", style = TextStyle(fontWeight = FontWeight.Medium))
                    orders.take(3).forEach { order ->
                        Text(
                            order.summary,
                            modifier = GlanceModifier.clickable(
                                actionStartActivity<MainActivity>(
                                    actionParametersOf(orderIdKey to order.id)
                                )
                            ),
                        )
                    }
                }
            }
        }
    }
}

Note the data is loaded before provideContent. provideGlance is a suspend function precisely so loading happens there — inside the content, you're describing a snapshot with no effects available.

State

Glance has its own state mechanism, because the widget's process lifetime is nothing like a screen's:

class OrderWidget : GlanceAppWidget() {
    override val stateDefinition = PreferencesGlanceStateDefinition

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            val prefs = currentState<Preferences>()
            val compact = prefs[compactKey] ?: false
            …
        }
    }
}

State is persisted per widget instance, and updating it triggers a re-push:

updateAppWidgetState(context, glanceId) { prefs -> prefs[compactKey] = true }
OrderWidget().update(context, glanceId)

remember and mutableStateOf don't help here — there is no long-lived composition to hold them. This is the clearest sign that the resemblance to Compose is syntactic.

Actions

Interaction is limited to what RemoteViews can dispatch, which is three things:

actionStartActivity<MainActivity>()            // open the app
actionRunCallback<RefreshAction>()             // run a background callback
actionSendBroadcast(intent)                    // send a broadcast

A callback is a class rather than a lambda, because it has to survive being invoked in a fresh process:

class RefreshAction : ActionCallback {
    override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
        repository.refresh()
        OrderWidget().update(context, glanceId)
    }
}

No gestures, no drag, no scroll listeners — Days 71–77 don't apply. A tap is what a widget gets.

Sizing

Widgets are resized by the user, and the API is a set of supported sizes rather than a continuous measurement:

override val sizeMode = SizeMode.Responsive(
    setOf(DpSize(120.dp, 120.dp), DpSize(250.dp, 120.dp), DpSize(250.dp, 250.dp))
)

// inside the content
val size = LocalSize.current
if (size.width > 200.dp) WideLayout() else CompactLayout()

SizeMode.Responsive pre-computes a layout for each declared size, which is what makes resizing smooth — the launcher already has the right RemoteViews. SizeMode.Exact recomputes per size and is more flexible and slower.

The pattern is Day 29's, with a coarser vocabulary: a small set of declared configurations rather than a continuous constraint.

What to put in a widget

The design half, and it matters more than the API:

One glanceable fact. The next delivery, today's total, the current temperature. A widget is read in passing from a home screen, not browsed.

One tap target that goes somewhere specific. Yesterday's deep-link argument applies identically — a widget that opens the home screen wastes the tap.

Nothing that needs to be current to the second. Updates are rate-limited, so a widget showing a live-changing number will be wrong most of the time. Show something that's true for hours.

The scheduling side follows from that. A widget refreshed by WorkManager on a periodic cadence is the normal shape, with an immediate update() when the underlying data changes while the app is open. Trying to keep a widget live with frequent updates is both rate-limited by the system and expensive for the user's battery.

How to prove it

Widgets have a worse feedback loop than anything else in this series, which is worth knowing before starting. There's no interactive preview and no hot reload; the loop is build, add the widget, look.

Two things make it bearable. GlanceAppWidgetReceiver can be triggered manually from adb, and Glance's own preview support renders a widget composition in the IDE — worth setting up before writing the second widget.

The adb form:

adb shell am broadcast -a android.appwidget.action.APPWIDGET_UPDATE \
  -n com.example.app/.OrderWidgetReceiver

Which forces a refresh without removing and re-adding the widget — the difference between a fifteen-second loop and a ninety-second one.

The check that matters: resize it through every size your sizeMode declares, and remove and re-add it to confirm a fresh instance loads correctly. State that survives only because the old instance was still around is the widget-specific version of Day 14's process-death bug.

What this generalizes to

The pillar's conclusion, and one worth carrying past this series: a familiar syntax does not imply a familiar runtime. Glance borrows Compose's shape because the shape is good — declarative, composable, state-driven — while the thing underneath produces RemoteViews for another process to inflate.

Reading the limitations as "missing features" leads to frustration; reading them as "consequences of the target" makes them predictable. The question to ask on meeting any familiar-looking API is what it actually compiles to, and most surprises answer themselves from there.

Five days of system surfaces make the same point from different angles. Insets are the system telling you what it occupies; predictive back is the system asking for progress rather than a decision; notifications and widgets are the system rendering on your behalf. In each case the app is a participant in something larger, and the APIs are the negotiation rather than an inconvenience.

Tomorrow is Day 100.


Day 99 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Glance.