Dibujando formas geométricas con Canvas en Android | Jetpack Compose

Video thumbnail

Este artículo describe el método presentaremos la alternativa moderna, Jetpack Compose, que utiliza un enfoque de "composable" para un desarrollo de UI más rápido y sencillo y al final la forma tradicional (ahora considerado "Legacy") para crear gráficos personalizados en Android utilizando el sistema de View y Canvas. Si bien este enfoque sigue siendo funcional, el desarrollo moderno de Android ha evolucionado.

Un Canvas no es más que una superficie o lienzo en la cual se puede dibujar; con ayuda de las primitivas que ofrece la API de Canvas en Android es posible dibujar gráficos como líneas, círculos, óvalos, cuadrados, rectángulos e incluso textos, así como variar el color, trazado y tamaño; en esta entrada daremos los primeros pasos con la clase Canvas en Android.

Anteriormente vimos como hacer una conexión a Internet en Android Studio con Retrofit que vamos a mejorar al final para mostrar una barra de energía. 

El Enfoque Moderno: Jetpack Compose (Composable)

La forma en que se construyen las interfaces de usuario en Android ha evolucionado. La manera que hemos visto, usando Vistas (Views) y XML, es el enfoque tradicional o "Legacy". La nueva forma, recomendada por Google, es usar Jetpack Compose.

Dibujando con Canvas en Jetpack Compose

El concepto de Canvas todavía existe en Jetpack Compose, pero se usa de una manera más directa y declarativa dentro de una función Composable. No es necesario crear una clase nueva ni sobreescribir onDraw.

Por ejemplo, para dibujar un círculo, simplemente usamos el Composable Canvas:

@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
        )
    }
}

Como puedes ver, el código es más conciso y se integra de forma natural con el resto de la UI declarativa de Compose. Este es el nuevo estándar para el desarrollo de UI en Android, ofreciendo un desarrollo más rápido y eficiente.

Dibujar nuestra primera figura con Canvas, una cara

Vamos a aprender las bases para trabajar con Canvas en Android utilizando Composables. Lo primero que debes saber es que el Canvas no está pensado para figuras estáticas o sencillas que podrías lograr con otros componentes; su verdadero potencial reside en la creación de figuras complejas y, sobre todo, en la implementación de animaciones avanzadas.

Aunque a veces la gente le huye por el componente matemático, en realidad se basa en operaciones básicas que conocemos desde pequeños: círculos, cuadrados, rectángulos, triángulos, arcos y polígonos.

Conceptos Fundamentales: Dimensiones y Área

Para dibujar, solo necesitamos recordar cómo varían las dimensiones de cada figura:

  • Círculo: Se define por su radio. Es la distancia desde el centro hasta el borde. Cuanto mayor sea este número, más grande será el círculo.
  • Cuadrado: Por definición, todos sus lados miden lo mismo (alto y ancho iguales).
  • Rectángulo: Varía tanto en su alto (height) como en su ancho (width).

El color es el aspecto más sencillo: si quieres un círculo amarillo, simplemente seleccionas ese color en el pincel y lo dibujas.

Dibujando nuestra primera figura

El Canvas es, esencialmente, un lienzo en blanco a nuestra disposición. En el código, declaramos el Composable Canvas, le asignamos un tamaño (por ejemplo, 300 dp) y un padding para que no quede pegado a los bordes:

@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Dibujar el rostro (Círculo amarillo)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' es una propiedad automática del Canvas
        )

        // 2. Dibujar el contorno del rostro
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Solo el borde
        )

        // 3. Dibujar los ojos
        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. Dibujar la sonrisa (un arco)
        drawArc(
            color = Color.Black,
            startAngle = 0f,    // Empieza en la derecha (3 en un reloj)
            sweepAngle = 180f,  // Gira 180 grados hacia abajo
            useCenter = false,  // Si es true, cierra el arco hacia el centro (como un Pacman)
            topLeft = Offset(center.x - 70f, center.y - 20f),
            size = Size(140f, 100f),
            style = Stroke(width = 10f, cap = StrokeCap.Round)
        )
    }
}

1. El Círculo Base

Dentro del Canvas, tenemos acceso a propiedades gratuitas como size (dimensiones totales) y center (el punto medio).

  • Radio: Si el tamaño del Canvas es 300, dividimos entre dos para obtener el radio y que el círculo ocupe el espacio perfectamente desde el centro.
  • Posición: Usamos center para ubicarlo automáticamente en el medio del lienzo.
@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Dibujar el rostro (Círculo amarillo)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' es una propiedad automática del Canvas
        )
}

2. Contornos con Stroke

Si queremos dibujar solo el borde de una figura sin rellenarla, utilizamos el estilo Stroke.

  • Círculo Relleno: Es el comportamiento por defecto.
  • Círculo de Contorno: Al indicar style = Stroke(width = 10f), el círculo será hueco y solo veremos la línea exterior.
@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Dibujar el rostro (Círculo amarillo)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' es una propiedad automática del Canvas
        )

        // 2. Dibujar el contorno del rostro
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Solo el borde
        )
}

Transformaciones: Offset, Translate y Arcos

Para mover figuras, utilizamos el Offset (desplazamiento en X e Y). También existen operaciones métricas como la rotación, la escala y la traslación (translate). Esto es muy similar a lo que se hace en entornos 2D o incluso en 3D como Blender: todo es cuestión de declarar tamaños, colores y posiciones.

@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Dibujar el rostro (Círculo amarillo)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' es una propiedad automática del Canvas
        )

        // 2. Dibujar el contorno del rostro
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Solo el borde
        )

        // 3. Dibujar los ojos
        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)
        )
}

El Arco (DrawArc)

Para formas que no son cerradas, como una sonrisa, usamos drawArc. Es un poco más complejo porque requiere:

  • startAngle: Dónde empieza el dibujo (en grados).
  • sweepAngle: Cuánto se extiende (por ejemplo, 180° para media circunferencia).
  • useCenter: Para decidir si el arco se cierra hacia el centro o queda solo la línea curva.
@Composable
fun MiDibujoPersonalizado() {
    Canvas(
        modifier = Modifier
            .size(300.dp)
            .padding(16.dp)
    ) {
        // 1. Dibujar el rostro (Círculo amarillo)
        drawCircle(
            color = Color.Yellow,
            radius = size.minDimension / 2,
            center = center // 'center' es una propiedad automática del Canvas
        )

        // 2. Dibujar el contorno del rostro
        drawCircle(
            color = Color.Black,
            radius = size.minDimension / 2,
            style = Stroke(width = 5f) // Solo el borde
        )

        // 3. Dibujar los ojos
        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. Dibujar la sonrisa (un arco)
        drawArc(
            color = Color.Black,
            startAngle = 0f,    // Empieza en la derecha (3 en un reloj)
            sweepAngle = 180f,  // Gira 180 grados hacia abajo
            useCenter = false,  // Si es true, cierra el arco hacia el centro (como un Pacman)
            topLeft = Offset(center.x - 70f, center.y - 20f),
            size = Size(140f, 100f),
            style = Stroke(width = 10f, cap = StrokeCap.Round)
        )
    }
}

Y con esto, obtenemos:

Ejemplo Real: Barras de Estadísticas (Stats)

Para ver algo más útil que una cara estática, podemos crear barras de progreso para un Pokémon. Aquí ya no usamos círculos, sino rectángulos con esquinas redondeadas (drawRoundRect).

El flujo lógico es el siguiente:

  • Fondo: Dibujamos un rectángulo gris que ocupa todo el ancho (fillMaxWidth) para representar el valor máximo (ej. 255).
  • Progreso: Calculamos una proporción matemática: (valorActual / valorMaximo) * anchoTotal.
  • Capa Superior: Dibujamos un segundo rectángulo encima con el color correspondiente (rojo, verde, azul) basado en el cálculo anterior.
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)
            ) {
                // Nombre del stat
                Text(text = stat.nombre, modifier = Modifier.width(80.dp))

                // El Canvas donde dibujamos la barra
                Canvas(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(20.dp)
                ) {
                    val maxStat = 255f
                    // Calculamos el ancho proporcional al tamaño del Canvas
                    val progressWidth = (stat.valor / maxStat) * size.width

                    // 1. Dibujar el fondo de la barra (gris claro)
                    drawRoundRect(
                        color = Color.LightGray.copy(alpha = 0.3f),
                        size = size,
                        cornerRadius = CornerRadius(10f, 10f)
                    )

                    // 2. Dibujar la barra de progreso (el stat real)
                    drawRoundRect(
                        color = stat.color,
                        size = Size(width = progressWidth, height = size.height),
                        cornerRadius = CornerRadius(10f, 10f)
                    )
                }
            }
        }
    }
}

Como puedes ver, el Canvas nos da libertad absoluta para crear componentes que no existen por defecto en la librería de Android. Todo este código fuente está disponible en el repositorio para que puedas experimentar con las posiciones y los colores:

Barras de energia

Empezando con el Canvas en Android -el método onDraw()- Forma Legacy

Primero hay que definir una clase que extienda de View la cual puede estar contenida dentro de la misma actividad o fuera de ella; a la clase la llamaremos MyView que sobreescriba el método onDraw(Canvas):

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

El método onDraw()

El método onDraw(Canvas canvas) especifica un parámetro Canvas cuya clase es la que permite dibujar las primitivas: líneas, círculos, rectángulos, etc.

Dibujando una figura:

Para dibujar una sencilla figura, debemos especificar una serie de métodos en conjunto con sus parámetros para cambiar el estilo de los trazados, colores, formas, etc:

  • Definir un paint/pintar para especificar parámetros como el color, grosor del trazo, etc; veamos algunos de ellos:
    • Paint.setColor: Establece un color para pintar; el método recibe como parámetro de tipo color; por ejemplo: Color.WHITE
    • Paint.setStyle: Establece un estilo para pintar; el método recibe un parámetro de tipo Paint.Style los cuales pueden ser:
      • FILL: Dibuja una figura rellena:
Canvas en Android -FILL-
  • STROKE: Dibuja solo el contorno de la figura:
Canvas en Android -STROKE-
  • FILL_AND_STROKE: Combinación de los dos anteriores.
  • El Canvas define los colores y formas geométricas a dibujar:
    • Canvas.drawColor(): Define un color.
    • Canvas.drawCircle(X,Y,Radius,Paint): Dibuja un círculo según: La posición (X y Y) radio (Radius) y Paint especificados.
    • Canvas.drawLine(startX,startY,stopX,stopY,Paint): Dibuja una línea según el segmento (startX,startY,stopX,stopY) y Paint especificados.
    • Canvas.drawRect(left, top, right, bottom, paint)): Dibuja un rectángulo según la posición (left,top,right,bottom) y Paint especificados.
    • Canvas.drawPaint(Paint): Pinta todo el Canvas según el Paint especificado.

Asociando el Canvas en la actividad

Una vez que que hayamos definido la vista la cual va a dibujar en el Canvas; el siguiente paso consiste en asociar una instancia de la clase MyView en la actividad mediante el método setContentView():

setContentView(new MyView(this));

Cuando queremos dibujar gráficos en Android podemos hacerlo dentro de la vista usando un layout (la manera tradicional de referencia un layout en Android):

setContentView(R.layout.activity_main);

O dibujar los gráficos directamente en un Canvas como hemos realizado en esta entrada:

setContentView(new MyView(this));

Como podrás notar, para ambos paso empleamos el método setContentView() pero con resultados muy diferentes.

¿Dibujar gráficos dentro de los layout o en el Canvas?

Se recomienda dibujar los gráficos en layout cuando se van a manejar gráficos simples sin mucho movimiento; pero, cuando se desea crear gráficos más ricos con más animaciones, movimientos, refrescamiento de los dibujos y un mayor rendimiento Android recomienda emplear el Canvas en su lugar.

Dibujando un SeekBar personalizado en Android con Canvas

Dibujando un SeekBar personalizado en Android con Canvas

El SeekBar a realizar es realmente sencillo, solo consiste de una barra lateral (un rectángulo achatado) y una circunferencia que hará la vez de control y es la que manipularemos mediante un clic o "gesto" para desplazarla de derecha a izquierda y viceversa.

Definiendo las bases: la estructura de la actividad

Primero debemos definir la estructura de nuestra actividad que es la que dará soporte al SeekBar dibujado mediante Canvas en una otra clase; en el layout de nuestra actividad hacemos realmente poco; simplemente definimos un layout vacío como el siguiente:

"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>

Ya nos encargamos desde la actividad de crear un FrameLayout con unas dimensiones fijas de 300 x 300 empleando el siguiente código Java:

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

La clase SeekBarView para dibujar el SeekBar mediante Canvas

Como veremos en el experimento, la clase SeekBarView extiende de la clase FrameLayout y por lo tanto debe sobrescribir ciertos métodos de esta clase; en el método constructor de la clase nos encargamos de crear un objeto de tipo Paint que nos permite dibujar formas geométricas (nuestro rectangulo y circulo) y dibujarlo dentro de un Canvas; ademas definimos dos variables con un tamaño fijo que nos serviran para otro propósito:

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

Estas variables se encargan de definir la posición del rectángulo que dibujamos con el método onDraw() de esta clase SeekBarView; el método onDraw() que sobreescribimos, se encarga de crear el lienzo/canvas y dibujar las figuras geométricas (rectángulo y círculo) en nuestro lienzo/canvas:

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

El método getMeasuredHeight() retorna el largo del contenedor que definimos en la actividad, que para este experimento es de 300.

El método getMeasuredWidth() retorna el ancho del contenedor que definimos en la actividad, que para este experimento es de 300.

Además de una barra que dibujamos con la función anterior de drawRect, dibujamos un círculo con ayuda de la primitiva drawCircle.

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

Por último se crea un método onTouch que se ejecuta para mover el círculo según el toque del usuario sobre el Canvas (una especie de evento onClick mantenido). El método onTouch es bastante interesante ya que se realizan los cálculos para reubicar la bola según la actualización de la variable thumbX la cual se actualiza según la posición del clic del usuario en la SeekBar; el método onTouch se encarga de llamar de manera automática el método onDraw y de esta forma se redibuja la bola según la posición actualizada:

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;
}

En el método anterior onTouch existen varios eventos para; tenemos la constante ACTION_DOWN que se ejecuta al momento de iniciar el "gesto" o el clic de la persona en el Canvas y la constante ACTION_UP que se ejecuta al momento de terminar el "gesto" o el clic de la persona en el Canvas.

A raíz de estos estados, se actualiza la variable thumbX que es la que "mueve" o permite redibujar la bola mediante el método onDraw().

Siguiente paso, aprende a generar códigos QR en Android con Compose.

Aprende a dibujar figuras personalizadas y animaciones complejas en Android con Canvas y Jetpack Compose para crear componentes únicos.

Acepto recibir anuncios de interes sobre este Blog.

Andrés Cruz

EN In english