Canvas internals and its working

This is going to be a short blog about how Compose Canvas works under the hood and interacts with the Android native Canvas.

ยท

6 min read

Canvas internals and its working

Thank you for dropping by, we are here to learn about the Jetpack Compose Canvas stuff. Let us be clear here, we are not going to learn how we can implement the canvas in compose. You can check that here.

In this blog, we are going to see:

  1. What is Canvas?
  2. How do we draw on Canvas?
  3. What happens under the Canvas Composable function?
  4. Something Something Something!

What is Canvas?

We can simply call it our (Android developer's) drawing sheet where we show our creativity!

Anyone can put paint on a canvas, but only a true master can bring the painting to life: Shaun Jeffery

In our terms, our phone's real estate is the canvas for us and we bring the designer's imaginations to reality. We draw the custom UI to make it useable to the users.

How do we draw on Canvas?

Let us be clear to start with, Compose's canvas is drawing on top of View's canvas itself!

In Jetpack Compose, we use the composable to draw any kind of UI. For eg:

Text(text = "Hey Compose!")

So, in terms of compose, we use two different ways to draw custom drawings. Let's see them one by one.

Box(modifier = Modifier.drawBehind {
   // Drawing goes here (1)
}) {
  // UI (2)
}

Here, we can use drawBehind to draw our custom UI and it be drawn behind the layout (1). If we keep the Box's UI (2) empty then all the drawing be drawn same like Canvas.

Second way to draw custom UI is to use Canvas composable itself. For eg:

Canvas(modifier = Modifier) {
 // Drawing goes here
}

Here, we do not need a drawBehind function to draw something. Right?

Let us hold on for a moment to this conclusion.

What happens under the Canvas Composable function?

Canvas and drawBehind expose the DrawScope that helps us to draw the UI we want.

DrawScope gives us the freedom to use its different functions like drawCircle, drawLine, etc to draw without excessive need to understand things about it (Let's talk about the DrawScope a tad later).

When Canvas draws the UI, it uses drawBehind function (we saw above) itself to do the thing ๐Ÿ™ˆ

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

Here, Canvas takes Spacer composable and passes the reference of modifier, and adds the drawBehind on the modifier and takes the onDraw to draw. Spacer composable is nothing but a composable that is used to add a space based on the size passed on using the Modifier like fillMaxSize or using size.

So, in a simply, we can say Canvas is like a wrapper composable on top of Spacer.

Let's move on.

Now, we have to understand what the drawBehind functions do so that we can understand the internal implementation.

drawBehind()

fun Modifier.drawBehind(
    onDraw: DrawScope.() -> Unit
) = this.then(
    DrawBackgroundModifier(
        onDraw = onDraw,
        inspectorInfo = debugInspectorInfo {
            name = "drawBehind"
            properties["onDraw"] = onDraw
        }
    )
)

drawBehind is a generic extension function that takes the DrawScope and creates an object of DrawBackgroundModifier class which is an implementation of DrawModifier and passes the onDraw and an inspectorInfo.

InspectorInfo in this case, is used to handle the information for the Canvas.

But what is this DrawScope here? Is it just a lambda? Answer is NO!

Now is the time to check upon our DrawScope!

As discussed earlier, we know DrawScope enables us with its power to do custom drawings and shapes right. It also provides us with a bit more information and details about the Canvas we will be drawing onto.

Basically, it stands by the name of providing us the scope for drawing like DrawContext, Size, Center info and LayoutDirection.

Here, DrawContext is the main player in entire canvas tbh!

Size is coming from:

    val size: Size
        get() = drawContext.size

and the same way center looks,

val center: Offset
        get() = drawContext.size.center

So, Since we can see the drawContext has access to size, in the same way it has the access to a Canvas variable that is the part of,

androidx.compose.ui.graphics.Canvas

Here, Canvas is the interface with functions like save, restore, translate, etc. In the same package, we have a,

internal expect fun ActualCanvas(image: ImageBitmap): Canvas

expect is used to have an actual implementation for platform-specific things.

that is used to create a new instance for Canvas function by implementing the Canvas interface present in drawContext that has an ImageBitmap like,

fun Canvas(image: ImageBitmap): Canvas = ActualCanvas(image)

and similarly, we have other expected class like:

expect class NativeCanvas

Now, these two expect have their actual implementation like:

actual typealias NativeCanvas = android.graphics.Canvas

and

internal actual fun ActualCanvas(image: ImageBitmap): Canvas =
    AndroidCanvas().apply {
        internalCanvas = android.graphics.Canvas(image.asAndroidBitmap())
    }

Wooffff! Too many and multiple types of canvas.

Let's talk about AndroidCanvas class then here!

Here, AndroidCanvas is a class implementing the Canvas interface and overrides all its functions of it like save and restore.

But more than that, it has an internalCanvas variable that is of the View type canvas and which is assigned to the Canvas in the ActualCanvas function above.

So, now in the AndroidCanvas class, we use internalCanvas to access all the internal methods like drawArc, drawCircle, etc like,

override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
        internalCanvas.drawCircle(
            center.x,
            center.y,
            radius,
            paint.asFrameworkPaint()
        )
}

Over the top, Canvas is just a wrapper Composable function that utilizes the native Canvas to draw.

This is how we can see Compose's Canvas uses the internal Canvas and its properties to let us draw things and make the work.

Paint in Canvas Compose.

Now, in the above code snippet we do see, to draw a circle we do need a paint reference. Paint is used to provide all the visual elements like colors, etc.

Paint is also present in the View's canvas and has been used extensively there. But in Compose's Canvas, we have an Interface Paint that is mapped to the Native Paint.

Internally it is designed the same way as the Canvas, where Paint is an interface like:

interface Paint {}

Where, it has all the property variables of a paint. like alpha, blendMode etc. To our notice, it also has a function called asFrameworkPaint() that will be used to access the Paint from android.graphics.Paint package.

But, in the same file Paint.kt we have some more expectvariables like we had in the case of Canvas.

Here, the variables are:

expect class NativePaint

expect fun Paint(): Paint

These variables are provided the value using actual keyword in the same package but in a different file i.e. AndroidPaint.android.kt like:

actual typealias NativePaint = android.graphics.Paint

actual fun Paint(): Paint = AndroidPaint()

Here if we see, NativePaint is given the value of the Native Paint that we have in the pre-Compose era and we assigned an object to the paint function that implements the Paint interface.

And the rest of all the values of Paint in compose we assigned from Paint in android.graphics package.

That is all for the blog, and let's keep learning.

Let's catch up on Twitter!

Did you find this article valuable?

Support Himanshu Singh by becoming a sponsor. Any amount is appreciated!

ย