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 control 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 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 fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    var ancho = calcularAncho(widthMeasureSpec)
    var alto = calcularAlto(heightMeasureSpec)

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

    setMeasuredDimension(ancho, alto);
}

private fun calcularAlto(limitesSpec: Int): Int {
    var res = 100 //Alto por defecto

    val modo = MeasureSpec.getMode(limitesSpec)
    val limite = MeasureSpec.getSize(limitesSpec)

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

    return res
}

private fun calcularAncho(limitesSpec: Int): Int {
    var res = 100 //Ancho por defecto

    val modo = MeasureSpec.getMode(limitesSpec)
    val 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_XFICHA_OVACIA). 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). Aprovecharemos su definición para inicializar también cada miembro con su valor por defecto.

class TresEnRaya : View {
    companion object{
        const val VACIA = 0
        const val FICHA_O = 1
        const val FICHA_X = 2
    }

    private val tablero = Array(3){Array(3){0}}
    private var fichaActiva = FICHA_X
    private var xColor = Color.RED
    private var oColor = Color.BLUE

    //...
}

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

fun limpiar() {
    for(i in 0..2)
        for(j in 0..2)
            tablero[i][j] = 0
}

fun setCasilla(fil: Int, col: Int, valor: Int) {
    tablero[fil][col] = valor
}

fun getCasilla(fil: Int, col: Int) : Int {
    return tablero[fil][col]
}

fun alternarFichaActiva() {
    fichaActiva = if (fichaActiva == FICHA_O) FICHA_X else FICHA_O
}

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 imágenes, 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 métodos 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:

private val pBorde = Paint().apply {
    style = Paint.Style.STROKE
    color = Color.BLACK
    strokeWidth = 4f
}

private val pMarcaO = Paint().apply {
    style = Paint.Style.STROKE
    strokeWidth = 10f
}

private val pMarcaX = Paint().apply {
    style = Paint.Style.STROKE
    strokeWidth = 10f
}

//...

override fun onDraw(canvas: Canvas?) {
    //Obtenemos las dimensiones del control
    val alto = measuredHeight
    val ancho = measuredWidth

    //Lineas
    canvas!!.drawLine((ancho / 3).toFloat(), 0f, (ancho / 3).toFloat(), alto.toFloat(), pBorde)
    canvas.drawLine(
        (2 * ancho / 3).toFloat(), 0f,
        (2 * ancho / 3).toFloat(),
        alto.toFloat(), pBorde
    )

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

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

    //Marcas
    pMarcaO.color = oColor
    pMarcaX.color = xColor

    //Casillas Seleccionadas
    for (fil in 0..2) {
        for (col in 0..2) {
            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).toFloat(),
                    (fil * (alto / 3) + alto / 6).toFloat(),
                    ancho / 6 * 0.8f, pMarcaO
                )
            }
        }
    }
}

En primer lugar definimos como miembro de la clase un objeto de tipo Paint (pBorde) que usaremos para dibujar la lineas interiores del tablero. Para indicar que se trata de un color de linea asignaremos a la propiedad style el valor Paint.Style.STROKE (si quisiéramos definir un color de relleno utilizaríamos Paint.Style.FILL). Por su parte, el color y el ancho de la línea lo definimos mediante las propiedades color y strokeWidth.

Pasando ya a la lógica de dibujo, primero obtenemos las dimensiones calculadas en la última llamada a onMeasure() mediante las propiedades measuredHeight y measuredWidth. 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 las propiedades x e y), 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 fun onTouchEvent(event: MotionEvent?): Boolean {
    val fil = (event!!.y / (measuredHeight / 3)).toInt()
    val col = (event.x / (measuredWidth / 3)).toInt()

    //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

interface OnCasillaSeleccionadaListener {
    fun onCasillaSeleccionada(fila: Int, columna: Int)
}

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:

fun setOnCasillaSeleccionadaListener(l: OnCasillaSeleccionadaListener) {
    listener = l
}

fun setOnCasillaSeleccionadaListener(seleccion: (Int, Int) -> Unit) {
    listener = object:OnCasillaSeleccionadaListener {
        override fun onCasillaSeleccionada(fila: Int, columna: Int) {
            seleccion(fila, columna)
        }
    }
}

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 fun onTouchEvent(event: MotionEvent?): Boolean {
    val fil = (event!!.y / (measuredHeight / 3)).toInt()
    val col = (event.x / (measuredWidth / 3)).toInt()

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

    //Lanzamos el evento de pulsación
    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():

class MainActivity : AppCompatActivity() {

    private lateinit var btnFicha : Button
    private lateinit var terTablero : TresEnRaya
    private lateinit var lblCasilla : TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        terTablero = findViewById(R.id.tablero)
        btnFicha = findViewById(R.id.btnFicha)
        lblCasilla = findViewById(R.id.lblCasilla)

        btnFicha.setOnClickListener {
            terTablero.alternarFichaActiva()
        }

        terTablero.setOnCasillaSeleccionadaListener { fila, columna ->
            lblCasilla.text = "Ú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 :

<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:

constructor(ctx: Context, attrs: AttributeSet) : super(ctx, attrs) {
    context.theme.obtainStyledAttributes(
        attrs,
        R.styleable.TresEnRaya, 0, 0).apply {
        try {
            oColor = getColor(R.styleable.TresEnRaya_ocolor, Color.BLUE)
            xColor = getColor(R.styleable.TresEnRaya_xcolor, Color.RED)
        } finally {
            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:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="8dp"
    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"
        app:ocolor="#0000FF"
        app: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/lblCasilla"
        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:app, y la asignación de los atributos precedidos por el prefijo elegido, app:xcolor y app: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:

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.

También te puede interesar

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

Responder a barney2144 Cancelar respuesta

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. Aceptar Más Información

Política de Privacidad y Cookies