Crear un dibujo personalizado

Prueba hacerlo con Compose
Jetpack Compose es el kit de herramientas de IU recomendado para Android. Obtén información sobre cómo a 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 abarcan 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 al diseñar una vista personalizada es anular el método onDraw(). El parámetro de onDraw() es un objeto Canvas que la vista puede usar para diseñar. La clase Canvas define métodos para diseñar texto, líneas, mapas de bits y muchas otras primitivas gráficas. Puedes usar estos métodos en onDraw() para crear tu interfaz de usuario (IU) personalizada.

Comienza por crear un objeto Paint. En la siguiente sección, se analiza Paint con más detalle.

Cómo crear objetos de diseño

El marco de trabajo android.graphics divide el diseño en dos áreas:

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

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

Antes de empezar a diseñar, debes crear uno o más objetos Paint. En el siguiente ejemplo, se hace esto en un método denominado init. Este método se llama en el constructor desde Java, pero se puede inicializar en línea 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 diseñar con frecuencia y muchos objetos de diseño requieren una costosa inicialización. La creación de objetos de diseño dentro del método onDraw() reduce considerablemente el rendimiento y puede hacer que la IU sea lenta.

Cómo controlar los eventos de diseño

Para diseñar correctamente tu vista personalizada, debes conocer el tamaño. Las vistas personalizadas complejas a menudo necesitan realizar múltiples cálculos de diseño según el tamaño y la forma del á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, esa app debe controlar diferentes tamaños de pantalla, múltiples 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, la mayoría no requiere la anulación. 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, de nuevo, 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 diseñes. En el siguiente ejemplo, onSizeChanged() es el lugar donde la vista calcula el rectángulo delimitador del gráfico y la posición relativa de la etiqueta de texto, además de otros elementos visuales.

Cuando se le asigna un tamaño a la vista, el administrador de diseño asume que el tamaño incluye el relleno de la vista. Controla los valores de relleno 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 qué tan grande quiere el objeto superior de la vista que sea la 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, para desempacar la información, debes usar los métodos estáticos de View.MeasureSpec.

A continuación, se incluye un ejemplo de implementación de onMeasure(). En esta implementación, intenta 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ó anteriormente, esta es la responsabilidad de la vista.
  • El método auxiliar resolveSizeAndState() se usa para crear los valores finales de ancho y alto. Este ayudante muestra un valor de View.MeasureSpec apropiado mediante la comparación del tamaño necesario de la vista con el valor pasado a onMeasure().
  • onMeasure() no tiene valor de retorno. En cambio, el método comunica sus resultados llamando a setMeasuredDimension(). Es obligatorio llamar a este método. Si omites esta llamada, la View clase genera una excepción de tiempo de ejecución.

Dibuja

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

  • Diseña el texto con drawText(). Para especificar el tipo de letra, llama a setTypeface() y para especificar el color del texto, llama a setColor().
  • Diseña formas básicas con drawRect(), drawOval(), y drawArc(). Para usar formas rellenas, contorneadas o ambas, llama a setStyle().
  • Diseña formas más complejas con la Path clase. Para definir una forma, agrega líneas y curvas a un Path objeto y, luego, dibuja la forma con drawPath(). Al igual que con las formas primitivas, puedes delinear o rellenar las rutas, o realizar ambas acciones, dependiendo de setStyle().
  • Define los rellenos de gradientes mediante la creación de LinearGradient objetos. Llama setShader() para usar tu LinearGradient en formas rellenas.
  • Diseña mapas de bits con drawBitmap().

El siguiente código dibuja una mezcla 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;
}    

Aplica efectos gráficos

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

También puedes aplicar los efectos a la clase subyacente RenderNode para una View mediante una llamada 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 forma programática o aumentarla desde un diseño XML y recuperarla con la vinculación de vistas o findViewById().