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.
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:
- What is Canvas?
- How do we draw on Canvas?
- What happens under the Canvas Composable function?
- 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 expect
variables 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!