DEV Community

Cover image for Drawing and painting in Jetpack Compose #1
Thomas Künneth
Thomas Künneth

Posted on • Updated on

Drawing and painting in Jetpack Compose #1

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

Top comments (6)

Collapse
 
cpratik711 profile image
cpratik711 • Edited

Inside the Jetpack Compose Beta 02 version

 drawCircle(
                Color.Red, 64f,
                Offset(size.width / 2, size.height / 2),
                style = Stroke(width = 8f,
                    pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f,20f),10f)
                ),
            )

Enter fullscreen mode Exit fullscreen mode
Collapse
 
varshakulkarni profile image
Varsha Kulkarni

Thank you! The above fixed the issue with the beta.
From the documentation

Introduced PathEffect graphics API to provide different patterns to stroked shapes. Deprecated usage of NativePathEffect in favor of expect/actual implementation of PathEffect. (I5e976, b/171072166)
Enter fullscreen mode Exit fullscreen mode

This is done in alpha09 release.

Collapse
 
tkuenneth profile image
Thomas Künneth

Sorry to reply late; last couple of days have been very busy. Thanks for sharing the resolution. If I am not mistaken, in one of my later posts I used PathEffect, too. Again, thank you very much for keeping me posted. Greatly appreciated.

Collapse
 
varshakulkarni profile image
Varsha Kulkarni • Edited
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

I keep getting this error after upgrading to beta. How do we solve that?
"Type mismatch: inferred type is DashPathEffect but PathEffect was expected."

Collapse
 
varshakulkarni profile image
Varsha Kulkarni • Edited

Got a workaround for this.

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

This has to be changed to this:

drawCircle(
  Color.Red, 64f,
  Offset(size.width / 2, size.height / 2),
  style = Stroke(width = 8f,
    pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
  ),
)
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tkuenneth profile image
Thomas Künneth

I'll loook into this shortly. Will keep you posted.