Capturable icon indicating copy to clipboard operation
Capturable copied to clipboard

Possible to capture Composables not visible on screen

Open panoramix360 opened this issue 2 years ago • 19 comments

Is it possible to capture another version of a Composable just to the Capturable and capture a bitmap?

panoramix360 avatar Apr 05 '22 22:04 panoramix360

From what I've seen is not possible, would be nice to have that option though :) @PatilShreyas wdyt?

renanboni avatar Apr 08 '22 19:04 renanboni

Maybe using something like a canvas or a graphics context, I don't know if it's really possible.

But has great advantages in doing so, sometimes the app has the need to generate another version of a shown component.

panoramix360 avatar Apr 11 '22 22:04 panoramix360

I had the same problem, I had to create another screen for the desired image capturing

jayesh83 avatar May 02 '22 13:05 jayesh83

If something which is not yet rendered on screen then it's not possible to capture. This is not case only limited to compose but it's also not possible in View as well.

PatilShreyas avatar May 07 '22 07:05 PatilShreyas

hey @PatilShreyas, in fact, it is possible to render even if the view (regular Android view not compose) hasn't been laid out yet, but for compose I think it's not possible so far.

renanboni avatar May 10 '22 15:05 renanboni

hey @PatilShreyas, in fact, it is possible to render even if the view (regular Android view not compose) hasn't been laid out yet, but for compose I think it's not possible so far.

Can you describe how this solution using View could work? Maybe we can abstract away from Jetpack Compose and use AndroidView or something like that.

Do you think it's possible?

panoramix360 avatar May 10 '22 19:05 panoramix360

hey @PatilShreyas, in fact, it is possible to render even if the view (regular Android view not compose) hasn't been laid out yet, but for compose I think it's not possible so far.

Can you describe how this solution using View could work? Maybe we can abstract away from Jetpack Compose and use AndroidView or something like that.

Do you think it's possible?

sure, I'll write a snippet over the weekend and will share it here

renanboni avatar May 11 '22 12:05 renanboni

Creating a Bitmap from a View is clearly doable without being visible on the screen. But It must be laid out with the desired width and height then you can use the drawToBitmap() KTX extension method. A typical example when you need such things is when you draw custom markers on a map since the renderer accepts a Bitmap that must be created before it will be attached to the screen. I've written this snippet: https://gist.github.com/StephenVinouze/6cbba532cb202fa9eb507f5224f73462

As for Compose, there would be a way to capture a Bitmap from a Composable not visible on the screen given this article. Not sure I'd recommend it though 🤔

StephenVinouze avatar Feb 03 '23 09:02 StephenVinouze

If something which is not yet rendered on screen then it's not possible to capture. This is not case only limited to compose but it's also not possible in View as well.

could you check this library https://github.com/guhungry/android-photo-manipulator I can overlay images or text on each other without showing on screen. Example code

 Glide.with(context).asBitmap()
            .load(backgroundUrl)
            .into(object : CustomTarget<Bitmap>() {
                override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
                    // saveBitmapAsImageToDevice(resource)
                    val point = PointF()
                    point.x = 30f
                    point.y = 30f

                   // val mIcon = BitmapFactory.decodeResource(resources, R.drawable.overlay)
                    // BitmapUtils.overlay(background,mIcon, point)
                    BitmapUtils.printText(resource, "Made with\nSnapface", point, Color.WHITE, 32f)

                    context.saveBitmapAsImageToDevice(resource)


                }

                override fun onLoadCleared(placeholder: Drawable?) {}
            })

akardas16 avatar Nov 06 '23 14:11 akardas16

I see someone has done it on StackOverflow https://stackoverflow.com/a/74814850/6745085 Sadly, it doesn't handle many corner cases as this library does (e.g. doesn't work with Coil)

kezc avatar Mar 05 '24 11:03 kezc

Maybe something to follow along with https://issuetracker.google.com/issues/288494724

yschimke avatar Mar 14 '24 08:03 yschimke

Let's keep an eye on it 👁️

PatilShreyas avatar Mar 15 '24 05:03 PatilShreyas

I tried it with a hack for a quick workaround and explained it here: https://stackoverflow.com/a/78170757/11326621


There's a way for it to capture the composable content by rendering composable content into an Invisible window and capturing it secretly from there.

Create a invisible composable

@Composable
fun InvisibleContent(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val windowManager = context.getSystemService<WindowManager>()!!

    DisposableEffect(key1 = content) {
        val composeView = ComposeView(context).apply {
            setParentCompositionContext(null)
            setContent {
                content()
            }
            setOwners(context.findActivity())
        }

        windowManager.addView(
            /* view = */ composeView,
            /* params = */ WindowManager.LayoutParams(
                /* w = */ WindowManager.LayoutParams.WRAP_CONTENT,
                /* h = */ WindowManager.LayoutParams.WRAP_CONTENT,
                /* _type = */ WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,
                /* _flags = */ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                /* _format = */ PixelFormat.TRANSLUCENT
            )
        )

        onDispose { windowManager.removeView(composeView) }
    }
}

private fun View.setOwners(fromActivity: ComponentActivity) {
    if (findViewTreeLifecycleOwner() == null) {
        setViewTreeLifecycleOwner(fromActivity)
    }
    if (findViewTreeViewModelStoreOwner() == null) {
        setViewTreeViewModelStoreOwner(fromActivity)
    }
    if (findViewTreeSavedStateRegistryOwner() == null) {
        setViewTreeSavedStateRegistryOwner(fromActivity)
    }
}

/**
 * Traverses through this [Context] and finds [Activity] wrapped inside it.
 */
private fun Context.findActivity(): ComponentActivity {
    var context = this
    while (context is ContextWrapper) {
        if (context is ComponentActivity) return context
        context = context.baseContext
    }
    throw IllegalStateException("Unable to retrieve Activity from the current context")
}

Usage

@Composable
fun CaptureDemo() {
    val captureController = rememberCaptureController()
    val uiScope = rememberCoroutineScope()

    InvisibleContent {
        Ticket(modifier = Modifier.capturable(captureController))
    }
    
    Button(
        onClick = {
            uiScope.launch {
               ticketBitmap = captureController.captureAsync().await()
            }
        }
    ) {
        Text("Preview Ticket Image")
    }
}

Here, the content of the Ticket composable won't be displayed on the UI and it won't take place in the UI in the same View along with relative composables. Instead, it'll secretly added on another window with no visibility.

I've tried this and it works. Let me know your thoughts and if it works for you.

PatilShreyas avatar Mar 16 '24 06:03 PatilShreyas

I think this API allows this without all your window manager code

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/DrawingPrebuiltGraphicsLayerTest.kt?q=buildLayer%20rememberGraphicsLayer

You can create a new graphics layer, the draw into it as well as or instead of the content

            val graphicsLayer = rememberGraphicsLayer()
...
    @Composable
    private fun Modifier.drawIntoLayer(
        layer: GraphicsLayer = obtainLayer()
    ): Modifier {
        return drawWithContent {
            layer.buildLayer {
                [email protected]()
            }
            drawLayer(layer)
        }
    }

But I'd have to try to confirm.

yschimke avatar Mar 16 '24 07:03 yschimke

But I think this will occupy a space in UI. We don't want that.

PatilShreyas avatar Mar 16 '24 08:03 PatilShreyas

Let me see if that can be avoided. I suspect it can.

yschimke avatar Mar 16 '24 09:03 yschimke

Yeah, I couldn't get it working. I was trying to create a new graphics layer, and a modifier to avoid drawing to the screen, and then either capture a bitmap with beginRecording, or just draw to the new layer and write that to a canvas/ImageBitmap.

But it's still treating the Composables as part of the main composition, so I can't actually change the size to something greater.

I suspect I need non landed CLs to get this working. https://android-review.googlesource.com/c/platform/frameworks/support/+/2969199/4

yschimke avatar Mar 17 '24 11:03 yschimke

@yschimke current API has these limitations, that's why this solution so far has worked (even if it's a hack)

PatilShreyas avatar Mar 17 '24 16:03 PatilShreyas

我尝试了一种快速解决_方法_,并在这里进行了解释:https://stackoverflow.com/a/78170757/11326621

有一种方法可以通过将可组合内容渲染到不可见窗口中并从那里秘密捕获它来捕获可组合内容。

创建一个不可见的可组合项

@Composable
fun InvisibleContent(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val windowManager = context.getSystemService<WindowManager>()!!

    DisposableEffect(key1 = content) {
        val composeView = ComposeView(context).apply {
            setParentCompositionContext(null)
            setContent {
                content()
            }
            setOwners(context.findActivity())
        }

        windowManager.addView(
            /* view = */ composeView,
            /* params = */ WindowManager.LayoutParams(
                /* w = */ WindowManager.LayoutParams.WRAP_CONTENT,
                /* h = */ WindowManager.LayoutParams.WRAP_CONTENT,
                /* _type = */ WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,
                /* _flags = */ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                /* _format = */ PixelFormat.TRANSLUCENT
            )
        )

        onDispose { windowManager.removeView(composeView) }
    }
}

private fun View.setOwners(fromActivity: ComponentActivity) {
    if (findViewTreeLifecycleOwner() == null) {
        setViewTreeLifecycleOwner(fromActivity)
    }
    if (findViewTreeViewModelStoreOwner() == null) {
        setViewTreeViewModelStoreOwner(fromActivity)
    }
    if (findViewTreeSavedStateRegistryOwner() == null) {
        setViewTreeSavedStateRegistryOwner(fromActivity)
    }
}

/**
 * Traverses through this [Context] and finds [Activity] wrapped inside it.
 */
private fun Context.findActivity(): ComponentActivity {
    var context = this
    while (context is ContextWrapper) {
        if (context is ComponentActivity) return context
        context = context.baseContext
    }
    throw IllegalStateException("Unable to retrieve Activity from the current context")
}

用法

@Composable
fun CaptureDemo() {
    val captureController = rememberCaptureController()
    val uiScope = rememberCoroutineScope()

    InvisibleContent {
        Ticket(modifier = Modifier.capturable(captureController))
    }
    
    Button(
        onClick = {
            uiScope.launch {
               ticketBitmap = captureController.captureAsync().await()
            }
        }
    ) {
        Text("Preview Ticket Image")
    }
}

在这里,可组合项的内容Ticket不会显示在 UI 上,也不会与相关可组合项一起出现在同一视图的 UI 中。相反,它会秘密地添加到另一个不可见的窗口上。

我已经尝试过这个并且有效。让我知道您的想法以及它是否适合您。

The content in InvisibleContent is displayed at the front of the screen

LZRight123 avatar Apr 09 '24 02:04 LZRight123