SUBSCRIBE NOW
avatar
I always learn something just by skimming it that makes me want to bookmark the issue now and dig deeper later
SUBSCRIBE NOW
avatar
Keep up the good work with the newsletter 💪 I really enjoy it
SUBSCRIBE NOW
avatar
Dispatch is a must read for Android devs today and my go-to for keeping up with all things Jetpack Compose
SUBSCRIBE NOW
avatar
Dispatch has been my go-to resource as it's packed with useful information while being fun at the same time
SUBSCRIBE NOW
avatar
The content is light, fun, and still useful. I especially appreciate the small tips that are in each issue
SUBSCRIBE NOW
avatar
I truly love this newsletter ❤️‍🔥 Spot on content and I know there's a lot of effort that goes behind it
SUBSCRIBE NOW
avatar
Thanks for taking the time and energy to do it so well
JetpackCompose.app's Newsletter
avatar
I always learn something just by skimming it that makes me want to bookmark the issue now and dig deeper later
JetpackCompose.app's Newsletter
avatar
Keep up the good work with the newsletter 💪 I really enjoy it
JetpackCompose.app's Newsletter
avatar
Dispatch is a must read for Android devs today and my go-to for keeping up with all things Jetpack Compose
JetpackCompose.app's Newsletter
avatar
Dispatch has been my go-to resource as it's packed with useful information while being fun at the same time
JetpackCompose.app's Newsletter
avatar
The content is light, fun, and still useful. I especially appreciate the small tips that are in each issue
JetpackCompose.app's Newsletter
avatar
I truly love this newsletter ❤️‍🔥 Spot on content and I know there's a lot of effort that goes behind it
JetpackCompose.app's Newsletter
avatar
Thanks for taking the time and energy to do it so well

Headless Composable Capture

Author: Rob Mason

You might remember that in the previous issue of the Dispatch Newsletter, we spoke about the newly minted rememberGraphicsLayer API that allows one to capture a composable into a bitmap. Rob wanted to take it a step further and wondered how he could capture a composable without actually displaying it. 🤯

This is a common practice in the web ecosystem, known as “headless browsing”, which has various use cases like web scraping, automated testing, and more. So, this question is not just interesting but also highly practical.

Rob decided to go down the rabbit hole and found a creative solution involving the Presentation class. If you are familiar with this class, call your parents and let them know that you’ve put in 10,000 hours in your craft and that they should be proud of you. For the vast majority of the rest of us, this should be a good "Today I Learned" moment.

import android.app.Presentation
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Picture
import android.graphics.SurfaceTexture
import android.hardware.display.DisplayManager
import android.view.Display
import android.view.Surface
import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.draw
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.lifecycle.*
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlin.coroutines.suspendCoroutine
/*
Usage example:
val bitmap = useVirtualDisplay(applicationContext) { display ->
captureComposable(
context = context,
size = DpSize(100.dp, 100.dp),
display = display
) {
LaunchedEffect(Unit) {
capture()
}
Box(modifier = Modifier.fillMaxSize().background(Color.Red))
}
}
*/
/** Use virtualDisplay to capture composables into a virtual (i.e. invisible) display. */
suspend fun <T>useVirtualDisplay(context: Context, callback: suspend (display: Display) -> T): T {
val texture = SurfaceTexture(false)
val surface = Surface(texture)
// Size of virtual display doesn't matter, because images are captured from compose, not the display surface.
val virtualDisplay = context.getDisplayManager().createVirtualDisplay(
"virtualDisplay", 1, 1, 72, surface, DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY)
val result = callback(virtualDisplay.display)
virtualDisplay.release()
surface.release()
texture.release()
return result
}
data class CaptureComposableScope(val capture: () -> Unit)
/** Captures composable content, by default using a hidden window on the default display.
*
* Be sure to invoke capture() within the composable content (e.g. in a LaunchedEffect) to perform the capture.
* This gives some level of control over when the capture occurs, so it's possible to wait for async resources */
suspend fun captureComposable(
context: Context,
size: DpSize,
density: Density = Density(density = 2f),
display: Display = context.getDisplayManager().getDisplay(Display.DEFAULT_DISPLAY),
content: @Composable CaptureComposableScope.() -> Unit,
): ImageBitmap {
val presentation = Presentation(context.applicationContext, display).apply {
window?.decorView?.let { view ->
view.setViewTreeLifecycleOwner(ProcessLifecycleOwner.get())
view.setViewTreeSavedStateRegistryOwner(EmptySavedStateRegistryOwner.shared)
view.alpha = 0f // If using default display, to ensure this does not appear on top of content.
}
}
val composeView = ComposeView(context).apply {
val intSize = with(density) { size.toSize().roundedToIntSize() }
require(intSize.width > 0 && intSize.height > 0) { "pixel size must not have zero dimension" }
layoutParams = ViewGroup.LayoutParams(intSize.width, intSize.height)
}
presentation.setContentView(composeView, composeView.layoutParams)
presentation.show()
val imageBitmap = suspendCoroutine { continuation ->
composeView.setContent {
var shouldCapture by remember { mutableStateOf(false) }
Box(modifier = Modifier
.size(size)
.thenIf(shouldCapture) {
drawIntoPicture { picture ->
val result = Result.success(picture.toImageBitmap())
continuation.resumeWith(result)
}
},
) {
CaptureComposableScope(capture = { shouldCapture = true }).run {
content()
}
}
}
}
presentation.dismiss()
return imageBitmap
}
private inline fun Modifier.thenIf(
condition: Boolean,
crossinline other: Modifier.() -> Modifier,
) = if (condition) other() else this
private fun Context.getDisplayManager(): DisplayManager =
getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
// Note: This causes a warning: requestLayout() improperly called by androidx.compose.ui.platform.ViewLayerContainer
// In Compose 1.7.0, this could be replaced with rememberGraphicsLayer(), which may fix this?
private fun Modifier.drawIntoPicture(onDraw: (Picture) -> Unit) = this
.drawWithContent {
val width = size.width.toInt()
val height = size.height.toInt()
val picture = Picture()
val canvas = Canvas(picture.beginRecording(width, height))
draw(this, layoutDirection, canvas, size) {
this@drawWithContent.drawContent()
}
picture.endRecording()
onDraw(picture)
}
private fun Picture.toImageBitmap(): ImageBitmap =
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also {
android.graphics.Canvas(it).drawPicture(this)
}.asImageBitmap()
private class EmptySavedStateRegistryOwner : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this).apply {
performRestore(null)
}
private val lifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get()
override val lifecycle: Lifecycle get() =
object : Lifecycle() {
override fun addObserver(observer: LifecycleObserver) {
lifecycleOwner?.lifecycle?.addObserver(observer)
}
override fun removeObserver(observer: LifecycleObserver) {
lifecycleOwner?.lifecycle?.removeObserver(observer)
}
override val currentState = State.INITIALIZED
}
override val savedStateRegistry: SavedStateRegistry
get() = controller.savedStateRegistry
companion object {
val shared = EmptySavedStateRegistryOwner()
}
}

Have a project you'd like to submit? Fill this form, will ya!

If you like this snippet, you might also like:

Maker OS is an all-in-one productivity system for developers

I built Maker OS to track, manage & organize my life. Now you can do it too!