Headless Composable Capture
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() | |
} | |
} |
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!