Inicio Android Interfaz de usuario en Android: Controles personalizados (III)

Interfaz de usuario en Android: Controles personalizados (III)

por sgoliver

En artículos anteriores del curso ya comentamos dos de las posibles vías que tenemos para crear controles personalizados en Android: la primera de ellas extendiendo la funcionalidad de un control ya existente, y como segunda opción creando un nuevo control compuesto por otros más sencillos.

En este nuevo artículo vamos a describir la tercera de las posibilidades que teníamos disponibles, que consiste en crear un control completamente desde cero, sin utilizar como base otros controles existentes. Como ejemplo, vamos a construir un control que reproduzca el comportamiento de un tablero del juego “Tres en Raya”.

intro

En las anteriores ocasiones vimos cómo el nuevo control creado siempre heredaba de algún otro control o contenedor ya existente. En este caso sin embargo, vamos a heredar nuestro contro directamente de la clase View (clase padre de la gran mayoría de elementos visuales de Android). Esto implica, entre otras cosas, que por defecto nuestro control no va a tener ningún tipo de interfaz gráfica, por lo que todo el trabajo de «dibujar» la interfaz lo vamos a tener que hacer nosotros. Además, como paso previo a la representación gráfica de la interfaz, también vamos a tener que determinar las dimensiones que nuestro control tendrá dentro de su elemento contenedor. Como veremos ahora, ambas cosas se llevarán a cabo redefiniendo dos eventos de la clase ViewonDraw() para el dibujo de la interfaz, y onMeasure() para el cálculo de las dimensiones.

Por llevar un orden cronológico, empecemos comentando el evento onMeasure(). Este evento se ejecuta automáticamente cada vez que se necesita recalcular el tamaño de un control. Pero como ya hemos visto en varias ocasiones, los elementos gráficos incluidos en una aplicación Android se distribuyen por la pantalla de una forma u otra dependiendo del tipo de contenedor o layout utilizado. Por tanto, el tamaño de un control determinado en la pantalla no dependerá sólo de él, sino de ciertas restricciones impuestas por su elemento contenedor o elemento padre. Para resolver esto, en el evento onMeasure() recibiremos como parámetros las restricciones del elemento padre en cuanto a ancho y alto del control, con lo que podremos tenerlas en cuenta a la hora de determinar el ancho y alto de nuestro control personalizado. Estas restricciones se reciben en forma de objetos MeasureSpec, que contiene dos campos: modo y tamaño. El significado del segundo de ellos es obvio, el primero por su parte sirve para matizar el significado del segundo. Me explico. Este campo modo puede contener tres valores posibles:

  • AT_MOST: indica que el control podrá tener como máximo el tamaño especificado.
  • EXACTLY: indica que al control se le dará exactamente el tamaño especificado.
  • UNSPECIFIED: indica que el control padre no impone ninguna restricción sobre el tamaño.

Dependiendo de esta pareja de datos, podremos calcular el tamaño deseado para nuestro control. Para nuestro control de ejemplo, apuraremos siempre el tamaño máximo disponible (o un tamaño por defecto de 100*100 en caso de no recibir ninguna restricción), por lo que en todos los casos elegiremos como tamaño de nuestro control el tamaño recibido como parámetro:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    int ancho = calcularAncho(widthMeasureSpec);
    int alto = calcularAlto(heightMeasureSpec);

    if(ancho < alto)
        alto = ancho;
    else
        ancho = alto;

    setMeasuredDimension(ancho, alto);
}

private int calcularAlto(int limitesSpec)
{
    int res = 100; //Alto por defecto

    int modo = MeasureSpec.getMode(limitesSpec);
    int limite = MeasureSpec.getSize(limitesSpec);

    if (modo == MeasureSpec.AT_MOST) {
        res = limite;
    }
    else if (modo == MeasureSpec.EXACTLY) {
        res = limite;
    }

    return res;
}

private int calcularAncho(int limitesSpec)
{
    int res = 100; //Ancho por defecto

    int modo = MeasureSpec.getMode(limitesSpec);
    int limite = MeasureSpec.getSize(limitesSpec);

    if (modo == MeasureSpec.AT_MOST) {
        res = limite;
    }
    else if (modo == MeasureSpec.EXACTLY) {
        res = limite;
    }

    return res;
}

Como nota importante, al final del evento onMeasure() siempre debemos llamar al método setMeasuredDimension() pasando como parámetros el ancho y alto calculados para nuestro control.

Con esto ya hemos determinado las dimensiones del control, por lo que tan sólo nos queda dibujar su interfaz gráfica, pero antes vamos a ver qué datos nos hará falta guardar para poder almacenar el estado del control y, entre otras cosas, poder dibujar su interfaz convenientemente.

Por un lado guardaremos en un array de 3×3 (tablero) el estado de cada casilla del tablero. Cada casilla la rellenaremos con un valor constante dependiendo de si contiene una ficha X, una ficha O, o si está vacía, valores para lo que definiremos tres constantes (FICHA_X, FICHA_O, VACIA). Guardaremos también los colores que utilizarán las fichas X y O (xColor y oColor), de forma que más tarde podamos personalizarlos. Y por último, almacenaremos también la ficha activa, es decir, el tipo de ficha que se colocará al pulsar sobre el tablero (fichaActiva).

public static final int VACIA = 0;
public static final int FICHA_O = 1;
public static final int FICHA_X = 2;

private int[][] tablero;
private int fichaActiva;
private int xColor;
private int oColor;

Todos estos datos los inicializaremos en un nuevo método inicializacion() que llamaremos desde nuestros constructores del control.

public TresEnRaya(Context context) {
    super(context);

    inicializacion();
}

public TresEnRaya(Context context, AttributeSet attrs, int defaultStyle) {
    super(context, attrs, defaultStyle);

    inicializacion();
}

public TresEnRaya(Context context, AttributeSet attrs) {
    super(context, attrs);

    inicializacion();
}

private void inicializacion() {
    tablero = new int[3][3];
    limpiar();

    fichaActiva = FICHA_X;
    xColor = Color.RED;
    oColor = Color.BLUE;
}

public void limpiar() {
    for(int i=0; i<3; i++)
        for(int j=0; j<3; j++)
            tablero[i][j] = VACIA;
}

Definiremos además una serie de métodos públicos del control para poder obtener y actualizar todos estos datos:

public void setFichaActiva(int ficha) {
    fichaActiva = ficha;
}

public int getFichaActiva() {
    return fichaActiva;
}

public void alternarFichaActiva()
{
    if(fichaActiva == FICHA_O)
        fichaActiva = FICHA_X;
    else
        fichaActiva = FICHA_O;
}

public void setXColor(int color) { xColor = color; }

public int getXColor() { return xColor; }

public void setOColor(int color) { oColor = color; }

public int getOColor() { return oColor; }

public void setCasilla(int fil, int col, int valor) { tablero[fil][col] = valor; }

public int getCasilla(int fil, int col) {
    return tablero[fil][col];
}

Pasemos ya al dibujo de la interfaz a partir de los datos anteriores. Como hemos indicado antes, esta tarea se realiza dentro del evento onDraw(). Este evento recibe como parámetro un objeto de tipo Canvas, sobre el que podremos ejecutar todas las operaciones de dibujo de la interfaz. No voy a entrar en detalles de la clase Canvas, por ahora nos vamos a conformar sabiendo que es la clase que contiene la mayor parte de los métodos de dibujo en interfaces Android, por ejemplo drawRect() para dibujar rectángulos, drawCircle() para círculos, drawBitmap() para imagenes, drawText() para texto, e infinidad de posibilidades más. Para consultar todos los métodos disponibles puedes dirigirte a la documentación oficial de la clase Canvas de Android. Además de la clase Canvas, también me gustaría destacar la clase Paint, que permite definir el estilo de dibujo a utilizar en los metodos de dibujo de Canvas, por ejemplo el ancho de trazado de las líneas, los colores de relleno, etc.

Para nuestro ejemplo no necesitaríamos conocer nada más, ya que la interfaz del control es relativamente sencilla. Vemos primero el código y después comentamos los pasos realizados:

@Override
protected void onDraw(Canvas canvas) {
    //Obtenemos las dimensiones del control
    int alto = getMeasuredHeight();
    int ancho = getMeasuredWidth();

    //Lineas
    Paint pBorde = new Paint();
    pBorde.setStyle(Style.STROKE);
    pBorde.setColor(Color.BLACK);
    pBorde.setStrokeWidth(2);

    canvas.drawLine(ancho/3, 0, ancho/3, alto, pBorde);
    canvas.drawLine(2*ancho/3, 0, 2*ancho/3, alto, pBorde);

    canvas.drawLine(0, alto/3, ancho, alto/3, pBorde);
    canvas.drawLine(0, 2*alto/3, ancho, 2*alto/3, pBorde);

    //Marco
    canvas.drawRect(0, 0, ancho, alto, pBorde);

    //Marcas
    Paint pMarcaO = new Paint();
    pMarcaO.setStyle(Style.STROKE);
    pMarcaO.setStrokeWidth(8);
    pMarcaO.setColor(oColor);

    Paint pMarcaX = new Paint();
    pMarcaX.setStyle(Style.STROKE);
    pMarcaX.setStrokeWidth(8);
    pMarcaX.setColor(xColor);

    //Casillas Seleccionadas
    for(int fil=0; fil<3; fil++) {
        for(int col=0; col<3; col++) {

            if(tablero[fil][col] == FICHA_X) {
                //Cruz
                canvas.drawLine(
                    col * (ancho / 3) + (ancho / 3) * 0.1f,
                    fil * (alto / 3) + (alto / 3) * 0.1f,
                    col * (ancho / 3) + (ancho / 3) * 0.9f,
                    fil * (alto / 3) + (alto / 3) * 0.9f,
                    pMarcaX);

                canvas.drawLine(
                    col * (ancho / 3) + (ancho / 3) * 0.1f,
                    fil * (alto / 3) + (alto / 3) * 0.9f,
                    col * (ancho / 3) + (ancho / 3) * 0.9f,
                    fil * (alto / 3) + (alto / 3) * 0.1f,
                    pMarcaX);
            }
            else if(tablero[fil][col] == FICHA_O) {
                //Circulo
                canvas.drawCircle(
                    col * (ancho / 3) + (ancho / 6),
                    fil * (alto / 3) + (alto / 6),
                    (ancho / 6) * 0.8f, pMarcaO);
            }
        }
    }
}

En primer lugar obtenemos las dimensiones calculadas en la última llamada a onMeasure() mediante los métodos getMeasuredHeight() y getMeasuredWidth(). Posteriormente definimos un objeto Paint que usaremos para dibujar la lineas interiores del tablero. Para indicar que se trata de un color de linea utilizaremos la llamada a setStyle(Style.STROKE) (si quisiéramos definir un color de relleno utilizaríamos Style.FILL). Por su parte, el color y el ancho de la línea lo definimos mediante setColor() y setStrokeWidth(). Tras esto, ya sólo debemos dibujar cada una de las lineas en su posición correspondiente con drawLine(), que recibe como parámetros las coordenadas X e Y de inicio, las coordenadas X e Y de fin, y el estilo de linea a utilizar, por ese orden. Por último, dibujamos el marco exterior del tablero mediante drawRect().

Lo siguiente será dibujar las fichas que haya colocadas en el tablero. Para ello definimos primero dos nuevos objetos Paint con los colores definidos en los atributos xColor y oColor. Posteriormente recorremos el array que representa al tablero y dibujamos las fichas en su posición dependiendo de su tipo. Las fichas de tipo X las dibujaremos mediante drawLine() y las de tipo O mediante drawCircle(). Los cálculos para saber la posición de cada linea o círculo son laboriosos pero muy sencillos si se estudian con detenimiento.

El siguiente paso será definir su funcionalidad implementando los eventos a los que queramos que responda nuestro control, tanto eventos internos como externos.

En nuestro caso sólo vamos a tener un evento de cada tipo. En primer lugar definiremos un evento interno (evento que sólo queremos capturar de forma interna al control, sin exponerlo al usuario) para responder a las pulsaciones del usuario sobre las distintas casillas del tablero, y que utilizaremos para actualizar el dibujo de la interfaz con la nueva ficha colocada. Para ello implementaremos el evento onTouch(), lanzado cada vez que el usuario toca la pantalla sobre nuestro control. La lógica será sencilla, simplemente consultaremos las coordenadas donde ha pulsado el usuario (mediante los métodos getX() y getY()), y dependiendo del lugar pulsado determinaremos a qué casilla del tablero se corresponde y actualizaremos el array tablero con el valor de la ficha actual fichaActiva. Finalmente, llamamos al método invalidate() para refrescar la interfaz del control:

@Override
public boolean onTouchEvent(MotionEvent event)
{
    int fil = (int) (event.getY() / (getMeasuredHeight()/3));
    int col = (int) (event.getX() / (getMeasuredWidth()/3));

    //Actualizamos el tablero
    tablero[fil][col] = fichaActiva;

    //Refrescamos el control
    this.invalidate();

    return super.onTouchEvent(event);
}

En segundo lugar crearemos un evento externo personalizado, que lanzaremos cuando el usuario pulse sobre una casilla del tablero. Llamaremos a este evento onCasillaSeleccionada(). Para crearlo actuaremos de la misma forma que ya vimos en el artículo anterior. Primero definiremos una interfaz para el listener de nuestro evento:

package net.sgoliver.android.controlpers3;

public interface OnCasillaSeleccionadaListener
{
    void onCasillaSeleccionada(int fila, int columna);
}

Posteriormente, definiremos un objeto de este tipo como atributo de nuestro control y escribiremos un nuevo método que permita a las aplicaciones suscribirse al evento:

public class TresEnRaya extends View
{
     private OnCasillaSeleccionadaListener listener;

     //...

     public void setOnCasillaSeleccionadaListener(OnCasillaSeleccionadaListener l)
     {
	     listener = l;
     }
}

Y ya sólo nos quedaría lanzar el evento en el momento preciso. Esto también lo haremos dentro del evento onTouch(), cuando detectemos que el usuario ha pulsado sobre una casilla del tablero:

@Override
public boolean onTouchEvent(MotionEvent event)
{
    int fil = (int) (event.getY() / (getMeasuredHeight()/3));
    int col = (int) (event.getX() / (getMeasuredWidth()/3));

    tablero[fil][col] = fichaActiva;

    //Lanzamos el evento de pulsación
    if (listener != null) {
        listener.onCasillaSeleccionada(fil, col);
    }

    //Refrescamos el control
    this.invalidate();

    return super.onTouchEvent(event);
}

Con esto, nuestra aplicación principal ya podría suscribirse a este nuevo evento para estar informada cada vez que se seleccione una casilla. En la aplicación de ejemplo he incluido, además del tablero (terTablero), un botón para alternar la ficha activa (btnFicha), y una etiqueta de texto para mostrar la casilla seleccionada (txtCasilla) haciendo uso de la información recibida en el evento externo onCasillaSeleccionada():

public class MainActivity extends ActionBarActivity {

    private Button btnFicha;
    private TresEnRaya terTablero;
    private TextView txtCasilla;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        terTablero = (TresEnRaya)findViewById(R.id.tablero);
        btnFicha = (Button)findViewById(R.id.btnFicha);
        txtCasilla = (TextView)findViewById(R.id.txtCasilla);

        btnFicha.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                terTablero.alternarFichaActiva();
            }
        });

        terTablero.setOnCasillaSeleccionadaListener(new OnCasillaSeleccionadaListener() {
            @Override
            public void onCasillaSeleccionada(int fila, int columna) {
                txtCasilla.setText("Última casilla seleccionada: " + fila + "." + columna);
            }
        });
    }

    //....
}

Por último, al igual que en el apartado anterior, voy a definir dos atributos XML personalizados para poder indicar los colores de las fichas X y O desde el propio layout XML. Para ellos, creamos primero un nuevo fichero /res/values/attrs.xml :

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="TresEnRaya">
        <attr name="ocolor" format="color"/>
        <attr name="xcolor" format="color"/>
    </declare-styleable>
</resources>

Posteriormente, accedemos a estos atributos desde nuestros constructores para establecer con ellos los valores de las variables xColor y oColor, esto ya lo vimos en el apartado anterior sobre controles compuestos:

public TresEnRaya(Context context) {
    super(context);

    inicializacion();
}

public TresEnRaya(Context context, AttributeSet attrs, int defaultStyle) {
    super(context, attrs, defaultStyle );

    inicializacion();

    // Procesamos los atributos XML personalizados
    TypedArray a =
           getContext().obtainStyledAttributes(attrs,
                   R.styleable.TresEnRaya);

    oColor = a.getColor(
           R.styleable.TresEnRaya_ocolor, Color.BLUE);

    xColor = a.getColor(
           R.styleable.TresEnRaya_xcolor, Color.RED);

    a.recycle();
}

public TresEnRaya(Context context, AttributeSet attrs) {
    super(context, attrs);

    inicializacion();

    // Procesamos los atributos XML personalizados
    TypedArray a =
           getContext().obtainStyledAttributes(attrs,
                   R.styleable.TresEnRaya);

    oColor = a.getColor(
           R.styleable.TresEnRaya_ocolor, Color.BLUE);

    xColor = a.getColor(
           R.styleable.TresEnRaya_xcolor, Color.RED);

    a.recycle();
}

Tras definir nuestros atributos, podemos ver cómo quedaría el layout de nuestra actividad principal haciendo uso de ellos:

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:sgo="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <net.sgoliver.android.controlpers3.TresEnRaya
        android:id="@+id/tablero"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="10dp"
        sgo:ocolor="#0000FF"
        sgo:xcolor="#FF0000" />

    <Button android:id="@+id/btnFicha"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Cambiar Ficha" />

    <TextView android:id="@+id/txtCasilla"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp" />

</LinearLayout>

Lo más relevante del código anterior, como ya comentamos, es la declaración de nuestro espacio de nombres local xmlns:sgo, y la asignación de los atributos precedidos por el prefijo elegido, sgo:xcolor y sgo:ocolor.

Con esto, tendríamos finalizado nuestro control completamente personalizado, que hemos construido sin utilizar como base ningún otro control predefinido, definiendo desde cero tanto su aspecto visual como su funcionalidad interna o sus eventos públicos. Veamos cómo queda visualmente:

demo

Puedes consultar y/o descargar el código completo de los ejemplos desarrollados en este artículo accediendo a la pagina del curso en GitHub.

You may also like

8 comentarios

Desarrollo en Android | sgoliver.net blog 10/02/2011 - 11:43

[…] Interfaz de usuario en Android: Controles personalizados (III) […]

Responder
Tweets that mention Interfaz de usuario en Android: Controles personalizados (III) | sgoliver.net blog -- Topsy.com 10/02/2011 - 12:30

[…] This post was mentioned on Twitter by Apps & News Android, Toni Gomez. Toni Gomez said: RT @sgolivernet: Publicado el artículo que faltaba sobre controles personalizados en #Android:Controles creados desde 0 http://bit.ly/h25Qr5 […]

Responder
barney2144 22/02/2011 - 12:21

Los tutoriales están muy bien, no has pensado en ir juntandolos todos en un ebook pdf? estaría interesante.
Saludos!

Responder
Angel 18/03/2011 - 11:18

Tus ejemplos son la panacea del desarrollador de android.
Gracias.
Salud y libertad

Responder
Juan jose 10/01/2012 - 21:28

muy buen turorial. tengo una pregunta: que pongo para que dependiando del color q uno escoja le cambien el texto y el color a un Text View?

Responder
Juan Carlos 08/03/2012 - 19:36

No entiendo mucho de java, pero según el código, el uso del evento es obligatorio. Tenemos un control del cual hay que «crear» el código del evento onCLick. Si no te petará por la llamada de OnTouch()…
Como ya te he dicho que no tengo mucha experiencia con java:
1.-¿Estoy en lo cierto?
2.-Si lo estoy, ¿Cómo podríamos poner un evento opcional en nuestro nuevo control, sin obligar a usarlo, o definir una función para él? Es por reutilizar el control… ya sé qeu si no usas ese evento no tiene mucha utilidad pero aún así me gustaría hacerlo «opcional».

Responder
Carlos 07/01/2015 - 22:43

Que tal nuevamente buenas tardes,

nuevamente por acá solicitándoles amablemente su apoyo, actualmente estoy trabajando en un aplicación para android y he tendido un pequeño problema, ya que cuando ejecuto mi aplicación en mi celular se ve bien siempre y cuando se encuentre vertical, el problema surge cuando muevo la pantalla de forma horizontal, si alguien me pudiera orientar como podría solucionar este problema se los agradecería.

gracias por su apoyo

Responder
jean carlo 17/11/2015 - 3:04

bueno espero alguien me responda mi incógnita ya que estoy empezando en el mundo de desarrollar apps en android y me he interesado demasiado y bueno lo que quisiera saber es como puedo poner librerías externas en android studio si alguien me puede hechar un cable de antemano muchas gracias.
MUY BUENOS TUTORIALES MUCHAS GRACIAS CADA VEZ MÁS AMPLIO MIS CONOCIMIENTO

Responder

Dejar un comentario

Uso de cookies

Este sitio web utiliza cookies para que usted tenga la mejor experiencia de usuario. Si continúa navegando está dando su consentimiento para la aceptación de las mencionadas cookies y la aceptación de nuestra política de cookies, pinche el enlace para mayor información.plugin cookies

ACEPTAR
Aviso de cookies