DEV Community

loading...
Cover image for Drawing and painting in Jetpack Compose #1

Drawing and painting in Jetpack Compose #1

tkuenneth profile image Thomas Kuenneth Updated on ・4 min read

Recently I blogged about using shapes in Jetpack Compose. As you have seen, RectangleShape, CircleShape and GenericShape are great for applying simple forms (shapes) to composables. But what about drawing lines, dots or circles that are not filled or have open sides? Let's investigate how we can become an artist. Now, that may sound a little over the top. But I did it on purpose. Because artists often paint on canvas, and Canvas is very important for us, too. Take a look:

Two lines and a filled circle

@Composable
fun SimpleCanvas() {
  Canvas(modifier = Modifier.fillMaxWidth().preferredHeight(128.dp),
    onDraw = {
      drawLine(
        Color.Black, Offset(0f, 0f),
        Offset(size.width - 1, size.height - 1)
      )
      drawLine(
        Color.Black, Offset(0f, size.height - 1),
        Offset(size.width - 1, 0f)
      )
      drawCircle(
        Color.Red, 64f,
        Offset(size.width / 2, size.height / 2)
      )
    })
}
Enter fullscreen mode Exit fullscreen mode

Canvas is a composable that allows you to

specify an area on the screen and perform canvas drawing
on this area. You MUST specify size with modifier, whether
with exact sizes via Modifier.size modifier, or relative to
parent, via Modifier.fillMaxSize, ColumnScope.weight, etc.
If parent wraps this child, only exact sizes must be specified.

Drawing instructions are given inside onDraw. My example produces two lines and a filled circle. Instead of solid red we could for example use a linear gradient. But before I show you how to achieve this, let's create an outline instead.

Circle with a dashed red line

drawCircle(
  Color.Red, 64f,
  Offset(size.width / 2, size.height / 2),
  style = Stroke(width = 8f,
    pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
  ),
)
Enter fullscreen mode Exit fullscreen mode

Stroke is no composable but a class. Its pathEffect receives a NativePathEffect, which is a typealias for android.graphics.PathEffect. It is extended by (among others) android.graphics.DashPathEffect. Its documentation explains:

The intervals array must contain an even number of entries
(>=2), with the even indices specifying the "on" intervals,
and the odd indices specifying the "off" intervals.

So, in my example the distances for "on" and "off" are equal (10f). The Stroke has a width (or thickness) of 8f. How big this is depends on the device configuration. Now, let's turn to gradients, shall we?

Circle filled with a linear gradient

@Composable
fun CanvasWithGradient() {
  Canvas(modifier = Modifier.fillMaxWidth().preferredHeight(128.dp),
    onDraw = {
      val gradient = LinearGradient(
        listOf(Color.Blue, Color.Black),
        startX = size.width / 2 - 64, startY = size.height / 2 - 64,
        endX = size.width / 2 + 64, endY = size.height / 2 + 64,
        tileMode = TileMode.Clamp
      )
      drawCircle(
        gradient, 64f,
      )
    })
}
Enter fullscreen mode Exit fullscreen mode

Have you noticed that I did not provide the center of the circle? Thanks to the default value in drawCircle() this is not necessary. Nonetheless I am using (that is, computing) the values (size.width / 2 and size.height / 2), because I need them for the definition of my linear gradient. I want it to cover only the area of the circle. Its upper left corner is startX, startY and the lower right corner is endX, endY.

A radial gradient looks like this:

Circle with a radial gradient

val gradient = RadialGradient(
  listOf(Color.Black, Color.Blue),
  centerX = center.x, centerY = center.y,
  radius = 64f
)
drawCircle(
  gradient, 64f,
)
Enter fullscreen mode Exit fullscreen mode

center.x and center.y specify the center of my canvas. This is used for the center of the circle (its default value), so I can reuse it for my radial gradient. There are more gradients. You may, for example, want to take a look at VerticalGradient or HorizontalGradient.

To conclude this post, let's draw some individual pixels. Here is a composable that draws a sinus curve in a carthesian coordinate system.

a sinus curve in a carthesian coordinate system

@Composable
fun SinusPlotter() {
  Canvas(modifier = Modifier.fillMaxSize(),
    onDraw = {
      val middleW = size.width / 2
      val middleH = size.height / 2
      drawLine(Color.Gray, Offset(0f, middleH), Offset(size.width - 1, middleH))
      drawLine(Color.Gray, Offset(middleW, 0f), Offset(middleW, size.height - 1))
      val points = mutableListOf<Offset>()
      for (x in 0 until size.width.toInt()) {
        val y = (sin(x * (2f * PI / size.width)) * middleH + middleH).toFloat()
        points.add(Offset(x.toFloat(), y))
      }
      drawPoints(
        points = points,
        strokeWidth = 4f,
        pointMode = PointMode.Points,
        color = Color.Blue
      )
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

drawPoints() receives a list if Offset instances. PointMode.Points means: draw individual points. Please note that I deliberately set strokeWidth because its default Stroke.HairlineWidth led to no visible output.

One more thing... You may be wondering how the axes of the coordinate system can look like arrows. While drawLine() can recieve both strokeWidth and cap, the latter one is used for both ends. Also, while there is StrokeCap.Round, Square and Butt, there seems to be no Arrow. So for now my resolution would be to draw the arrow by myself. Here is how this might look like for the horizontal axis:

drawPath(
  path = Path().apply {
    moveTo(size.width - 1, middleH)
    relativeLineTo(-20f, 20f)
    relativeLineTo(0f, -40F)
    close()
  },
  Color.Gray,
)
Enter fullscreen mode Exit fullscreen mode

As you can see, I am using an old friend, Path, which is then passed to drawPath(). The vertical axis has just a slightly changed set of drawing instructions:

moveTo(size.width - 1, middleH)
relativeLineTo(-20f, 20f)
relativeLineTo(0f, -40F)
Enter fullscreen mode Exit fullscreen mode

That's it for today. I hope to follow up on this soon. Kindly share your thoughts in the comments.


source

Discussion

pic
Editor guide