Canvas in Android with Jetpack Compose: drawCircle, drawRect, and drawArc step by step

- Andrés Cruz - ES En español

Video thumbnail

In this article, you will learn how to draw geometric shapes and custom figures in Android using the Canvas API. We will first explore the modern approach with Jetpack Compose, which uses @Composable functions for faster, declarative, and simpler UI development, and finally, we will review the traditional way (now considered "Legacy") based on the View system and the Canvas class. Although this classic approach remains functional and useful for older projects, modern Android development has evolved significantly toward Compose.

A Canvas is simply a surface or canvas on which you can draw. With the primitives offered by the Canvas API in Android, it is possible to draw lines, circles, ovals, squares, rectangles, and even text, in addition to controlling the color, stroke thickness, and size of each figure. In this post, we take our first steps with the Canvas class in Android, from the basics to a practical example with animated stats bars.

In a previous installment, we saw how to perform an Internet connection in Android with Retrofit. At the end of this article, we will leverage that knowledge to display an energy bar using Canvas.

The Modern Approach: Jetpack Compose

The way user interfaces are built in Android has evolved considerably. The traditional approach based on View and XML files is still active in millions of apps, but the new way recommended by Google is Jetpack Compose: a declarative UI framework that eliminates the need to inflate layouts in XML and allows describing the interface directly with Kotlin code.

Drawing with Canvas in Jetpack Compose

The concept of Canvas still exists in Jetpack Compose, but it is used in a more direct and declarative way inside a @Composable function. It is no longer necessary to create a class extending View or override the onDraw method. Instead, you use the Canvas composable directly in your UI tree, making it much more intuitive and easier to maintain.

For example, to draw a circle, we simply use the Canvas composable and call the drawCircle function:

@Composable
fun MiCirculo() {
    Canvas(modifier = Modifier.fillMaxSize()) {
        val centro = Offset(x = size.width / 2, y = size.height / 2)
        drawCircle(
            color = Color.Blue,
            radius = 100f,
            center = centro
        )
    }
}

As you can see, the code is concise and integrates naturally with the rest of Compose's declarative UI. Inside the lambda block of the Canvas composable, you have access to a DrawScope that exposes properties like size (total canvas dimensions) and center (the automatic midpoint), as well as all drawing functions: drawCircle, drawRect, drawArc, drawLine, among others. This is the current standard for UI development on Android.

Drawing Our First Figure with Canvas: A Face

We are going to learn the basics of working with Canvas in Android using composables. The first thing you should know is that Canvas is not intended to replace static or simple shapes that you could achieve with other Compose components; its true potential lies in creating complex shapes, custom data charts, and, above all, implementing advanced animations that would be very difficult to achieve with standard widgets.

Although people sometimes shy away from it because of math, it actually relies on basic operations we've known since childhood: circles, squares, rectangles, triangles, arcs, and polygons. Once you understand how to position and scale each primitive, the rest is creativity.

Fundamental Concepts: Dimensions and Coordinate System

To draw on the Android Canvas, the coordinate system has its origin (0, 0) at the top-left corner. The X-axis grows to the right and the Y-axis grows downward. This is different from the classical Cartesian plane and is important to keep in mind when positioning figures.

To draw, we only need to remember how the dimensions of each basic shape are defined:

  • Circle: Defined by its radius, which is the distance from the center to the edge. The larger this value, the larger the circle. It is drawn with drawCircle(color, radius, center).
  • Square: By definition, all its sides are equal (equal height and width). In Canvas, it is drawn with drawRect or drawRoundRect passing a Size with equal width and height values.
  • Rectangle: Varies in both height (height) and width (width). It is drawn with drawRect specifying a Size object or the topLeft and bottomRight coordinates.

Color is the simplest aspect: if you want a yellow circle, you simply define color = Color.Yellow when calling the corresponding drawing function.

Drawing Our First Figure

The Canvas is essentially a blank canvas at our disposal. In code, we declare the Canvas composable, assign it a fixed size (for example, 300.dp) and padding so the shapes don't stick to the container's edges:

@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Draw the face (Yellow circle)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' is an automatic property of the Canvas
        )

        // 2. Draw the outline of the face
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Border only
        )

        // 3. Draw the eyes
        val eyeRadius = 20f
        drawCircle(
            color = Color.Black,
            radius = eyeRadius,
            center = Offset(center.x - 60f, center.y - 50f)
        )
        drawCircle(
            color = Color.Black,
            radius = eyeRadius,
            center = Offset(center.x + 60f, center.y - 50f)
        )

        // 4. Draw the smile (an arc)
        drawArc(
            color = Color.Black,
            startAngle = 0f,    // Starts on the right (3 o'clock on a clock)
            sweepAngle = 180f,  // Sweeps 180 degrees downwards
            useCenter = false,  // If true, closes the arc toward the center (like a Pacman)
            topLeft = Offset(center.x - 70f, center.y - 20f),
            size = Size(140f, 100f),
            style = Stroke(width = 10f, cap = StrokeCap.Round)
        )
    }
}

1. The Base Circle

Inside the Canvas block, you have access to automatic DrawScope properties like size (total canvas dimensions) and center (the exact midpoint). This avoids having to manually calculate the center every time.

  • Radius: We use size.minDimension / 2 so that the circle takes up exactly the available space, taking the smaller dimension between width and height to prevent it from going outside the canvas.
  • Position: We use the center property of the DrawScope to automatically place it at the midpoint of the canvas.
@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Draw the face (Yellow circle)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' is an automatic property of the Canvas
        )
}

2. Outlines with Stroke

If we want to draw only the border of a shape without filling it, we use the Stroke style. By default, all drawing functions fill the shape with the indicated color (Fill style). By passing style = Stroke(width = Xf) as a parameter, we tell the Canvas to paint only the outline with the specified thickness in pixels.

  • Filled Circle: Default behavior. It is not necessary to specify the style parameter.
  • Outlined Circle: By specifying style = Stroke(width = 10f), the circle will be hollow and we will only see its outer line with the defined thickness.
@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Draw the face (Yellow circle)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' is an automatic property of the Canvas
        )

        // 2. Draw the outline of the face
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Border only
        )
}

Transformations: Offset, Translate, and Arcs

To move shapes relative to their default position, we use Offset, which defines a displacement along the X and Y axes. In addition, DrawScope exposes more advanced transformation functions like rotate() (rotation), scale() (scaling), and translate() (origin translation), which act as blocks inside which shapes are drawn with the modified coordinate system. This is very similar to transformations applied in 2D graphics environments or even 3D like Blender: everything is a matter of declaring sizes, colors, and positions.

@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Draw the face (Yellow circle)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' is an automatic property of the Canvas
        )

        // 2. Draw the outline of the face
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Border only
        )

        // 3. Draw the eyes using Offset to position them
        val eyeRadius = 20f
        drawCircle(
            color = Color.Black,
            radius = eyeRadius,
            center = Offset(center.x - 60f, center.y - 50f)
        )
        drawCircle(
            color = Color.Black,
            radius = eyeRadius,
            center = Offset(center.x + 60f, center.y - 50f)
        )
}

The Arc (drawArc)

For curved shapes that are not full circles, like a smile or a pie chart, we use drawArc. It is slightly more complex because it requires understanding how angles are defined in the Canvas system (where 0° points to the right, like 3 o'clock on a clock, and angles increase clockwise):

  • startAngle: Angle in degrees where the arc begins. For example, 0f starts at the 3 o'clock position on a clock.
  • sweepAngle: How many degrees the arc extends from the startAngle. For example, 180f draws half a circumference.
  • useCenter: If true, closes the arc by connecting the ends to the center of the bounding rectangle (creating a "Pacman" or pie slice shape). If false, leaves the ends open, showing only the curved line.
@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Draw the face (Yellow circle)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' is an automatic property of the Canvas
        )

        // 2. Draw the outline of the face
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Border only
        )

        // 3. Draw the eyes
        val eyeRadius = 20f
        drawCircle(
            color = Color.Black,
            radius = eyeRadius,
            center = Offset(center.x - 60f, center.y - 50f)
        )
        drawCircle(
            color = Color.Black,
            radius = eyeRadius,
            center = Offset(center.x + 60f, center.y - 50f)
        )

        // 4. Draw the smile (an arc)
        drawArc(
            color = Color.Black,
            startAngle = 0f,    // Starts on the right (3 o'clock on a clock)
            sweepAngle = 180f,  // Sweeps 180 degrees downwards
            useCenter = false,  // If true, closes the arc toward the center (like a Pacman)
            topLeft = Offset(center.x - 70f, center.y - 20f),
            size = Size(140f, 100f),
            style = Stroke(width = 10f, cap = StrokeCap.Round)
        )
    }
}

And with this, we get:

Real Example: Stats Bars with drawRoundRect

To see something more useful than a static face, let's create progress bars in the style of Pokémon stats. Here we no longer use circles, but rectangles with rounded corners using the drawRoundRect function. This primitive accepts a CornerRadius to smooth out the corners, giving a much more modern and polished look than a flat rectangle.

The logical flow to build each bar is as follows:

  • Background: We draw a gray drawRoundRect that occupies all available width to represent the maximum possible value (for example, 255 in Pokémon stats).
  • Progress: We calculate the proportional width with the formula (currentValue / maxValue) * totalWidth.
  • Top Layer: We draw a second drawRoundRect on top with the color corresponding to the stat type (red for attack, blue for defense, etc.), using the width calculated in the previous step.
data class Stat(
    val nombre: String,
    val valor: Int,
    val color: Color
)

val misStats = listOf(
    Stat("HP", 45, Color.Green),
    Stat("Ataque", 49, Color.Red),
    Stat("Defensa", 49, Color.Blue),
    Stat("Velocidad", 65, Color.Yellow)
)
***
@Composable
fun PokemonStatsChart(stats: List<Stat>) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Text(
            text = "Estadísticas Base",
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier.padding(bottom = 16.dp)
        )

        stats.forEach { stat ->
            Row(
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier.padding(vertical = 4.dp)
            ) {
                // Stat name
                Text(text = stat.nombre, modifier = Modifier.width(80.dp))

                // The Canvas where we draw the bar
                Canvas(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(20.dp)
                ) {
                    val maxStat = 255f
                    // Calculate proportional width to Canvas size
                    val progressWidth = (stat.valor / maxStat) * size.width

                    // 1. Draw bar background (light gray)
                    drawRoundRect(
                        color = Color.LightGray.copy(alpha = 0.3f),
                        size = size,
                        cornerRadius = CornerRadius(10f, 10f)
                    )

                    // 2. Draw progress bar (actual stat)
                    drawRoundRect(
                        color = stat.color,
                        size = Size(width = progressWidth, height = size.height),
                        cornerRadius = CornerRadius(10f, 10f)
                    )
                }
            }
        }
    }
}

As you can see, Canvas gives us absolute freedom to create components that do not exist by default in the Compose library. This kind of custom progress bars would be very complicated to achieve with standard widgets. All source code is available in the repository so you can experiment with positions, colors, and values:

Energy bars

Canvas in Android — Legacy Way: the onDraw() Method

Before Jetpack Compose existed, the standard way to draw in Android was to create a class extending View and override the onDraw(Canvas) method. This class can be contained within the same activity or in a separate file. Below we will call our custom class MyView:

	public class MyView extends View {
		public MyView(Context context) {
			super(context);
		}
		@Override
		protected void onDraw(Canvas canvas) {
		}
	}

The onDraw() Method

The onDraw(Canvas canvas) method receives an instance of the Canvas class as a parameter, which is what exposes all drawing primitives: lines, circles, rectangles, etc. This method is automatically called by the system when the view needs to render or update. To force a manual redraw, you can call invalidate() from anywhere in the class, which will schedule a new call to onDraw().

Drawing a figure:

To draw any figure in the Legacy approach, you need to combine two key ingredients: a Paint object (the "brush" that defines how it is painted) and a call to a Canvas primitive (the function that defines what is drawn). Let's look at the main Paint parameters:

  • Define a Paint object to specify the visual style of the stroke; the most common methods are:
    • Paint.setColor: Sets the paint color. Accepts constants like Color.WHITE, Color.RED, or hexadecimal colors with Color.parseColor("#FF5733").
    • Paint.setStyle: Sets the fill style. Accepts values from the Paint.Style enum:
      • FILL: Draws the shape completely filled with the defined color.
Canvas in Android -FILL-
  • STROKE: Draws only the outline of the figure, without filling.
Canvas in Android -STROKE-
  • FILL_AND_STROKE: Combination of the previous two: fills the shape and also draws its outline.
  • The Canvas object defines the geometric shapes to draw through its primitives:
    • Canvas.drawColor(): Fills the entire Canvas with a solid color. Useful as a background.
    • Canvas.drawCircle(X, Y, Radius, Paint): Draws a circle at position (X, Y) with the specified Radius and Paint style.
    • Canvas.drawLine(startX, startY, stopX, stopY, Paint): Draws a straight line from point (startX, startY) to (stopX, stopY).
    • Canvas.drawRect(left, top, right, bottom, paint): Draws a rectangle bounded by the coordinates left, top, right, and bottom.
    • Canvas.drawPaint(Paint): Paints the entire Canvas with the specified Paint style, similar to drawColor but with more style options.

Associating the Canvas in the Activity

Once we have defined the view that will draw on the Canvas, the next step consists of associating an instance of the MyView class to the activity via the setContentView() method:

setContentView(new MyView(this));

When we want to draw graphics in Android, we have two options: doing it inside the view using an XML layout (the traditional way to reference a layout in Android):

setContentView(R.layout.activity_main);

Or drawing the graphics directly on a custom Canvas, as we have done in this article:

setContentView(new MyView(this));

As you can notice, in both cases we use the same setContentView() method, but with very different results: in the first, we inflate an existing XML layout; in the second, we pass our custom view that paints on the Canvas directly.

Drawing Graphics Inside Layouts or Directly on the Canvas?

Android's general recommendation is to draw graphics inside an XML layout when dealing with simple elements and without much movement, since the standard view system is highly optimized for it. However, when you need richer graphics with complex animations, frequent refreshing, or maximum performance (such as in 2D games or real-time visualizations), Android recommends using Canvas directly, as it offers much finer and more efficient pixel control.

Drawing a custom SeekBar in Android with Canvas

Drawing a Custom SeekBar in Android with Canvas

The SeekBar we are going to build is simple but very illustrative: it consists of a horizontal bar (a flattened rectangle) and a circumference acting as a slider control. The user can interact with it via a touch gesture to move the ball from right to left and vice versa, updating the value in real-time.

Defining the Basics: The Activity Structure

First we must define the structure of our activity, which will support the SeekBar drawn via Canvas in a separate class. In the activity layout we do very little; we simply define an empty container that will serve as the root:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/cons"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   tools:context="com.presentacion.desarrollolibre.audioplayer.MainActivity">
</LinearLayout>

From the activity, we programmatically create a FrameLayout with fixed dimensions of 300 × 300 pixels and assign our custom view to it using the following Java code:

SeekBarView seekBarView = new SeekBarView(MainActivity.this);
FrameLayout.LayoutParams myFrameLayoutParams = new FrameLayout.LayoutParams(300,300);
seekBarView.setLayoutParams(myFrameLayoutParams);
setContentView(seekBarView);

The SeekBarView Class: Drawing the SeekBar with Canvas

The SeekBarView class extends FrameLayout and overrides certain methods of this class. In the constructor, we create a Paint type object that allows us to define the visual style of the geometric shapes (our rectangle and circle) before drawing them on the Canvas. In addition, we define two variables with a fixed size that control the dimensions of the sliding element:

thumbWidth = dp(24);
thumbHeight = dp(24);

These variables define the position and size of the circular control we draw inside the onDraw() method of the SeekBarView class. Said overridden method is responsible for creating the canvas and drawing the two geometric figures: the bar (rectangle) and the slider control (circle):

canvas.drawRect(thumbWidth / 2, getMeasuredHeight() / 2 - dp(1), thumbWidth / 2 + thumbX, getMeasuredHeight() / 2 + dp(1), outerPaint1);

The getMeasuredHeight() method returns the height of the container we defined in the activity; in this experiment, that value is 300 pixels.

The getMeasuredWidth() method returns the width of the container; also 300 pixels in this case. These measurements are essential for calculating the relative positions of the figures within the Canvas.

In addition to the bar drawn with drawRect, we draw the circular control with the drawCircle primitive. Notice that the circle's radius changes slightly depending on whether the user is pressing or not (pressed ? 8 : 6), which provides small tactile visual feedback:

canvas.drawCircle(thumbX + thumbWidth / 2, y + thumbHeight / 2, dp(pressed ? 8 : 6), outerPaint1);

Finally, the onTouch method is implemented, capturing user touch events on the Canvas to update the slider control's position in real-time. The onTouch method is especially interesting because it performs the necessary calculations to relocate the ball according to finger movement, updating the thumbX variable with the current horizontal touch position. After each update, it automatically calls invalidate(), which forces a complete redraw of the view with the new coordinates:

boolean onTouch(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        getParent().requestDisallowInterceptTouchEvent(true);
        int additionWidth = (getMeasuredHeight() - thumbWidth) / 2;
        if (thumbX - additionWidth <= ev.getX() && ev.getX() <= thumbX + thumbWidth + additionWidth && ev.getY() >= 0 && ev.getY() <= getMeasuredHeight()) {
            pressed = true;
            thumbDX = (int) (ev.getX() - thumbX);
            invalidate();
            return true;
        }
    } else if (ev.getAction() == MotionEvent.ACTION_UP || ev.getAction() == MotionEvent.ACTION_CANCEL) {
        if (pressed) {
            if (ev.getAction() == MotionEvent.ACTION_UP) {
//                        onSeekBarDrag((float) thumbX / (float) (getMeasuredWidth() - thumbWidth));
            }
            pressed = false;
            invalidate();
            return true;
        }
    } else if (ev.getAction() == MotionEvent.ACTION_MOVE) {
        if (pressed) {
            thumbX = (int) (ev.getX() - thumbDX);
            if (thumbX < 0) {
                thumbX = 0;
            } else if (thumbX > getMeasuredWidth() - thumbWidth) {
                thumbX = getMeasuredWidth() - thumbWidth;
            }
            invalidate();
            return true;
        }
    }
    return false;
}

In the onTouch method, we handle three key MotionEvent events: the ACTION_DOWN constant, which triggers when a touch begins on the Canvas; the ACTION_MOVE constant, which executes continuously while the finger is dragged; and the ACTION_UP constant, which activates upon lifting the finger, marking the end of the gesture.

Based on these states, the thumbX variable is updated, which "moves" or allows redrawing the ball via the onDraw() method. This continuous cycle of onTouch → invalidate() → onDraw() is the fundamental pattern for any Canvas-based animation or interaction within the Android view system.

Next step: learn how to generate QR codes in Android with Compose.

A comprehensive tutorial on Canvas in Android using Jetpack Compose and the legacy onDraw() method. Learn how to use drawCircle, drawRect, drawRoundRect, and drawArc with real-world examples in Kotlin: an animated face and Pokémon-style stat bars.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.