Skip to content

Instantly share code, notes, and snippets.

@jamesjmtaylor
Forked from iamcalledrob/CaptureComposable.kt
Last active October 24, 2025 11:28
Show Gist options
  • Select an option

  • Save jamesjmtaylor/40e038e04c785ffa0bea71a517b34831 to your computer and use it in GitHub Desktop.

Select an option

Save jamesjmtaylor/40e038e04c785ffa0bea71a517b34831 to your computer and use it in GitHub Desktop.
Android headless composable capture
import android.app.Presentation
import android.content.Context
import android.content.Context.DISPLAY_SERVICE
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 androidx.compose.ui.unit.IntSize
import kotlin.coroutines.suspendCoroutine
import androidx.core.graphics.createBitmap
/*
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.getSystemService(DISPLAY_SERVICE) as DisplayManager).createVirtualDisplay(
"virtualDisplay",
1, 1, 72,
surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
) ?: return null
val result = callback(virtualDisplay.display)
virtualDisplay.release()
surface.release()
texture.release()
return result
}
data class CaptureComposableScope(val capture: () -> Unit)
fun androidx.compose.ui.geometry.Size.roundedToIntSize(): IntSize =
IntSize(width.toInt(), height.toInt())
// 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.toBitmap(): Bitmap =
createBitmap(width, height).also {
android.graphics.Canvas(it).drawPicture(this)
}
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()
}
}
private inline fun Modifier.thenIf(
condition: Boolean,
crossinline other: Modifier.() -> Modifier,
) = if (condition) other() else this
/** 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.getSystemService(DISPLAY_SERVICE) as DisplayManager)
.getDisplay(Display.DEFAULT_DISPLAY), //context.getDisplayManager().getDisplay(Display.DEFAULT_DISPLAY),
content: @Composable CaptureComposableScope.() -> Unit,
): Bitmap {
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.toBitmap())
continuation.resumeWith(result)
}
},
) {
CaptureComposableScope(capture = { shouldCapture = true }).run {
content()
}
}
}
}
presentation.dismiss()
return imageBitmap
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment