29

I want to take screenshot of specific composable function on Jetpack Compose. How can I do this? Please, anyone help me. I want to take screenshot of composable function and share with other applications.

Example of my function:

@Composable
fun PhotoCard() {
    Stack() {
        Image(imageResource(id = R.drawable.background))
        Text(text = "Example")
    }
}

How to take screenshot of this function?

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
Mehmet Peker
  • 415
  • 5
  • 10
  • 4
    At present, there is nothing for this directly in Compose, AFAIK. You can, however, have an `AndroidComposeView` render your composable, then have the `AndroidComposeView` `draw()` its content to a `Bitmap`-backed `Canvas`. That won't cover everything, but it should do as well as "screenshots" of ordinary views. See [this Kotlinlang Slack post](https://kotlinlang.slack.com/archives/CJLTWPH7S/p1593977883408400?thread_ts=1593041594.083900&cid=CJLTWPH7S) for an example, though it is a couple of months old and will need updating for current versions of Compose. – CommonsWare Sep 12 '20 at 14:05
  • One year later, the dev team talks about screenshotting ([around 25:00](http://adbackstage.libsyn.com/episode-171-compose-testing)), but it is in the context of testing. Would be great if this functionality was available in everywhere. Or maybe we could add the test dependency to our production app? – Maarten Aug 30 '21 at 19:31
  • @CommonsWare would you mind posting the actual solution? That Slack channel is only accessible to people with a verified Jetbrains account. – Maarten Aug 30 '21 at 19:53
  • @Maarten: "would you mind posting the actual solution?" -- that Slack thread is about a year old. Pretty much anything about Compose from back then is obsolete. See [this Medium post](https://medium.com/@johannblake/create-bitmaps-from-jetpack-composables-bdb2c95db51) and [corresponding GitHub repo](https://github.com/JohannBlake/bitmap-from-composable) for something much more recent. – CommonsWare Aug 30 '21 at 20:00
  • Thank you! This is still interleaving `View` with the composables, would be nice to have easy access to something akin to that `SemanticsNodeInteraction.captureToImage`. I've filed a feature request for it: https://issuetracker.google.com/issues/198182887 – Maarten Aug 30 '21 at 20:12
  • See https://github.com/JohannBlake/bitmap-from-composable – Johann Dec 08 '21 at 07:46

7 Answers7

10

As @Commonsware mentioned in the comment, and assuming this is not about screenshot testing:

According to official docs you can access the view version of your composable function using LocalView.current, and export that view to a bitmap file like this (the following code goes inside the composable function):

    val view = LocalView.current
    val context = LocalContext.current

    val handler = Handler(Looper.getMainLooper())
    handler.postDelayed(Runnable {
        val bmp = Bitmap.createBitmap(view.width, view.height,
            Bitmap.Config.ARGB_8888).applyCanvas {
            view.draw(this)
        }
        bmp.let {
            File(context.filesDir, "screenshot.png")
                .writeBitmap(bmp, Bitmap.CompressFormat.PNG, 85)
        }
    }, 1000)

The writeBitmap method is a simple extension function for File class. Example:

private fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
    outputStream().use { out ->
        bitmap.compress(format, quality, out)
        out.flush()
    }
}
Hrafn
  • 2,867
  • 3
  • 25
  • 44
superus8r
  • 751
  • 1
  • 11
  • 24
  • This doesn't seem to work, can you give more context around this answer? – alfietap Sep 28 '21 at 17:43
  • Of course, could you please specify which part do you need help with? Please make sure the first code is inside a composable function. – superus8r Sep 30 '21 at 09:29
  • This solution takes the whole window screenshot, not the region that is rendered for the composable function, ej. taking a screen shot of a composable that have a toolbar, you will have the toolbar also in your screenshot. – Akhha8 Oct 06 '21 at 06:11
  • @Akhha8 That's correct, you can get the current composable coordinates through Modifier.onGloballyPositioned {it.positionInRoot().. }, and then use them to crop the bitmap. More info on official docs: https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#(androidx.compose.ui.layout.LayoutCoordinates).positionInRoot() Please let me know if you need more assistance with it. :) – superus8r Oct 06 '21 at 10:05
8

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

enter image description here

Thracian
  • 43,021
  • 16
  • 133
  • 222
2

You can create a test, set the content to that composable and then call composeTestRule.captureToImage(). It returns an ImageBitmap.

Example of usage in a screenshot comparator: https://github.com/android/compose-samples/blob/e6994123804b976083fa937d3f5bf926da4facc5/Rally/app/src/androidTest/java/com/example/compose/rally/ScreenshotComparator.kt

Jose Alcérreca
  • 1,809
  • 17
  • 20
2

You can get position of a composable view inside the root compose view using onGloballyPositioned, and then draw the needed part of the root view into the Bitmap:

val view = LocalView.current
var capturingViewBounds by remember { mutableStateOf<Rect?>(null) }
Button(onClick = {
    val bounds = capturingViewBounds ?: return@Button
    val image = Bitmap.createBitmap(
        bounds.width.roundToInt(), bounds.height.roundToInt(),
        Bitmap.Config.ARGB_8888
    ).applyCanvas {
        translate(-bounds.left, -bounds.top)
        view.draw(this)
    }
}) {
    Text("Capture")
}
ViewToCapture(
    modifier = Modifier
        .onGloballyPositioned {
            capturingViewBounds = it.boundsInRoot()
        }
)

Note that if you have some view on top of ViewToCapture, like placed with a Box, it'll still be on the image.

p.s. there's a bug which makes Modifier.graphicsLayer effects, offset { IntOffset(...) }(you still can use offset(dp) in this case), scrollable and lazy views position not being displayed correctly on the screenshot. If you've faced it, please star the issue to get more attention.

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • This works well however one issue im finding is if i drag any of my composables to a new position, this method does not pick that up in the screenshot, any idea? – alfietap Oct 02 '21 at 08:35
  • @alfietap Try adding drag offset value to `translate`. If this doesn't help, ask a separate question with a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of your problem. – Phil Dukhov Oct 02 '21 at 09:00
  • I posted a question for you, thank you! – alfietap Oct 02 '21 at 09:28
  • I used this code to take a screenshot from a view inside Dialog, but it takes a screenshot of the views behind the dialog, any idea ? – Zakaria M. Jawas Aug 04 '22 at 20:48
  • @ZakariaM.Jawas make sure you declare `LocalView.current` inside `Dialog`. These are gonna be two different views when declared inside and outside of dialog content – Phil Dukhov Aug 05 '22 at 05:32
0

Using PixelCopy worked for me:

@RequiresApi(Build.VERSION_CODES.O)
suspend fun Window.drawToBitmap(
    config: Bitmap.Config = Bitmap.Config.ARGB_8888,
    timeoutInMs: Long = 1000
): Bitmap {
    var result = PixelCopy.ERROR_UNKNOWN
    val latch = CountDownLatch(1)

    val bitmap = Bitmap.createBitmap(decorView.width, decorView.height, config)
    PixelCopy.request(this, bitmap, { copyResult ->
        result = copyResult
        latch.countDown()
    }, Handler(Looper.getMainLooper()))

    var timeout = false
    withContext(Dispatchers.IO) {
        runCatching {
            timeout = !latch.await(timeoutInMs, TimeUnit.MILLISECONDS)
        }
    }

    if (timeout) error("Failed waiting for PixelCopy")
    if (result != PixelCopy.SUCCESS) error("Non success result: $result")

    return bitmap
}

Example:

val scope = rememberCoroutineScope()
val context = LocalContext.current as Activity
var bitmap by remember { mutableStateOf<Bitmap?>(null) }

Button(onClick = {
    scope.launch {
        //wrap in a try catch/block
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            bitmap = context.window.drawToBitmap()
        }
    }

}) {
    Text(text = "Take Screenshot")
}

Box(
    modifier = Modifier
        .background(Color.Red)
        .padding(10.dp)
) {
    bitmap?.let {
        Image(
            bitmap = it.asImageBitmap(),
            contentDescription = null,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
Bruno
  • 4,337
  • 12
  • 42
  • 55
-2

You can create a preview function with @Preview , run the function on phone or emulator and take the screenshot of the component.

Louis Duboscq
  • 359
  • 1
  • 3
  • 9
-2

I was looking for how to take screenshot of a composable in tests and this question appeared as the first in results. So, for future users who want to take/save/compare screenshots in tests or do screenshot testing, I put my answer here (thanks to this).

Ensure you have this dependency along with other Compose dependencies:

debugImplementation("androidx.compose.ui:ui-test-manifest:<version>")

Note: Instead of the above dependency, you can simply add an AndroidManifest.xml file in androidTest directory and add <activity android:name="androidx.activity.ComponentActivity" /> in manifestapplication element.
Refer to this answer.

The following is a complete example of taking, saving, reading, and comparing screenshots of a composable function called MyComposableFunction:

class ScreenshotTest {

    @get:Rule val composeTestRule = createComposeRule()

    @Test fun takeAndSaveScreenshot() {
        composeTestRule.setContent { MyComposableFunction() }
        val node = composeTestRule.onRoot()
        val screenshot = node.captureToImage().asAndroidBitmap()
        saveScreenshot("screenshot.png", screenshot)
    }

    @Test fun readAndCompareScreenshots() {
        composeTestRule.setContent { MyComposableFunction() }
        val node = composeTestRule.onRoot()
        val screenshot = node.captureToImage().asAndroidBitmap()

        val context = InstrumentationRegistry.getInstrumentation().targetContext
        val path = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        val file = File(path, "screenshot.png")
        val saved = readScreenshot(file)

        println("Are screenshots the same: ${screenshot.sameAs(saved)}")
    }

    private fun readScreenshot(file: File) = BitmapFactory.decodeFile(file.path)

    private fun saveScreenshot(filename: String, screenshot: Bitmap) {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        // Saves in /Android/data/your.package.name.test/files/Pictures on external storage
        val path = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        val file = File(path, filename)
        file.outputStream().use { stream ->
            screenshot.compress(Bitmap.CompressFormat.PNG, 100, stream)
        }
    }
}

I've also answered a similar question here.

Mahozad
  • 18,032
  • 13
  • 118
  • 133