Compose UI without an Activity

Recently, I needed to add a custom marker to a MapLibre map with the upcoming street name, while respecting various rules (supporting RTL, etc). Inspired by our iOS team, I solved this by writing the street name marker code in Compose, and then taking a screenshot of the Composable on demand and rendering it to the map. This solution worked well, and everything was great until I wanted to show the same markers on the Android Auto map.

This post will talk about how to use ComposeView outside of an Activity. This turns out to be useful for various use cases, including Android Auto, overlays, IMEs, bottom sheet dialogs, and potentially others.

tldr Android Auto

Android Auto does not provide us with an Activity, though we do have a CarContext (which is just a Context). Android Auto does not give a lot of flexibility in terms of how to build apps - there are templates that are pre-configured for the standard use cases that need to be used. The exception to this is that Android Auto provides a Surface on which content can be rendered (used specifcially for rendering a map, for example). In this case, I am already rendering the map, and just want to be able to generate the symbols I need to show on the map when I have no Activity.

Android Auto does not provide a way to directly integrate with Compose code. While browsing the Android for Cars App Library documentation, however, I found a section specifically about Use a virtual display to render content and Use Compose to render to the virtual display. The documentation gives us some high level code that can be used to have a View (and, by extension, a ComposeView) render directly to a Surface.

This seems like a good starting point.

ComposeView

Based on the documentation for Android Auto, we know we need a way to render a ComposeView. Simply making a ComposeView, however, is not enough, since it must be attached to a Window (failing to do this will result in an Exception being thrown). There are a few ways to do this, but all of them are based on the idea of using the WindowManager service to get a Window instance to attach the ComposeView to.

Using the way provided in the documentation, we can create a virtual Display. We can then use the Presentation class (which is a “a special kind of dialog whose purpose is to present content on a secondary display.” (ref)), add our ComposeView to it, and then show() it.

val displayMetrics = appContext.resources.displayMetrics
val displayManager = appContext.getSystemService(DisplayManager::class.java)

val virtualDisplay = displayManager
    .createVirtualDisplay(
        "ComposeScreenshotTaker",
        displayMetrics.widthPixels,
        displayMetrics.heightPixels,
        displayMetrics.densityDpi,
        // note - unlike the documentation, we pass null here since we
        // don't want to actually render to a particular surface.
        null,
        0
    )

val composeView = makeComposeView()
val presentation = Presentation(appContext, virtualDisplay.display)
presentation.setContentView(composeView)
presentation.show()

// after taking the screenshot
presentation.dismiss()

// later, when we're completely done with taking screenshots...
virtualDisplay.release()

Alternatively, we can directly call addView on a WindowManager with the proper WindowManager.LayoutParams, and add our View that way. This is how the MapLibre-Android-Auto-Sample attaches a org.maplibre.android.maps.MapView to the car today.

For Compose, simply attaching the ComposeView is not enough, and running this would give us:

java.lang.IllegalStateException: ViewTreeLifecycleOwner not found from androidx.compose.ui.platform.ComposeView{d1c5c98 V.E...... ......I. 0,0-0,0}

The documentation hints at this when it says:

Because ComposeView is used outside of an activity, you must ensure that it or a parent view propagates a LifecycleOwner and SavedStateRegistryOwner. Use setViewTreeLifecycleOwner and setViewTreeSavedStateRegistryOwner to achieve this.

The setViewTreeLifecycleOwner takes in a LifecycleOwner, and the setViewTreeSavedStateRegistryOwner takes in a SavedStateRegistryOwner, which is also a LifecycleOwner. In a vanilla Activity world, ComponentActivity implements both interfaces for us, but outside of an Activity, we need to provide our own implementation.

// Taken and slightly modified from the original at
// https://gist.github.com/handstandsam/6ecff2f39da72c0b38c07aa80bbb5a2f
class CustomLifecycleOwner : SavedStateRegistryOwner {
    private var lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
    private var savedStateRegistryController: SavedStateRegistryController = SavedStateRegistryController.Companion.create(this)

    override val lifecycle: Lifecycle = lifecycleRegistry
    override val savedStateRegistry = savedStateRegistryController.savedStateRegistry

    fun handleLifecycleEvent(event: Lifecycle.Event) {
        lifecycleRegistry.handleLifecycleEvent(event)
    }

    fun performRestore(savedState: Bundle?) {
        savedStateRegistryController.performRestore(savedState)
    }
}

Now, we can just make a new one and set it on our ComposeView -

val lifecycleOwner = CustomLifecycleOwner()
val composeView = ComposeView(appContext).apply {
    this.setViewTreeLifecycleOwner(lifecycleOwner)
    this.setViewTreeSavedStateRegistryOwner(lifecycleOwner)
    setContent {
        // content
    }
}

If we now run this, we won’t get a crash, but no bitmap will be generated. That’s because we haven’t told our LifecycleOwner that we’re in ON_CREATE state or beyond. We do this by adding:

val lifecycleOwner = CustomLifecycleOwner()
// this line is important to ensure things are properly initialized.
// without it we'd get:
// java.lang.IllegalStateException: You can 'consumeRestoredStateForKey' only after the corresponding component has moved to the 'CREATED' state
lifecycleOwner.performRestore(null)

// technically, we only really need any one of these instead of all 3
// i add them to be consistent with the actual lifecycle.
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)

We need to also make sure to properly clean things up when we’re done:

presentation.dismiss()
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)

Why not Molecule?

Molecule’s main page has a nice comic that states, “Molecule isn’t a framework, just a headless Compose runtime.” The headless part is exactly what we want, so can we use that instead? The short answer is no, because the Applier class for Molecule emits Unit (instead of LayoutNode) since it’s not expecting to emit ui nodes. Despite not rendering ui to the screen, we still need our Composer to be able to handle LayoutNodes to generate the Canvas operations that will ultimately be saved to a Bitmap.

Per the documentation:

An Applier is responsible for applying the tree-based operations that get emitted during a composition. Every Composer has an Applier which it uses to emit a ComposeNode.

Consequently, while Molecule is essential for non-ui logic, we can’t adapt it to this specific use case.