Compose for widgets, RemoteViews underneath
Calendula’s home-screen widgets are written in
Jetpack Glance, the
Compose-style API for widgets. You declare a GlanceAppWidget, write something
that looks like a composable, and attach behaviour with familiar-feeling
modifiers. It’s a real relief compared to hand-assembling widget layouts.
Glance is a translation layer on top of the old widget runtime, though. Your
composition is compiled down to
RemoteViews,
a serialized description of a layout that another process (the launcher)
inflates and renders. Your app doesn’t have to be running while the widget is on
screen, and it never gets the touch events. Glance hides that, but it can’t change it. Three
Calendula widget bugs in a row came down to the same thing: the RemoteViews
constraints still apply under the Compose surface.
Every tap is a PendingIntent
In a real Compose UI you’d write Modifier.clickable { doThing() } and doThing
runs in your process, called straight from the touch event. Glance lets you
write almost the same thing, GlanceModifier.clickable { doThing() }, and that
resemblance hides most of this post. The launcher still can’t call into your
code. Whatever you attach, Glance turns into the PendingIntents RemoteViews
has always required. actionStartActivity launches something. actionRunCallback
names an ActionCallback class that Glance’s broadcast receiver creates when the
tap arrives. Even a plain lambda goes the same way: the intent comes back to
your app, and the lambda runs in a background worker that hosts the widget
session. However it looks at the call site, a tap sends an intent to a process
that may not be running yet, and your code only runs once it’s delivered.
That changes how I read every “make X tappable” request. When the month
widget’s arrows and header did nothing (#18), the arrows did have actions.
Each fired an actionRunCallback, which Glance resolves by writing the
callback’s class name into the intent and creating the class from that name
when the tap comes in. A recent release had broken that: R8, which shrinks
release builds, found no code that used those classes directly and renamed them,
so the lookup failed silently and the tap went nowhere. Taps that launched an
activity kept working, since they need no lookup. The fix was a rule that keeps
every ActionCallback as it is. The header was the other case: it never had an
action at all, so the launcher had nothing to fire until I attached one that
opens the app in month view. #20 had that shape. The agenda widget’s header
should open your default view, and the action was missing.
Because every day cell needs to open its own date, each one carries its own
action. Glance’s clickable makes that look like an ordinary per-item modifier,
but underneath it’s still one addressed intent per cell, so a month grid is a
grid of intents. An earlier round (#2) fixed exactly this: the month grid
wasn’t interactive at all until each day was made to launch itself.
The far side has to be told what “back” means
Because a tap launches an intent rather than navigating, the app has to reconstruct context on arrival. Tapping a day in the agenda widget and tapping a day in the month widget both open a day, but where should Back take you? The widget knows. The freshly started activity doesn’t, unless the action says so.
So actionStartActivity carries “you came from the agenda context” along with
“open this day,” and the app rebuilds a back stack from that: day → agenda →
your default view, or day → month → default. It looks like normal navigation to
the user. Under the hood the app trusts a breadcrumb the widget packed into the
launch. A widget can’t hand over live navigation state; with or without Glance,
all it can serialize is data.
“Upcoming” has to actually mean upcoming
The last one wasn’t about interaction. The agenda widget is titled Upcoming,
yet it listed every event of the day, finished ones included (#12). In a
normal Compose list you’d filter as the list recomposes. A widget can’t do
that: what the launcher renders is fixed when the composition is snapshotted to
RemoteViews, and nothing is recomputed as you scroll. So “don’t show past
events” has to be a property of the data you build before Glance serializes
it. In Calendula that’s a PastEventDisplay preference: hide finished events
outright, or dim them in place for people who still want them visible.
What I took from it
Glance is a real improvement, and declaring widgets in Compose beats the old ceremony. Underneath, though, there’s still no process of yours and no live view tree, only data you snapshot and actions you address in advance, rendered by someone else. Once I stopped asking “how do I handle this tap” and started asking “what action should this fire, and what does the far side need to know,” the three bugs stopped looking separate.