PatilShreyas / Capturable

🚀Jetpack Compose utility library for capturing Composable content and transforming it into Bitmap Image🖼️
https://patilshreyas.github.io/Capturable/
MIT License
1.07k stars 39 forks source link

Possible to capture Composables not visible on screen #33

Open panoramix360 opened 2 years ago

panoramix360 commented 2 years ago

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

renanboni commented 2 years ago

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

panoramix360 commented 2 years ago

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.

jayesh83 commented 2 years ago

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

PatilShreyas commented 2 years ago

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.

renanboni commented 2 years ago

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.

panoramix360 commented 2 years ago

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?

renanboni commented 2 years ago

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

StephenVinouze commented 1 year ago

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 🤔

akardas16 commented 1 year ago

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?) {}
            })
kezc commented 8 months ago

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)

yschimke commented 8 months ago

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

PatilShreyas commented 8 months ago

Let's keep an eye on it 👁️

PatilShreyas commented 8 months ago

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.

yschimke commented 8 months ago

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 {
                this@drawWithContent.drawContent()
            }
            drawLayer(layer)
        }
    }

But I'd have to try to confirm.

PatilShreyas commented 8 months ago

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

yschimke commented 8 months ago

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

yschimke commented 8 months ago

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

PatilShreyas commented 8 months ago

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

LZRight123 commented 7 months ago

我尝试了一种快速解决方法,并在这里进行了解释: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