Controles planos y sensibles al tacto en .NET

Efecto bidimensional y reacción al contacto redibujando el control con GDI+.

Publicado: 12/Ago/2003
Agosto 2003
Cipriano Valdezate Sayalero
.

Índice

Introducción

VSNET permite eliminar el efecto tridimensional de los controles mediante las propidades FlatStyle y BorderStyle, sin embargo el borde es fijo. Los controles que aquí presento son sensibles al tacto, que es más que sensibilidad al foco: simplemente, alteran el color de los bordes no sólo al recibir el foco, sino también al pasar por encima el ratón. Ya sé que los hay en la web a patadas mucho más refinados que los míos, precisamente por eso oso publicarlos: porque son sencillísimos de implementar. No están hechos desde cero (me encanta esa expresión: from scratch), no llamo a ninguna API, no les he preparado skins ni estilo XP, sino que son totalmente artesanales, al alcance del principiante: un brochazo y cuatro rayas y a correr.

FlatTextBox y controles de un solo borde rectangular

Todo lo que tenemos que hacer es subclasificar el control, es decir, introducir en la corriente de los mensajes que lanza el sistema operativo a nuestro control un procedimiento escrito por nosotros justo antes del procedimiento propio del control (window procedure, abreviado, WndProc), y obligar a los mensajes a atravesar nuestro procedimiento antes de aterrizar en WndProc, de tal modo que podamos introducir código que tome decisiones basadas en los mensajes.

Ya estamos que si la abuela fuma. ¿No decías que iba a ser fácil? A mí eso me suena a puro C++ y a subclasificación en Visual Basic 6, en resumen, a vade retro.

Pues yo sigo en mis trece, vamos a hacer todo eso y ni nos vamos a enterar. Esta es otra de las maravillas de la plataforma .NET. Aquí lo tenéis:


Public Class FlatTextBox : Inherits TextBox
       ' Este es el mensaje que debemos discriminar
       Private Const WM_PAINT As Integer = &HF

        ' Este es el procedimiento que insertamos en la corriente de los mensajes
		' Su parámetro es una encapsulación de la estructura Message
		' que entra en el procedimiento
        Protected Overrides Sub WndProc(ByRef m As Message)
		' Ya sabemos qué mensaje es, luego ya no lo necesitamos
		' y lo despachamos al procedimiento del control
        MyBase.WndProc(m)
		' Aqui ejecutamos código según qué mensaje nos ha visitado
            If m.Msg = WM_PAINT Then
		'
		' Aquí dibujamos
		'
	     End If
	 End sub
End Class

Primero creamos una clase derivada del control que vamos a subclasificar y definimos la constante que representa el mensaje. Después reimplementamos el método WndProc del control, cuyo único argumento es una versión encapsulada de la estructura Message pasada por referencia. En realidad estamos interponiendo antes del procedimiento del control WndProc nuestro propio procedimiento, y estamos obligando a los mensajes a circular por él. Nada más entrar, sin embargo, les abrimos la puerta de salida y permitimos que se vayan al procedimiento propio del control invocando el método WndProc de la clase base. Y es que no necesitamos los mensajes: nos basta con actuar cuando detectamos que se trata de WM_PAINT.

WM_PAINT le obliga al control a dibujarse de nuevo. Y aquí viene la sensibilización del control. Sólo tenemos que definir las distintas variaciones del efecto bidimensional y decidir cuándo pintaremos cuál. Por ejemplo: queremos que cuando el ratón pase por encima, el botón se oscurezca. Pues en el momento en que acariciemos el botón con el ratón le enviaremos el mensaje WM_PAINT, y al atravesar nuestro procedimiento interpuesto WndProc lo detectaremos y pintaremos el botón de color oscuro.

Crearemos un efecto "resaltado" cuando se disparen los eventos OnMouseEnter, OnGotFocus y OnMouseHover, y volveremos a un aspecto "neutral" cuando ocurran OnMouseLeave y OnLostFocus. Declararemos una variable booleana a nivel de clase que indicará a WndProc si debe dibujar estilo resaltado o neutral. Así pues, cada evento dará un valor a esta variable e invocará el método Me.Invalidate(), que es quien envía el mensaje WM_PAINT al control:


        Private IamON As Boolean

        Protected Overrides Sub OnMouseEnter(ByVal e As System.EventArgs)
            MyBase.OnMouseEnter(e)
            IamON = True
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnMouseLeave(ByVal e As System.EventArgs)
            MyBase.OnMouseLeave(e)
            IamON = False
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnLostFocus(ByVal e As System.EventArgs)
            MyBase.OnLostFocus(e)
            IamON = False
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnGotFocus(ByVal e As System.EventArgs)
            MyBase.OnGotFocus(e)
            IamON = True
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnMouseHover(ByVal e As System.EventArgs)
            MyBase.OnMouseHover(e)
            IamON = True
            Me.Invalidate()
        End Sub

Ya sólo nos queda dibujar en el control. Necesitamos dos colores, así que los definiremos a nivel de clase y les daremos valores por defecto. Por supuesto que invitaremos al código cliente, ofreciéndole propiedades, a que los modifique. Y no hay que olvidar que la propiedad BorderStyle o FlatStyle del control debe definirse al construir la clase con un valor y no otro. Cuál de ellos depende del control, es cosa de probar. Aquí está la clase entera:


Imports System.Drawing.Drawing2D

Namespace ControlesFlacos

    Public Class FlatTextBox : Inherits TextBox
        Private Const WM_PAINT As Integer = &HF

        Private _ActiveBorderColor As Color = SystemColors.ControlDarkDark
        Private _InactiveBorderColor As Color = SystemColors.ControlDark
        Private _BorderColor As Color
        Private IamON As Boolean

        Public Sub New()
            MyBase.New()
            Me.BorderStyle = BorderStyle.FixedSingle
        End Sub

        Public Property ActiveBorderColor() As Color
            Get
                Return _ActiveBorderColor
            End Get
            Set(ByVal Value As Color)
                _ActiveBorderColor = Value
            End Set
        End Property

        Public Property InactiveBorderColor() As Color
            Get
                Return _InactiveBorderColor
            End Get
            Set(ByVal Value As Color)
                _InactiveBorderColor = Value
            End Set
        End Property

        Protected Overrides Sub OnMouseEnter(ByVal e As System.EventArgs)
            MyBase.OnMouseEnter(e)
            IamON = True
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnMouseLeave(ByVal e As System.EventArgs)
            MyBase.OnMouseLeave(e)
            IamON = False
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnLostFocus(ByVal e As System.EventArgs)
            MyBase.OnLostFocus(e)
            IamON = False
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnGotFocus(ByVal e As System.EventArgs)
            MyBase.OnGotFocus(e)
            IamON = True
            Me.Invalidate()
        End Sub

        Protected Overrides Sub OnMouseHover(ByVal e As System.EventArgs)
            MyBase.OnMouseHover(e)
            IamON = True
            Me.Invalidate()
        End Sub

        Protected Overrides Sub WndProc(ByRef m As Message)
            MyBase.WndProc(m)
            If m.Msg <> WM_PAINT Then Exit Sub

            Dim g As Graphics = Me.CreateGraphics
            Dim cliente As Rectangle = Me.ClientRectangle

            If IamON Then
                _BorderColor = _ActiveBorderColor
            Else
                _BorderColor = _InactiveBorderColor
            End If

            g.DrawRectangle(New Pen(_BorderColor), 0, 0, cliente.Width - 1, cliente.Height - 1)
            g.Dispose()

        End Sub
    End Class
End Namespace

Como véis, al final todo desemboca en el rectángulo que simula el borde del control. Sus coordenadas las obtenemos de la propiedad Me.ClientRectangle, de modo que sólo nos queda dibujarlo con GDI+.

(Nota: lo de ControlesFlacos va de guasa. Ya sé que flat no significa flaco Tampoco library significa librería y todos vamos por ahí con nuestras dynamic link bookshops y nos quedamos más anchos que largos. Así que yo también reclamo mi derecho a pertenecer al heap de los patanes e inauguro emocionado mi pésima traducción. (Nota a la nota: lo que pasa es que me acabo de leer La rebelión de las masas de Ortega y Gasset y todavía lo tengo fresco))

En los programas de ejemplo podéis ver que, salvo pequeños detalles, esta misma clase se aplica a los controles con un sólo borde rectangular, como ListView, TreeView, Listbox y Button. Al botón, además de pintarle el borde, le alteramos la propiedad BackColor de forma que, con el permiso del código cliente, al acariciarlo con el cursor se oscurece. Talmente como si se ruborizara.

FlatCheckBox y FlatRadioButton

CheckBox y RadioButton muestran dos peculiaridades:

Resolver la primera peculiaridad no tiene ningún misterio: sólo hay que calcular las coordenadas y el tamaño de la figura y dibujarla. La herramienta que he utilizado ha sido el ojímetro.

La segunda peculiaridad se resuelve con la misma herramienta que la primera. Introducimos un Select Case y calculamos las coordenadas para cada posición. Yo a tanto no he llegado. Mis FlatCheckBox y FlatRadioButton sólo admiten las dos alineaciones de toda la vida: a la izquierda y a la derecha. Copio el procedimiento WndProc del FlatRadioButton:


        Protected Overrides Sub WndProc(ByRef m As Message)
            MyBase.WndProc(m) : If m.Msg <> WM_PAINT Then Exit Sub

            Const DIAM As Integer = 11
            Dim Cliente As RectangleF
            Dim g As Graphics = Me.CreateGraphics

            Select Case Me.CheckAlign
                Case ContentAlignment.MiddleLeft
                    Cliente = New RectangleF(Me.ClientRectangle.X, _
                    Me.ClientRectangle.Height / 2 - DIAM / 2, DIAM, DIAM)
                    Exit Select
                Case ContentAlignment.MiddleRight
                    Cliente = New RectangleF(Me.ClientRectangle.Width - DIAM, _
                    Me.ClientRectangle.Height / 2 - DIAM / 2, DIAM, DIAM)
                    Exit Select
                Case Else
                    Exit Sub
            End Select

            If IamON = True Then
                _BorderColor = _ActiveBorderColor
            Else
                _BorderColor = _InactiveBorderColor
            End If

            g.SmoothingMode = Drawing2D.SmoothingMode.HighQuality
            g.DrawEllipse(New Pen(_BorderColor), Cliente)
        End Sub

Dignos de mención dos detalles: dada la necesaria pixelmétrica precisión, en vez de Rectangle se utiliza RectangleF, que se mide con decimales; y, para que no quede basto, suavizamos la línea con SmoothingMode.

FlatComboBox

Un control especial

El caso del ComboBox es especial porque se compone de tres controles interrelacionados: un TextBox, un Listbox y un botón. El ListBox ni lo tocamos porque es imposible que aparezca y el ComboBox no tenga el foco, así que redibujaremos la caja de texto, el botón y su flechita.

Tenemos que definir tres parejas de colores: una para la caja de texto, otra para el botón y otra para la flechita. Primero cubrimos el control entero de blanco y luego vamos perfilando cada elemento. Muestro el código de WndProc en C# porque el programa de ejemplo está en C#:


protected override void WndProc(ref Message m)
{
	base.WndProc (ref m);

	if (m.Msg == WM_PAINT)
	{
		//Definimos el rectángulo que abarca 
		//la caja de texto y el botón
		Graphics g = this.CreateGraphics();
		Rectangle Cliente = this.ClientRectangle;

		//Establecemos los colores
		if (IAmOn == true)
		{
			_BorderColor = _ActiveBorderColor;
			_ArrowColor = _ActiveArrowColor;
			_ButtonColor = _ActiveButtonColor;
		}
		else
		{
			_BorderColor = _InactiveBorderColor;
			_ArrowColor = _InactiveArrowColor;
			_ButtonColor = _InactiveButtonColor;
		}

		//Pintamos el control entero de blanco
		//para eliminar todo rastro de tres dimensiones
		g.FillRectangle(new SolidBrush(SystemColors.Window), Cliente);

		//Dibujamos el borde de todo el control
		g.DrawRectangle(new Pen(_BorderColor),0,0,
			Cliente.Width, Cliente.Height-1);

		//Definimos la localización y el tamaño del botón
		Point Punto = new Point(Cliente.Width-18,0);
		Size Area = new Size(Cliente.Width-Punto.X,
			Cliente.Height-Punto.Y);

		Rectangle Boton = new Rectangle(Punto, Area);

		//y lo pintamos
		g.FillRectangle(new SolidBrush(_ButtonColor), Boton);

		//Movemos el eje de coordenadas a la esquina noroeste del botón
		//para dibujar más cómodamente
		g.TranslateTransform(Boton.X,Boton.Y);

		//Dibujamos el borde del botón
		g.DrawRectangle(new Pen(_BorderColor),0,0,Boton.Width-1,Boton.Height-1);

		//Definimos un GraphicsPath que contendrá el dibujo de la flecha
		GraphicsPath Flecha = new GraphicsPath();

		PointF NO = new PointF(Boton.Width / 4, 9 * Boton.Height / 24);
		PointF NE = new PointF(3 * Boton.Width / 4, NO.Y);
		PointF SU = new PointF(Boton.Width / 2, 15 * Boton.Height / 24);

		Flecha.AddLine(NO, NE);
		Flecha.AddLine(NE, SU);

		//suavizamos los bordes en lo posible
		g.SmoothingMode = SmoothingMode.AntiAlias;

		//y dibujamos la flecha
		g.FillPath(new SolidBrush(_ArrowColor), Flecha);

		g.Dispose();
	}
}

Hasta aquí va sobre ruedas. Pero resulta que si aplicamos al FlatComboBox el estilo DropDownList, que obliga al control a no admitir más ítems que los cargados en su lista de ítems, ¡se borra el contenido de la caja de texto al perder el control el foco! Y esto sí que no me lo ha dicho nadie en ninguna página web, que lo mío me costó descubrir porqué ocurría. Solución: implementar el estilo DropDownList "a mano". Y ya que no nos queda más remedio, vamos a hacerlo a nuestro gusto. Crearemos dos estilos: los dos autocompletarán la caja de texto con los ítems de la lista, pero uno de ellos permitirá introducir ítems nuevos y el otro no.

Autocompletado con admisión de ítems nuevos

En vez de crear una enumeración que contuviera los estilos que vamos a crear y una propiedad del tipo de la enumeración dentro de la clase FlatComboBox, he optado por crear tres controles diferentes, uno por cada estilo. A gusto del consumidor. Comento el código en el mismo código:


/// FlatComboBox que se autocompleta
/// y admite entradas nuevas
public class AutoComboBox : FlatComboBox
{
	bool _AutoComplete = true;

	public AutoComboBox():base()
	{
	    //El estilo debe ser DropDown
		//para evitar que nos ocurra lo que queremos evitar
		base.DropDownStyle = ComboBoxStyle.DropDown;
	}
	protected override void OnKeyDown(KeyEventArgs e)
	{
		//No aplicamos el autocompletado 
		//si las teclas pulsadas son Suprimir o Borrar
		_AutoComplete = ((e.KeyCode != Keys.Delete) && (e.KeyCode != Keys.Back));
		base.OnKeyDown (e);
	}

	protected override void OnTextChanged(EventArgs e)
	{
		if (_AutoComplete == true)
		{
			string Texto = this.Text;
			//FindString localiza el primer ítem de la lista
			//cuyas primeras letras coinciden con las de su argumento
			//Está claro que es una función pensada 
			//para autocompletar el ComboBox.
			int Index = this.FindString(Texto);
			if (Index >= 0)
			{
				//Al cargar la caja de texto con el ítem localizado
				//se dispara otra vez el evento OnTextChanged
				//o sea, este procedimiento,
				//que volvería a cambiar el contenido de la caja de texto
				//aunque fuera con el mismo texto,
				//lo cual volvería a disparar el evento OnTextChanged
				//formándose un ciclo sin fin.
				//Por eso hay que crear una variable 
				//de tipo booleano que haga de semáforo.
				_AutoComplete = false;
				this.SelectedIndex = Index;
				_AutoComplete = true;

				//Al ir autocompletando mantenemos seleccionado
				//el fragmento de texto que no se ha autocompletado.
				//Esto permite autocompletar el ítem entero
				//tecleando sólo las letras de la palabra.
				this.Select(Texto.Length, this.Text.Length);
			}
		}
		base.OnTextChanged (e);
	}
}

Autocompletado sin admisión de ítems nuevos

El evento dentro del cual podemos discriminar los caracteres que admitimos en la caja de texto es, como todo NETadicto sabe, OnKeyPress. Buscamos en la lista el texto de la caja de texto más el carácter nuevo, y si lo encontramos dejamos pasar al carácter, y si no, no. Este procedimiento no sustituye sino que se añade al anterior.


protected override void OnKeyPress(System.Windows.Forms.KeyPressEventArgs e)
{
	if (base.AutoComplete == true)
	{
		e.Handled = true;

		//Añadimos al texto del control el carácter nuevo
		string Texto = 
			this.Text.Substring(0, this.Text.Length - this.SelectedText.Length)
			+ e.KeyChar;

		//Lo buscamos en la lista y si lo encontramos 
		//permitimos que se una al texto
		if (this.FindString(Texto) >= 0) e.Handled = false;

	}

	base.OnKeyPress (e);
}

Código de ejemplo

En Visual Basic están ejemplificados los controles Listview, TreeView, ListBx, TextBox, CheckBox, Radiobutton y Button. El ComboBox lo he reservado para C#. Y de regalo un formulario con quintuplas de los controles TextBox, Button, RadioButton y CheckBox en C++NET que disparan un evento cuando cambia el color del borde. Si supiera cómo se pasan argumentos por referencia en J# os habría ofrecido una versión en este idioma, pero no lo sé. Si alguien me lo explica se lo agradezco.

Que seáis felices
Cipri

Pulsa aquí para descargarte el ejemplo en VBNET
Pulsa aquí para descargarte el ejemplo en C#
Pulsa aquí para descargarte el ejemplo en C++NET


ir al índice