Crear un dibujo personalizado

Prueba el estilo de Compose
Jetpack Compose es el kit de herramientas de IU recomendado para Android. Obtén información para trabajar con diseños en Compose.

La parte más importante de una vista personalizada es su apariencia. El diseño personalizado puede ser fácil o complejo según las necesidades de tu aplicación. En este documento, se abordan algunas de las operaciones más comunes.

Para obtener más información, consulta Descripción general de los elementos de diseño.

Cómo anular onDraw()

El paso más importante del diseño de una vista personalizada es anular el método onDraw(). El parámetro para onDraw() es un objeto Canvas que la vista puede usar en su propio diseño. La clase Canvas define métodos para diseñar texto, líneas, mapas de bits y muchas otras primitivas de gráficos. Puedes usar estos métodos en onDraw() para crear tu interfaz de usuario (IU) personalizada.

Para comenzar, crea un objeto Paint. En la siguiente sección, se analiza Paint con más detalle.

Crear objetos de dibujo

El framework android.graphics divide el dibujo en dos áreas:

  • Qué diseñar, controlado por Canvas
  • Cómo diseñar, manejado por Paint

Por ejemplo, Canvas proporciona un método para dibujar una línea y Paint proporciona métodos para definir el color de esa línea. Canvas tiene un método para dibujar un rectángulo, y Paint define si se debe rellenar ese rectángulo con un color o dejarlo vacío. Canvas define las formas que puedes dibujar en la pantalla, y Paint define el color, el estilo, la fuente, etc., de cada forma que dibujas.

Antes de dibujar algo, crea uno o más objetos Paint. En el siguiente ejemplo, se hace esto en un método llamado init. Este método se llama desde el constructor desde Java, pero se puede inicializar de forma intercalada en Kotlin.

Kotlin

@ColorInt
private var textColor    // Obtained from style attributes.

@Dimension
private var textHeight   // Obtained from style attributes.

private val textPaint = Paint(ANTI_ALIAS_FLAG).apply {
    color = textColor
    if (textHeight == 0f) {
        textHeight = textSize
    } else {
        textSize = textHeight
    }
}

private val piePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.FILL
    textSize = textHeight
}

private val shadowPaint = Paint(0).apply {
    color = 0x101010
    maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
}

Java

private Paint textPaint;
private Paint piePaint;
private Paint shadowPaint;

@ColorInt
private int textColor;       // Obtained from style attributes.

@Dimension
private float textHeight;    // Obtained from style attributes.

private void init() {
   textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
   textPaint.setColor(textColor);
   if (textHeight == 0) {
       textHeight = textPaint.getTextSize();
   } else {
       textPaint.setTextSize(textHeight);
   }

   piePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
   piePaint.setStyle(Paint.Style.FILL);
   piePaint.setTextSize(textHeight);

   shadowPaint = new Paint(0);
   shadowPaint.setColor(0xff101010);
   shadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL));
   ...
}

La creación de objetos con anticipación es una optimización importante. Las vistas se vuelven a dibujar con frecuencia, y muchos objetos de dibujo requieren una inicialización costosa. La creación de objetos de dibujo dentro del método onDraw() reduce significativamente el rendimiento y puede hacer que la IU sea lenta.

Cómo controlar eventos de diseño

Para dibujar correctamente tu vista personalizada, descubre de qué tamaño es. Las vistas personalizadas complejas suelen necesitar realizar varios cálculos de diseño según el tamaño y la forma de su área en la pantalla. Nunca hagas suposiciones sobre el tamaño de la vista en la pantalla. Incluso si solo una app usa tu vista, esta debe controlar diferentes tamaños de pantalla, varias densidades de pantalla y varias relaciones de aspecto tanto en modo vertical como horizontal.

Aunque View tiene muchos métodos para controlar la medición, no es necesario anular la mayoría de ellos. Si la vista no necesita un control especial sobre su tamaño, solo anula un método: onSizeChanged().

Se llama a onSizeChanged() cuando se le asigna un tamaño a la vista por primera vez, y nuevamente si el tamaño de la vista cambia por algún motivo. Calcula las posiciones, las dimensiones y cualquier otro valor relacionado con el tamaño de la vista en onSizeChanged(), en lugar de volver a calcularlos cada vez que dibujes. En el siguiente ejemplo, onSizeChanged() es donde la vista calcula el rectángulo delimitador del gráfico, la posición relativa de la etiqueta de texto y otros elementos visuales.

Cuando se le asigna un tamaño a tu vista, el administrador de diseño asume que el tamaño incluye el padding de la vista. Controla los valores de padding cuando calcules el tamaño de la vista. En este fragmento de onSizeChanged(), se muestra cómo hacerlo:

Kotlin

private val showText    // Obtained from styled attributes.
private val textWidth   // Obtained from styled attributes.

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    // Account for padding.
    var xpad = (paddingLeft + paddingRight).toFloat()
    val ypad = (paddingTop + paddingBottom).toFloat()

    // Account for the label.
    if (showText) xpad += textWidth.toFloat()
    val ww = w.toFloat() - xpad
    val hh = h.toFloat() - ypad

    // Figure out how big you can make the pie.
    val diameter = Math.min(ww, hh)
}

Java

private Boolean showText;    // Obtained from styled attributes.
private int textWidth;       // Obtained from styled attributes.

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    // Account for padding.
    float xpad = (float)(getPaddingLeft() + getPaddingRight());
    float ypad = (float)(getPaddingTop() + getPaddingBottom());

    // Account for the label.
    if (showText) xpad += textWidth;

    float ww = (float)w - xpad;
    float hh = (float)h - ypad;

    // Figure out how big you can make the pie.
    float diameter = Math.min(ww, hh);
}

Si necesitas un control más preciso sobre los parámetros de diseño de la vista, implementa onMeasure(). Los parámetros de este método son valores View.MeasureSpec que te indican el tamaño que el elemento superior del elemento superior quiere que sea tu vista y si ese tamaño es un máximo estricto o solo una sugerencia. Como optimización, estos valores se almacenan como números enteros empaquetados, y debes usar los métodos estáticos de View.MeasureSpec para descomprimir la información almacenada en cada número entero.

A continuación, se incluye un ejemplo de implementación de onMeasure(). En esta implementación, intenta hacer que el área sea lo suficientemente grande como para que el gráfico sea tan grande como su etiqueta:

Kotlin

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // Try for a width based on your minimum.
    val minw: Int = paddingLeft + paddingRight + suggestedMinimumWidth
    val w: Int = View.resolveSizeAndState(minw, widthMeasureSpec, 1)

    // Whatever the width is, ask for a height that lets the pie get as big as
    // it can.
    val minh: Int = View.MeasureSpec.getSize(w) - textWidth.toInt() + paddingBottom + paddingTop
    val h: Int = View.resolveSizeAndState(minh, heightMeasureSpec, 0)

    setMeasuredDimension(w, h)
}

Java

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
   // Try for a width based on your minimum.
   int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
   int w = resolveSizeAndState(minw, widthMeasureSpec, 1);

   // Whatever the width is, ask for a height that lets the pie get as big as it
   // can.
   int minh = MeasureSpec.getSize(w) - (int)textWidth + getPaddingBottom() + getPaddingTop();
   int h = resolveSizeAndState(minh, heightMeasureSpec, 0);

   setMeasuredDimension(w, h);
}

Hay tres aspectos importantes que debes tener en cuenta en este código:

  • Los cálculos tienen en cuenta el relleno de la vista. Como se mencionó antes, esto es responsabilidad de la vista.
  • El método auxiliar resolveSizeAndState() se usa para crear los valores finales de ancho y altura. Este ayudante muestra un valor de View.MeasureSpec apropiado comparando el tamaño necesario de la vista con el valor que se pasó a onMeasure().
  • onMeasure() no tiene valor de retorno. En cambio, el método comunica los resultados llamando a setMeasuredDimension(). Llamar a este método es obligatorio. Si omites esta llamada, la clase View genera una excepción de tiempo de ejecución.

Generación

Después de definir el código de creación y medición de tu objeto, puedes implementar onDraw(). Cada vista implementa onDraw() de manera diferente, pero hay algunas operaciones comunes que comparten la mayoría de las vistas:

  • Dibuja texto con drawText(). Para especificar el tipo de letra, llama a setTypeface(); para el color del texto, llama a setColor().
  • Dibuja formas primitivas con drawRect(), drawOval() y drawArc(). Llama a setStyle() para cambiar si las formas están rellenas, contorneadas o ambas.
  • Dibuja formas más complejas con la clase Path. Para definir una forma, agrega líneas y curvas a un objeto Path y, luego, dibuja la forma con drawPath(). Al igual que con las formas primitivas, las rutas se pueden delinear o rellenar, o ambas, según setStyle().
  • Para definir los rellenos de gradientes, crea objetos LinearGradient. Llama a setShader() para usar tu LinearGradient en formas rellenas.
  • Dibuja mapas de bits con drawBitmap().

El siguiente código dibuja una combinación de texto, líneas y formas:

Kotlin

private val data = mutableListOf<Item>() // A list of items that are displayed.

private var shadowBounds = RectF()       // Calculated in onSizeChanged.
private var pointerRadius: Float = 2f    // Obtained from styled attributes.
private var pointerX: Float = 0f         // Calculated in onSizeChanged.
private var pointerY: Float = 0f         // Calculated in onSizeChanged.
private var textX: Float = 0f            // Calculated in onSizeChanged.
private var textY: Float = 0f            // Calculated in onSizeChanged.
private var bounds = RectF()             // Calculated in onSizeChanged.
private var currentItem: Int = 0         // The index of the currently selected item.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    canvas.apply {
        // Draw the shadow.
        drawOval(shadowBounds, shadowPaint)

        // Draw the label text.
        drawText(data[currentItem].label, textX, textY, textPaint)

        // Draw the pie slices.
        data.forEach {item ->
            piePaint.shader = item.shader
            drawArc(
                bounds,
                360 - item.endAngle,
                item.endAngle - item.startAngle,
                true,
                piePaint
            )
        }

        // Draw the pointer.
        drawLine(textX, pointerY, pointerX, pointerY, textPaint)
        drawCircle(pointerX, pointerY, pointerRadius, textPaint)
    }
}

// Maintains the state for a data item.
private data class Item(
    var label: String,      
    var value: Float = 0f,

    @ColorInt
    var color: Int = 0,

    // Computed values.
    var startAngle: Float = 0f,
    var endAngle: Float = 0f,

    var shader: Shader
)

Java

private List<Item> data = new ArrayList<Item>();  // A list of items that are displayed.

private RectF shadowBounds;                       // Calculated in onSizeChanged.
private float pointerRadius;                      // Obtained from styled attributes.
private float pointerX;                           // Calculated in onSizeChanged.
private float pointerY;                           // Calculated in onSizeChanged.
private float textX;                              // Calculated in onSizeChanged.
private float textY;                              // Calculated in onSizeChanged.
private RectF bounds;                             // Calculated in onSizeChanged.
private int currentItem = 0;                      // The index of the currently selected item.

protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // Draw the shadow.
    canvas.drawOval(
            shadowBounds,
            shadowPaint
    );

    // Draw the label text.
    canvas.drawText(data.get(currentItem).label, textX, textY, textPaint);

    // Draw the pie slices.
    for (int i = 0; i < data.size(); ++i) {
        Item it = data.get(i);
        piePaint.setShader(it.shader);
        canvas.drawArc(
                bounds,
                360 - it.endAngle,
                it.endAngle - it.startAngle,
                true, 
                piePaint
        );
    }

    // Draw the pointer.
    canvas.drawLine(textX, pointerY, pointerX, pointerY, textPaint);
    canvas.drawCircle(pointerX, pointerY, pointerRadius, textPaint);
}

// Maintains the state for a data item.
private class Item {
    public String label;
    public float value;
    @ColorInt
    public int color;

    // Computed values.
    public int startAngle;
    public int endAngle;

    public Shader shader;
}    

Cómo aplicar efectos gráficos

Android 12 (nivel de API 31) agrega la clase RenderEffect, que aplica efectos de gráficos comunes, como desenfoques, filtros de color, efectos de sombreador de Android y mucho más a los objetos View y jerarquías de renderización. Puedes combinar efectos como en cadena, que constan de un efecto interno y externo, o bien efectos combinados. La compatibilidad con esta función varía según la potencia de procesamiento del dispositivo.

También puedes aplicar efectos al RenderNode subyacente para una View llamando a View.setRenderEffect(RenderEffect).

Para implementar un objeto RenderEffect, haz lo siguiente:

view.setRenderEffect(RenderEffect.createBlurEffect(radiusX, radiusY, SHADER_TILE_MODE))

Puedes crear la vista de manera programática o ampliarla desde un diseño XML y recuperarla mediante la vinculación de vistas o findViewById().