Criar um desenho personalizado

Teste o Compose
O Jetpack Compose é o kit de ferramentas de interface recomendado para Android. Aprenda a trabalhar com layouts no Compose.

A parte mais importante de uma visualização personalizada é a aparência. O desenho personalizado pode ser fácil ou complexo, de acordo com as necessidades do aplicativo. Este documento aborda algumas das operações mais comuns.

Para saber mais, consulte Visão geral de drawables.

Substituir o onDraw()

A etapa mais importante no desenho de uma visualização personalizada é substituir o método onDraw(). O parâmetro para onDraw() é um objeto Canvas que a visualização pode usar para se desenhar. A classe Canvas define métodos para desenhar texto, linhas, bitmaps e muitos outros primitivos gráficos. Você pode usar esses métodos em onDraw() para criar sua interface do usuário (interface) personalizada.

Comece criando um objeto Paint. A próxima seção discute Paint em mais detalhes.

Criar objetos de desenho

O framework android.graphics divide o desenho em duas áreas:

  • O que desenhar, processado por Canvas.
  • Como desenhar, processado por Paint.

Por exemplo, Canvas fornece um método para desenhar uma linha, e Paint fornece métodos para definir a cor dessa linha. O Canvas tem um método para desenhar um retângulo, e o Paint define se ele será preenchido com uma cor ou ficará vazio. Canvas define formas que você pode desenhar na tela, e Paint define a cor, o estilo, a fonte e assim por diante de cada forma desenhada.

Antes de desenhar algo, crie um ou mais objetos Paint. O exemplo a seguir faz isso em um método chamado init. Esse método é chamado pelo construtor do Java, mas pode ser inicializado inline no 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));
   ...
}

Criar objetos com antecedência é uma otimização importante. As visualizações são redesenhadas com frequência, e muitos objetos de desenho exigem inicialização cara. A criação de objetos de desenho no método onDraw() reduz significativamente a performance e pode deixar a interface lenta.

Processar eventos de layout

Para desenhar corretamente sua visualização personalizada, descubra qual é o tamanho dela. Visualizações personalizadas complexas geralmente precisam executar vários cálculos de layout, dependendo do tamanho e da forma da área delas na tela. Nunca faça suposições sobre o tamanho da sua visualização na tela. Mesmo que apenas um app use a visualização, ele vai precisar processar diferentes tamanhos e densidades de tela e diversas proporções nos modos retrato e paisagem.

Embora View tenha muitos métodos para processar medições, a maioria deles não precisa ser substituída. Caso sua visualização não precise de controle especial sobre o tamanho, substitua apenas um método: onSizeChanged().

O onSizeChanged() é chamado quando um tamanho é atribuído à visualização pela primeira vez e novamente se o tamanho da visualização mudar por qualquer motivo. Calcule posições, dimensões e outros valores relacionados ao tamanho da visualização no onSizeChanged(), em vez de recalcular esses valores toda vez que você desenhar. No exemplo a seguir, onSizeChanged() é onde a visualização calcula o retângulo delimitador do gráfico e a posição relativa do rótulo de texto e outros elementos visuais.

Quando um tamanho é atribuído à sua visualização, o gerenciador de layout supõe que o tamanho inclui o padding da visualização. Gerencie os valores de padding ao calcular o tamanho da visualização. Confira um snippet de onSizeChanged() que mostra como fazer isso:

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

Se você precisar de um controle mais preciso sobre os parâmetros de layout da sua visualização, implemente onMeasure(). Os parâmetros desse método são valores View.MeasureSpec que informam o tamanho que o pai da visualização quer que ela tenha e se esse tamanho é o máximo ou apenas uma sugestão. Como otimização, esses valores são armazenados como números inteiros compactados, e você usa os métodos estáticos de View.MeasureSpec para descompactar as informações armazenadas em cada número inteiro.

Veja um exemplo de implementação de onMeasure(): Nesta implementação, ele tenta tornar a área grande o suficiente para que o gráfico tenha o tamanho do rótulo:

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

Há três coisas importantes a serem observadas nesse código:

  • Os cálculos consideram o padding da visualização. Como mencionado anteriormente, isso é responsabilidade da visualização.
  • O método auxiliar resolveSizeAndState() é usado para criar os valores finais de largura e altura. Esse auxiliar retorna um valor View.MeasureSpec apropriado comparando o tamanho necessário da visualização com o valor transmitido para onMeasure().
  • onMeasure() não tem valor de retorno. Em vez disso, o método comunica os resultados chamando setMeasuredDimension(). É obrigatório chamar esse método. Se você omitir essa chamada, a classe View vai gerar uma exceção de execução.

Desenho

Depois de definir o código de criação e medição de objetos, é possível implementar onDraw(). Cada visualização implementa o onDraw() de maneira diferente, mas há algumas operações comuns na maioria das visualizações:

  • Desenhe texto usando drawText(). Especifique a família tipográfica chamando setTypeface() e a cor do texto chamando setColor().
  • Desenhe formas primitivas usando drawRect(), drawOval() e drawArc(). Defina se as formas são preenchidas, circunscritas ou ambos chamando setStyle().
  • Desenhe formas mais complexas usando a classe Path. Defina uma forma adicionando linhas e curvas a um objeto Path e desenhe a forma usando drawPath(). Assim como nas formas primitivas, os caminhos podem ser delineados, preenchidos ou ambos, dependendo de setStyle().
  • Defina preenchimentos de gradiente criando objetos LinearGradient. Chame setShader() para usar LinearGradient em formas preenchidas.
  • Desenhe bitmaps usando drawBitmap().

O código a seguir desenha uma mistura de texto, linhas e 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;
}    

Aplicar efeitos gráficos

O Android 12 (nível 31 da API) adiciona a classe RenderEffect, que aplica efeitos gráficos comuns, como desfoques, filtros de cores, efeitos de sombreador do Android e muito mais a objetos View e hierarquias de renderização. É possível combinar efeitos como efeitos de cadeia, que consistem em um efeito interno e externo ou efeitos mistos. O suporte a esse recurso varia de acordo com a capacidade de processamento do dispositivo.

Também é possível aplicar efeitos ao RenderNode subjacente para um View chamando View.setRenderEffect(RenderEffect).

Para implementar um objeto RenderEffect, faça o seguinte:

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

É possível criar a visualização de forma programática ou inflá-la usando um layout XML e recuperá-la usando a vinculação de visualizações ou findViewById().