I made a small library that screenshots Composables with single shot or periodically.
A state that is used for capturing and storing Bitmap or ImageBitmap
/**
* Create a State of screenshot of composable that is used with that is kept on each recomposition.
* @param delayInMillis delay before each screenshot
* if [ScreenshotState.liveScreenshotFlow] is collected.
*/
@Composable
fun rememberScreenshotState(delayInMillis: Long = 20) = remember {
ScreenshotState(delayInMillis)
}
/**
* State of screenshot of composable that is used with.
* @param timeInMillis delay before each screenshot if [liveScreenshotFlow] is collected.
*/
class ScreenshotState internal constructor(
private val timeInMillis: Long = 20,
) {
val imageState = mutableStateOf<ImageResult>(ImageResult.Initial)
val bitmapState = mutableStateOf<Bitmap?>(null)
internal var callback: (() -> Unit)? = null
/**
* Captures current state of Composables inside [ScreenshotBox]
*/
fun capture() {
callback?.invoke()
}
val liveScreenshotFlow = flow {
while (true) {
callback?.invoke()
delay(timeInMillis)
bitmapState.value?.let {
emit(it)
}
}
}
.map {
it.asImageBitmap()
}
.flowOn(Dispatchers.Default)
val bitmap: Bitmap?
get() = bitmapState.value
val imageBitmap: ImageBitmap?
get() = bitmap?.asImageBitmap()
}
ImageResult that contains error or success depending on process result
sealed class ImageResult {
object Initial : ImageResult()
data class Error(val exception: Exception) : ImageResult()
data class Success(val data: Bitmap) : ImageResult()
}
Composable that captures screenshot of its children Composables
/**
* A composable that gets screenshot of Composable that is in [content].
* @param screenshotState state of screenshot that contains [Bitmap].
* @param content Composable that will be captured to bitmap on action or periodically.
*/
@Composable
fun ScreenshotBox(
modifier: Modifier = Modifier,
screenshotState: ScreenshotState,
content: @Composable () -> Unit,
) {
val view: View = LocalView.current
var composableBounds by remember {
mutableStateOf<Rect?>(null)
}
DisposableEffect(Unit) {
screenshotState.callback = {
composableBounds?.let { bounds ->
if (bounds.width == 0f || bounds.height == 0f) return@let
view.screenshot(bounds) { imageResult: ImageResult ->
screenshotState.imageState.value = imageResult
if (imageResult is ImageResult.Success) {
screenshotState.bitmapState.value = imageResult.data
}
}
}
}
onDispose {
val bmp = screenshotState.bitmapState.value
bmp?.apply {
if (!isRecycled) {
recycle()
}
}
screenshotState.bitmapState.value = null
screenshotState.callback = null
}
}
Box(modifier = modifier
.onGloballyPositioned {
composableBounds = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
it.boundsInWindow()
} else {
it.boundsInRoot()
}
}
) {
content()
}
}
Functions for Capturing screenshot. PixelCopy is required for Devices with version O and above. And you can use these functions without Composables either
fun View.screenshot(
bounds: Rect
): ImageResult {
try {
val bitmap = Bitmap.createBitmap(
bounds.width.toInt(),
bounds.height.toInt(),
Bitmap.Config.ARGB_8888,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Above Android O not using PixelCopy throws exception
// https://stackoverflow.com/questions/58314397/java-lang-illegalstateexception-software-rendering-doesnt-support-hardware-bit
PixelCopy.request(
(this.context as Activity).window,
bounds.toAndroidRect(),
bitmap,
{},
Handler(Looper.getMainLooper())
)
} else {
val canvas = Canvas(bitmap)
.apply {
translate(-bounds.left, -bounds.top)
}
this.draw(canvas)
canvas.setBitmap(null)
}
return ImageResult.Success(bitmap)
} catch (e: Exception) {
return ImageResult.Error(e)
}
}
fun View.screenshot(
bounds: Rect,
bitmapCallback: (ImageResult) -> Unit
) {
try {
val bitmap = Bitmap.createBitmap(
bounds.width.toInt(),
bounds.height.toInt(),
Bitmap.Config.ARGB_8888,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Above Android O not using PixelCopy throws exception
// https://stackoverflow.com/questions/58314397/java-lang-illegalstateexception-software-rendering-doesnt-support-hardware-bit
PixelCopy.request(
(this.context as Activity).window,
bounds.toAndroidRect(),
bitmap,
{
when (it) {
PixelCopy.SUCCESS -> {
bitmapCallback.invoke(ImageResult.Success(bitmap))
}
PixelCopy.ERROR_DESTINATION_INVALID -> {
bitmapCallback.invoke(
ImageResult.Error(
Exception(
"The destination isn't a valid copy target. " +
"If the destination is a bitmap this can occur " +
"if the bitmap is too large for the hardware to " +
"copy to. " +
"It can also occur if the destination " +
"has been destroyed"
)
)
)
}
PixelCopy.ERROR_SOURCE_INVALID -> {
bitmapCallback.invoke(
ImageResult.Error(
Exception(
"It is not possible to copy from the source. " +
"This can happen if the source is " +
"hardware-protected or destroyed."
)
)
)
}
PixelCopy.ERROR_TIMEOUT -> {
bitmapCallback.invoke(
ImageResult.Error(
Exception(
"A timeout occurred while trying to acquire a buffer " +
"from the source to copy from."
)
)
)
}
PixelCopy.ERROR_SOURCE_NO_DATA -> {
bitmapCallback.invoke(
ImageResult.Error(
Exception(
"The source has nothing to copy from. " +
"When the source is a Surface this means that " +
"no buffers have been queued yet. " +
"Wait for the source to produce " +
"a frame and try again."
)
)
)
}
else -> {
bitmapCallback.invoke(
ImageResult.Error(
Exception(
"The pixel copy request failed with an unknown error."
)
)
)
}
}
},
Handler(Looper.getMainLooper())
)
} else {
val canvas = Canvas(bitmap)
.apply {
translate(-bounds.left, -bounds.top)
}
this.draw(canvas)
canvas.setBitmap(null)
bitmapCallback.invoke(ImageResult.Success(bitmap))
}
} catch (e: Exception) {
bitmapCallback.invoke(ImageResult.Error(e))
}
}
Implementation
val screenshotState = rememberScreenshotState()
var progress by remember { mutableStateOf(0f) }
ScreenshotBox(screenshotState = screenshotState) {
Column(
modifier = Modifier
.border(2.dp, Color.Green)
.padding(5.dp)
) {
Image(
bitmap = ImageBitmap.imageResource(
LocalContext.current.resources,
R.drawable.landscape
),
contentDescription = null,
modifier = Modifier
.background(Color.LightGray)
.fillMaxWidth()
// This is for displaying different ratio, optional
.aspectRatio(4f / 3),
contentScale = ContentScale.Crop
)
Text(text = "Counter: $counter")
Slider(value = progress, onValueChange = { progress = it })
}
}
Capturing screenshot
Button(onClick = {
screenshotState.capture()
}) {
Text(text = "Take Screenshot")
}
Result
