•
8 min read

Convert Canvas to Bitmap in Jetpack Compose using CanvasDrawScope

Table of Contents

Bitmap is a versatile format used to represent an image in Jetpack Compose (or Compose Multiplatform), acquiring the ability to transform your Canvas drawings into that format allows you to share and store your drawings, and many possibilities in between, but how do I go with that? with CanvasDrawScope it is now a possibility!

Why Use CanvasDrawScope?

To answer such question we can compare CanvasDrawScope to other existing methods that achieve similar results, a very noteworthy approach is using android.graphics.Picture, you can look up that fantastic article here, but for a quick recap, it basically renders your work into screen and using the Picture class, it captures the rendered image of your Canvas Composable. It is an open discussion to which approach is better given every project necessities and goals. I will highlight the pros and cons of CanvasDrawScope, and why it shines with its unique advantages:

No Visual Noise

Picture requires rendering on-screen to capture the drawn image, which is not ideal in some scenarios where showing that drawing would impact user experience. With CanvasDrawScope, you can perform operations without showing them on-screen, making it ideal for scenarios like pre-processing images before feeding them to an AI model.

Code Separation

Picture requires adding the logic to the Modifier, so the image capture logic is mixed with the view (composables). But with CanvasDrawScope you achieve better code separation, as you basically put the Canvas drawing process into separate function inside your Controller/ViewModel.

Enhanced Performance with Coroutines

Rendering on Screen always happens on single main thread, so complex drawings might cause UI thread hang-ups, resulting in visual stutter and lag. This limits how complex your drawings can be if you are using Picture method, to leverage the use of coroutines, using CanvasDrawScope you can simply highlight your function as suspend. This facilitates smoother performance by running drawing operations on a separate co-routine.

Multiplatform Support and AndroidX

CanvasDrawScope class is part of the androidx library, the new recommended Support Library by google, it is imported under androidx.compose.ui.graphics.drawscope.CanvasDrawScope. However android.graphics.Picture is part of the legacy Support Library. So, building your app over androidx makes it future-proof, as android lib is no longer maintained. This brings me to the next point, Compose Multiplatform, android.* is an Android only solution, if you are planning or in the process of moving your app to other platforms, use androidx.* modules as they already do support multiplatform.

Sadly No Composable Conversion support

CanvasDrawScope does not render any Composables into bitmap, that means, no buttons, no fields, and so on. It renders Canvas of course, and only Canvas. This is the only limitation I could find with it, compared to android.graphics.Picture which you can set it in the Modifier of (presumably) any Composable and it works. If you have no business with converting your composables into images, CanvasDrawScope should be the way to go.

Code time!

The full example

Here is a simple example

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection

fun drawToBitmap(): ImageBitmap {
  val drawScope = CanvasDrawScope()
  val size = Size(400f, 400f) // simple example of 400px by 400px image
  val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt())
  val canvas = Canvas(bitmap)

  drawScope.draw(
    density = Density(1f),
    layoutDirection = LayoutDirection.Ltr,
    canvas = canvas,
    size = size,
  ) {
    // Draw whatever you want here; for instance, a white background and a red line.
    drawRect(color = Color.White, topLeft = Offset.Zero, size = size)
    drawLine(
      color = Color.Red,
      start = Offset.Zero,
      end = Offset(size.width, size.height),
      strokeWidth = 5f
    )
  }
  return bitmap
}

Let’s go through that example bit by bit, I left the imports up there to help clarify any import confusion you meet when you are copying the code

Setting up the scope

First, we initiate some variables

val drawScope = CanvasDrawScope()
val size = Size(400f, 400f) // simple example of 400px by 400px image
val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt())
val canvas = Canvas(bitmap)
  • Starting with draw scope instance used for drawing later.
  • A size, just the boundaries of drawing or image size.
  • Then we have the ImageBitmap, notice here we have ImageBitmap instead of Bitmap, imported from androidx.compose.ui.graphics.ImageBitmap, this example works on both Android or Compose Multiplatform projects, to get a Bitmap instance, we simply use imageBitmap.asAndroidBitmap() method that is available only on Android projects.
  • Canvas() is just a simple Canvas wrapped over bitmap. Pay attention! to import from androidx.compose.ui.graphics.Canvas, Do not import androidx.compose.foundation.Canvas which is a the Composable Canvas() that you use in your view side, hope I made this point clear enough, trying to save someone precious hours that they could be wasting on debugging a non-existent problem!

Density and layout

Moving on to the next bit

drawScope.draw(
  density = Density(1f),
  layoutDirection = LayoutDirection.Ltr,
  canvas = canvas,
  size = size,
) {
// ...

We supply density as 1, if you don’t know what this parameter is, just set it to 1, and chances are 99.99% of cases you will be fine. Still curious? Well it’s a float value, density 1 means the Canvas is the same density as its size, we have 400px X 400px image, if we want to know its center that would simply be Point(200, 200). If we put density 2 means the Canvas is 2 times denser than its size (doubled the pixel size of both width and the height), I like to call it Size Dilation, if we have that with our image 400px X 400px than the the Canvas will view it inside the draw scope as 800px X 800px drawing area, so the center of the Canvas would be Point(400, 400). If you see your drawings are smaller than expected in the resulted image, double-check your size is set correctly, only after you are certain that the size is not the problem, you can tweak with density to see what might work for you.

My journey discovering the Physics laws behind Jetpack Compose My journey discovering the Physics laws behind Jetpack Compose

  • layoutDirection determines your Point(0, 0) position, that is Ltr means its the Top-Left to Right it should be your default, unless you need it from From Top-Right to Left, set it to Rtl.
  • Both lines canvas = canvas and size = size are no-brainer.

Drawing on the bitmap

Then draw whatever you would like, note that drawRect, drawLine, and other methods are only available inside draw scope, every drawing step is applied directly to our bitmap variable.

// Draw whatever you want here; for instance, a white background and a red line.
drawRect(color = Color.White, topLeft = Offset.Zero, size = size)
drawLine(
  color = Color.Red,
  start = Offset.Zero,
  end = Offset(size.width, size.height),
  strokeWidth = 5f
)

Returning the bitmap

Lastly we return the filled ImageBitmap.

return bitmap

Using it in the app

Calling that drawToBitmap() inside my app to draw it and confirm it is working as expected.

@Composable
@Preview
fun App() {
  val image = remember { drawToBitmap() }

  MaterialTheme {
    Canvas(modifier = Modifier.fillMaxSize()) {
      drawImage(image)
    }
  }
}

This is one example use case, results below.

Getting an Android Bitmap

Ideally you might want to store the resulted bitmap into a file or something, to stay on topic, I’m not going to delve onto it, but again, here is how to get Bitmap as in android.graphics.Bitmap from ImageBitmap.

val image = drawToBitmap().asAndroidBitmap()
  • Another Improvement

One thing I always like to do is to organize the code using extension methods in Kotlin, it makes to code way cleaner and understandable, here I created CanvasDrawScope.asBitmap() for that. It is an optional step.

The asBitmap() extension

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection

fun CanvasDrawScope.asBitmap(size: Size, onDraw: DrawScope.() -> Unit): ImageBitmap {
  val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt())
  draw(Density(1f), LayoutDirection.Ltr, Canvas(bitmap), size) { onDraw() }
  return bitmap
}

fun drawToBitmap(): ImageBitmap {
  val drawScope = CanvasDrawScope()
  val size = Size(400f, 400f)
  val bitmap = drawScope.asBitmap(size) {
    // Draw whatever you want here; for instance, a white background and a red line.
    drawRect(color = Color.White, topLeft = Offset.Zero, size = size)
    drawLine(
      color = Color.Red,
      start = Offset.Zero,
      end = Offset(size.width, size.height),
      strokeWidth = 5f
    )

  }

  return bitmap
}

The full working demo can be found here, it is a Compose Multiplatform project.

Happy coding day.

Originally published on Medium.