Cómo dibujar objetos Canvas

Este codelab es parte del curso Aspectos avanzados de Android en Kotlin. Aprovecharás al máximo este curso si trabajas con los codelabs de forma secuencial, aunque no es obligatorio. Todos los codelabs del curso se indican en la página de destino de los codelabs de Aspectos avanzados de Android en Kotlin.

Introducción

En Android, tienes varias técnicas disponibles para implementar gráficos y animaciones 2D personalizados en las vistas.

Además de usar recursos de diseño, puedes crear dibujos en 2D con los métodos de dibujo de la clase Canvas. El objeto Canvas es una superficie de dibujo 2D que proporciona métodos para dibujar. Esto es útil cuando tu app necesita volver a dibujarse con regularidad, ya que lo que ve el usuario cambia con el tiempo. En este codelab, aprenderás a crear un lienzo y a dibujar en él, que se mostrará en un View.

Entre los tipos de operaciones que puedes realizar en un lienzo, se incluyen las siguientes:

  • Rellena todo el lienzo con color.
  • Dibuja formas, como rectángulos, arcos y rutas con el estilo definido en un objeto Paint. El objeto Paint contiene la información de estilo y color sobre cómo dibujar geometrías (como líneas, rectángulos, óvalos y rutas) o, por ejemplo, el tipo de letra del texto.
  • Aplicar transformaciones, como traslación, ajuste o transformaciones personalizadas
  • Recortar, es decir, aplicar una forma o una ruta al lienzo para definir sus partes visibles.

Cómo puedes pensar en el dibujo de Android (¡de forma muy simplificada!)

El dibujo en Android o en cualquier otro sistema moderno es un proceso complejo que incluye capas de abstracciones y optimizaciones hasta el hardware. La forma en que Android dibuja es un tema fascinante sobre el que se ha escrito mucho, y sus detalles están fuera del alcance de este codelab.

En el contexto de este codelab y su app que dibuja en un lienzo para mostrarlo en una vista de pantalla completa, puedes pensarlo de la siguiente manera.

  1. Necesitas una vista para mostrar lo que dibujas. Podría ser una de las vistas que proporciona el sistema Android. O bien, en este codelab, crearás una vista personalizada que servirá como vista de contenido para tu app (MyCanvasView).
  2. Esta vista, como todas las vistas, incluye su propio lienzo (canvas).
  3. Para dibujar en el lienzo de una vista de la manera más básica, anula su método onDraw() y dibuja en su lienzo.
  4. Cuando creas un dibujo, debes almacenar en caché lo que dibujaste antes. Existen varias formas de almacenar en caché tus datos. Una de ellas es en un mapa de bits (extraBitmap). Otra es guardar un historial de lo que dibujaste como coordenadas e instrucciones.
  5. Para dibujar en tu mapa de bits de almacenamiento en caché (extraBitmap) con la API de dibujo de Canvas, crea un lienzo de almacenamiento en caché (extraCanvas) para tu mapa de bits de almacenamiento en caché.
  6. Luego, dibujas en tu lienzo de almacenamiento en caché (extraCanvas), que dibuja en tu mapa de bits de almacenamiento en caché (extraBitmap).
  7. Para mostrar todo lo que se dibuja en la pantalla, le indicas al lienzo de la vista (canvas) que dibuje el mapa de bits de almacenamiento en caché (extraBitmap).

Conocimientos que ya deberías tener

  • Cómo crear una app con una actividad y un diseño básico, y ejecutarla con Android Studio
  • Cómo asociar controladores de eventos con vistas
  • Cómo crear una vista personalizada

Qué aprenderás

  • Cómo crear un Canvas y dibujar en él en respuesta al toque del usuario

Actividades

  • Crear una app que dibuje líneas en la pantalla en respuesta a las acciones del usuario
  • Captura eventos de movimiento y, en respuesta, dibuja líneas en un lienzo que se muestra en una vista personalizada de pantalla completa.

La app de MiniPaint usa una vista personalizada para mostrar una línea en respuesta a los toques del usuario, como se muestra en la siguiente captura de pantalla.

Paso 1: Crea el proyecto MiniPaint

  1. Crea un nuevo proyecto de Kotlin llamado MiniPaint que use la plantilla Empty Activity.
  2. Abre el archivo app/res/values/colors.xml y agrega los siguientes dos colores.
<color name="colorBackground">#FFFF5500</color>
<color name="colorPaint">#FFFFEB3B</color>
  1. Abrir styles.xml
  2. En el elemento superior del estilo AppTheme determinado, reemplaza DarkActionBar por NoActionBar. Esto quita la barra de acciones para que puedas dibujar en pantalla completa.
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">

Paso 2: Crea la clase MyCanvasView

En este paso, crearás una vista personalizada, MyCanvasView, para dibujar.

  1. En el paquete app/java/com.example.android.minipaint, crea un New > Kotlin File/Class llamado MyCanvasView.
  2. Haz que la clase MyCanvasView extienda la clase View y pasa el context: Context. Acepta las importaciones sugeridas.
import android.content.Context
import android.view.View

class MyCanvasView(context: Context) : View(context) {
}

Paso 3: Cómo establecer MyCanvasView como la vista de contenido

Para mostrar lo que dibujarás en MyCanvasView, debes configurarlo como la vista de contenido de MainActivity.

  1. Abre strings.xml y define una cadena para usarla en la descripción del contenido de la vista.
<string name="canvasContentDescription">Mini Paint is a simple line drawing app.
   Drag your fingers to draw. Rotate the phone to clear.</string>
  1. Abrir MainActivity.kt
  2. En onCreate(), borra setContentView(R.layout.activity_main).
  3. Crea una instancia de MyCanvasView.
val myCanvasView = MyCanvasView(this)
  1. Debajo de eso, solicita la pantalla completa para el diseño de myCanvasView. Para ello, establece la marca SYSTEM_UI_FLAG_FULLSCREEN en myCanvasView. De esta manera, la vista ocupa toda la pantalla.
myCanvasView.systemUiVisibility = SYSTEM_UI_FLAG_FULLSCREEN
  1. Agrega una descripción del contenido.
myCanvasView.contentDescription = getString(R.string.canvasContentDescription)
  1. Debajo de eso, configura la vista de contenido en myCanvasView.
setContentView(myCanvasView)
  1. Ejecuta tu app. Verás una pantalla completamente blanca, ya que el lienzo no tiene tamaño y aún no dibujaste nada.

Paso 1: Anula onSizeChanged()

El sistema Android llama al método onSizeChanged() cada vez que cambia el tamaño de una vista. Dado que la vista comienza sin tamaño, también se llama al método onSizeChanged() de la vista después de que la actividad la crea y la infla por primera vez. Por lo tanto, este método onSizeChanged() es el lugar ideal para crear y configurar el lienzo de la vista.

  1. En MyCanvasView, a nivel de la clase, define variables para un lienzo y un mapa de bits. Llamémoslos extraCanvas y extraBitmap. Estos son tu mapa de bits y tu lienzo para almacenar en caché lo que se dibujó antes.
private lateinit var extraCanvas: Canvas
private lateinit var extraBitmap: Bitmap
  1. Define una variable de nivel de clase backgroundColor para el color de fondo del lienzo y, luego, inicialízala en el colorBackground que definiste antes.
private val backgroundColor = ResourcesCompat.getColor(resources, R.color.colorBackground, null)
  1. En MyCanvasView, anula el método onSizeChanged(). El sistema Android llama a este método de devolución de llamada con las dimensiones de pantalla modificadas, es decir, con un nuevo ancho y alto (para cambiar a) y el ancho y alto anteriores (para cambiar desde).
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
   super.onSizeChanged(width, height, oldWidth, oldHeight)
}
  1. Dentro de onSizeChanged(), crea una instancia de Bitmap con el nuevo ancho y alto, que son el tamaño de la pantalla, y asígnala a extraBitmap. El tercer argumento es la configuración de color del mapa de bits. Se recomienda ARGB_8888, ya que almacena cada color en 4 bytes.
extraBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
  1. Crea una instancia de Canvas a partir de extraBitmap y asígnala a extraCanvas.
 extraCanvas = Canvas(extraBitmap)
  1. Especifica el color de fondo con el que se rellenará extraCanvas.
extraCanvas.drawColor(backgroundColor)
  1. Si observas onSizeChanged(), se crea un nuevo mapa de bits y un nuevo lienzo cada vez que se ejecuta la función. Necesitas un mapa de bits nuevo porque cambió el tamaño. Sin embargo, esto es una pérdida de memoria, ya que los mapas de bits antiguos permanecen. Para corregir esto, recicla extraBitmap antes de crear el siguiente agregando este código justo después de la llamada a super.
if (::extraBitmap.isInitialized) extraBitmap.recycle()

Paso 2: Cómo anular onDraw()

Todo el trabajo de dibujo para MyCanvasView se realiza en onDraw().

Para comenzar, muestra el lienzo y llena la pantalla con el color de fondo que estableciste en onSizeChanged().

  1. Anula onDraw() y dibuja el contenido de extraBitmap almacenado en caché en el lienzo asociado a la vista. El método drawBitmap() Canvas viene en varias versiones. En este código, proporcionas el mapa de bits, las coordenadas X e Y (en píxeles) de la esquina superior izquierda y null para el Paint, ya que lo establecerás más adelante.
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)
canvas.drawBitmap(extraBitmap, 0f, 0f, null)
}


Ten en cuenta que el lienzo que se pasa a onDraw() y que usa el sistema para mostrar el mapa de bits es diferente del que creaste en el método onSizeChanged() y que usaste para dibujar en el mapa de bits.

  1. Ejecuta la app. Deberías ver toda la pantalla con el color de fondo especificado.

Para dibujar, necesitas un objeto Paint que especifique cómo se diseñan los elementos cuando se dibujan y un Path que especifique qué se dibuja.

Paso 1: Inicializa un objeto Paint

  1. En MyCanvasView.kt, en el nivel superior del archivo, define una constante para el ancho del trazo.
private const val STROKE_WIDTH = 12f // has to be float
  1. En el nivel de clase de MyCanvasView, define una variable drawColor para contener el color con el que se dibujará y, luego, inicialízala con el recurso colorPaint que definiste antes.
private val drawColor = ResourcesCompat.getColor(resources, R.color.colorPaint, null)
  1. En el nivel de la clase, agrega una variable paint para un objeto Paint y, luego, inicialízala de la siguiente manera.
// Set up the paint with which to draw.
private val paint = Paint().apply {
   color = drawColor
   // Smooths out edges of what is drawn without affecting shape.
   isAntiAlias = true
   // Dithering affects how colors with higher-precision than the device are down-sampled.
   isDither = true
   style = Paint.Style.STROKE // default: FILL
   strokeJoin = Paint.Join.ROUND // default: MITER
   strokeCap = Paint.Cap.ROUND // default: BUTT
   strokeWidth = STROKE_WIDTH // default: Hairline-width (really thin)
}
  • El color del paint es el drawColor que definiste antes.
  • isAntiAlias define si se debe aplicar el suavizado de bordes. Si estableces isAntiAlias en true, se suavizan los bordes de lo que se dibuja sin afectar la forma.
  • isDither, cuando es true, afecta la forma en que se reduce la resolución de los colores con mayor precisión que la del dispositivo. Por ejemplo, el tramado es el medio más común para reducir el rango de color de las imágenes a 256 colores (o menos).
  • style establece el tipo de pintura que se aplicará a un trazo, que es esencialmente una línea. Paint.Style especifica si la primitiva que se dibuja se rellena, se traza o ambas (con el mismo color). El valor predeterminado es rellenar el objeto al que se aplica la pintura. (El "relleno" colorea el interior de la forma, mientras que el "trazo" sigue su contorno).
  • strokeJoin de Paint.Join especifica cómo se unen las líneas y los segmentos de curva en una ruta de acceso trazada. El valor predeterminado es MITER.
  • strokeCap establece la forma del final de la línea como un tope. Paint.Cap especifica cómo se ven el inicio y el final de las líneas y las rutas con trazo. El valor predeterminado es BUTT.
  • strokeWidth especifica el ancho del trazo en píxeles. El valor predeterminado es el ancho de línea fina, que es muy delgado, por lo que se establece en la constante STROKE_WIDTH que definiste antes.

Paso 2: Inicializa un objeto Path

El Path es la ruta de lo que dibuja el usuario.

  1. En MyCanvasView, agrega una variable path y, luego, inicialízala con un objeto Path para almacenar la ruta que se dibuja cuando se sigue el toque del usuario en la pantalla. Importa android.graphics.Path para Path.
private var path = Path()

Paso 1: Cómo responder al movimiento en la pantalla

Se llama al método onTouchEvent() en una vista cada vez que el usuario toca la pantalla.

  1. En MyCanvasView, anula el método onTouchEvent() para almacenar en caché las coordenadas x y y del event pasado. Luego, usa una expresión when para controlar los eventos de movimiento cuando se toca la pantalla, se mueve el dedo por la pantalla y se suelta el toque en la pantalla. Estos son los eventos de interés para dibujar una línea en la pantalla. Para cada tipo de evento, llama a un método de utilidad, como se muestra en el siguiente código. Consulta la documentación de la clase MotionEvent para obtener una lista completa de los eventos táctiles.
override fun onTouchEvent(event: MotionEvent): Boolean {
   motionTouchEventX = event.x
   motionTouchEventY = event.y

   when (event.action) {
       MotionEvent.ACTION_DOWN -> touchStart()
       MotionEvent.ACTION_MOVE -> touchMove()
       MotionEvent.ACTION_UP -> touchUp()
   }
   return true
}
  1. A nivel de la clase, agrega las variables motionTouchEventX y motionTouchEventY faltantes para almacenar en caché las coordenadas X e Y del evento táctil actual (las coordenadas MotionEvent). Inicialízalos en 0f.
private var motionTouchEventX = 0f
private var motionTouchEventY = 0f
  1. Crea stubs para las tres funciones touchStart(), touchMove() y touchUp().
private fun touchStart() {}

private fun touchMove() {}

private fun touchUp() {}
  1. Tu código debería compilarse y ejecutarse, pero aún no verás nada diferente del fondo de color.

Paso 2: Implementa touchStart()

Se llama a este método cuando el usuario toca la pantalla por primera vez.

  1. A nivel de la clase, agrega variables para almacenar en caché los valores de X e Y más recientes. Después de que el usuario deja de moverse y levanta el dedo, estos son los puntos de partida para la siguiente ruta (el siguiente segmento de la línea que se debe dibujar).
private var currentX = 0f
private var currentY = 0f
  1. Implementa el método touchStart() de la siguiente manera. Restablece path, muévete a las coordenadas X-Y del evento táctil (motionTouchEventX y motionTouchEventY) y asigna currentX y currentY a ese valor.
private fun touchStart() {
   path.reset()
   path.moveTo(motionTouchEventX, motionTouchEventY)
   currentX = motionTouchEventX
   currentY = motionTouchEventY
}

Paso 3: Implementa touchMove()

  1. A nivel de la clase, agrega una variable touchTolerance y establécela en ViewConfiguration.get(context).scaledTouchSlop.
private val touchTolerance = ViewConfiguration.get(context).scaledTouchSlop

Con una ruta de acceso, no es necesario dibujar cada píxel ni solicitar una actualización de la pantalla cada vez. En cambio, puedes (y debes) interpolar una ruta entre puntos para obtener un rendimiento mucho mejor.

  • Si el dedo apenas se movió, no es necesario dibujar.
  • Si el dedo se movió menos que la distancia touchTolerance, no dibuje.
  • scaledTouchSlop devuelve la distancia en píxeles que puede oscilar un toque antes de que el sistema considere que el usuario se está desplazando.
  1. Define el método touchMove(). Calcula la distancia recorrida (dx, dy), crea una curva entre los dos puntos y la almacena en path, actualiza el recuento acumulado de currentX y currentY, y dibuja path. Luego, llama a invalidate() para forzar el redibujo de la pantalla con el path actualizado.
private fun touchMove() {
   val dx = Math.abs(motionTouchEventX - currentX)
   val dy = Math.abs(motionTouchEventY - currentY)
   if (dx >= touchTolerance || dy >= touchTolerance) {
       // QuadTo() adds a quadratic bezier from the last point,
       // approaching control point (x1,y1), and ending at (x2,y2).
       path.quadTo(currentX, currentY, (motionTouchEventX + currentX) / 2, (motionTouchEventY + currentY) / 2)
       currentX = motionTouchEventX
       currentY = motionTouchEventY
       // Draw the path in the extra bitmap to cache it.
       extraCanvas.drawPath(path, paint)
   }
   invalidate()
}

Este método en más detalle:

  1. Calcula la distancia que se movió (dx, dy).
  2. Si el movimiento fue mayor que la tolerancia al tacto, agrega un segmento a la ruta.
  3. Establece el punto de partida del siguiente segmento en el extremo de este segmento.
  4. Usar quadTo() en lugar de lineTo() crea una línea dibujada de forma suave y sin esquinas. Consulta Curvas de Bézier.
  5. Llama a invalidate() para (finalmente llamar a onDraw() y) volver a dibujar la vista.

Paso 4: Implementa touchUp()

Cuando el usuario levanta el dedo, solo es necesario restablecer la ruta para que no se vuelva a dibujar. No se dibuja nada, por lo que no es necesaria la invalidación.

  1. Implementa el método touchUp().
private fun touchUp() {
   // Reset the path so it doesn't get drawn again.
   path.reset()
}
  1. Ejecuta el código y usa el dedo para dibujar en la pantalla. Ten en cuenta que, si rotas el dispositivo, la pantalla se borrará porque no se guarda el estado del dibujo. En el caso de esta app de ejemplo, esto se diseñó de esta manera para que el usuario tenga una forma sencilla de borrar la pantalla.

Paso 5: Dibuja un marco alrededor del boceto

A medida que el usuario dibuja en la pantalla, tu app construye la ruta y la guarda en el mapa de bits extraBitmap. El método onDraw() muestra el mapa de bits adicional en el lienzo de la vista. Puedes dibujar más en onDraw(). Por ejemplo, podrías dibujar formas después de dibujar el mapa de bits.

En este paso, dibujarás un marco alrededor del borde de la foto.

  1. En MyCanvasView, agrega una variable llamada frame que contenga un objeto Rect.
private lateinit var frame: Rect
  1. Al final de onSizeChanged(), define una inserción y agrega código para crear el Rect que se usará para el fotograma, con las nuevas dimensiones y la inserción.
// Calculate a rectangular frame around the picture.
val inset = 40
frame = Rect(inset, inset, width - inset, height - inset)
  1. En onDraw(), después de dibujar el mapa de bits, dibuja un rectángulo.
// Draw a frame around the canvas.
canvas.drawRect(frame, paint)
  1. Ejecuta tu app y observa el fotograma.

Tarea (opcional): Almacena datos en una ruta

En la app actual, la información de dibujo se almacena en un mapa de bits. Si bien esta es una buena solución, no es la única forma posible de almacenar información de dibujo. La forma en que almacenas el historial de dibujo depende de la app y de tus diversos requisitos. Por ejemplo, si dibujas formas, puedes guardar una lista de formas con su ubicación y dimensiones. En el caso de la app de MiniPaint, podrías guardar la ruta como un Path. A continuación, se incluye un esquema general sobre cómo hacerlo, si quieres probarlo.

  1. En MyCanvasView, quita todo el código de extraCanvas y extraBitmap.
  2. Agrega variables para la ruta hasta el momento y la ruta que se dibuja actualmente.
// Path representing the drawing so far
private val drawing = Path()

// Path representing what's currently being drawn
private val curPath = Path()
  1. En onDraw(), en lugar de dibujar el mapa de bits, dibuja las rutas almacenadas y actuales.
// Draw the drawing so far
canvas.drawPath(drawing, paint)
// Draw any current squiggle
canvas.drawPath(curPath, paint)
// Draw a frame around the canvas
canvas.drawRect(frame, paint)
  1. En touchUp(), agrega la ruta actual a la ruta anterior y restablece la ruta actual.
// Add the current path to the drawing so far
drawing.addPath(curPath)
// Rewind the current path for the next touch
curPath.reset()
  1. Ejecuta tu app. No debería haber ninguna diferencia.

Descarga el código del codelab terminado.

$  git clone https://github.com/googlecodelabs/android-kotlin-drawing-canvas


También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Download Zip

  • Un Canvas es una superficie de dibujo 2D que proporciona métodos para dibujar.
  • El Canvas se puede asociar con una instancia de View que lo muestre.
  • El objeto Paint contiene la información de estilo y color sobre cómo dibujar geometrías (como líneas, rectángulos, óvalos y rutas) y texto.
  • Un patrón común para trabajar con un lienzo es crear una vista personalizada y anular los métodos onDraw() y onSizeChanged().
  • Anula el método onTouchEvent() para capturar los toques del usuario y responder a ellos dibujando elementos.
  • Puedes usar un mapa de bits adicional para almacenar en caché información de los dibujos que cambian con el tiempo. También podrías almacenar formas o una ruta.

Curso de Udacity:

Documentación para desarrolladores de Android:

En esta sección, se enumeran las posibles actividades para el hogar para los alumnos que trabajan en este codelab como parte de un curso dirigido por un instructor. Depende del instructor hacer lo siguiente:

  • Si es necesario, asigna una tarea.
  • Comunicarles a los alumnos cómo enviar las actividades para el hogar.
  • Califica las actividades para el hogar.

Los instructores pueden usar estas sugerencias en la medida que quieran y deben asignar cualquier otra actividad para el hogar que consideren apropiada.

Si estás trabajando en este codelab por tu cuenta, usa estas actividades para el hogar para probar tus conocimientos.

Responde estas preguntas:

Pregunta 1

¿Cuáles de los siguientes componentes son necesarios para trabajar con un Canvas? Selecciona todas las opciones que correspondan.

Bitmap

Paint

Path

View

Pregunta 2

¿Qué hace una llamada a invalidate() (en términos generales)?

▢ Invalida y reinicia tu app.

▢ Borra el dibujo del mapa de bits.

▢ Indica que no se debe ejecutar el código anterior.

▢ Le indica al sistema que debe volver a dibujar la pantalla.

Pregunta 3

¿Cuál es la función de los objetos Canvas, Bitmap y Paint?

▢ Superficie de dibujo 2D, mapa de bits que se muestra en la pantalla, información de diseño para el dibujo.

▢ Superficie de dibujo en 3D, mapa de bits para almacenar en caché la ruta de acceso, información de diseño para el dibujo.

▢ Superficie de dibujo 2D, mapa de bits que se muestra en la pantalla, diseño de la vista

▢ Caché para la información de dibujo, mapa de bits para dibujar y la información de diseño para dibujar.

Para obtener vínculos a otros codelabs de este curso, consulta la página de destino de los codelabs de Aspectos avanzados de Android en Kotlin.